From 7ffbb0145e228a9a71974342abf8b67c63a4f2e0 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Wed, 19 Aug 2026 22:30:27 +0530 Subject: [PATCH 1/6] fix: raise max_total_diff_chars default and report files dropped by limits --- packages/core/src/review/finalize.ts | 33 ++++++++++++++++++-- packages/schema/src/schema-repo-config.ts | 6 +++- test/review/fragmented-packing.spec.ts | 12 +++++++- test/review/partial-review-message.spec.ts | 36 ++++++++++++++++++++++ 4 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 test/review/partial-review-message.spec.ts diff --git a/packages/core/src/review/finalize.ts b/packages/core/src/review/finalize.ts index cd2712d1..ec4959f3 100644 --- a/packages/core/src/review/finalize.ts +++ b/packages/core/src/review/finalize.ts @@ -12,6 +12,31 @@ import { FRESH_INVOCATION_YIELD_SECONDS } from '../constants'; import { sendReviewTelemetry } from './telemetry'; import { applyFindingGates } from './gate-pipeline'; +/** + * Why a completed review is less than the whole pull request, for the job record the dashboard reads. + * + * Files dropped by the limits count as partial too. They used to be reported only in the PR comment, + * which no longer carries that line, so without this a truncated run reports plain success -- which is + * how a 250-file pull request reviewed 25 files and said nothing. + */ +export function partialReviewMessage(input: { + failedFileCount: number; + reviewedFileCount: number; + filesOverCap: number; +}): string | null { + const plural = (n: number) => (n === 1 ? '' : 's'); + const reasons: string[] = []; + + if (input.failedFileCount > 0) { + reasons.push(`${input.failedFileCount} of ${input.reviewedFileCount} file${plural(input.reviewedFileCount)} could not be reviewed`); + } + if (input.filesOverCap > 0) { + reasons.push(`${input.filesOverCap} file${plural(input.filesOverCap)} left out by the file and diff-size limits`); + } + + return reasons.length > 0 ? `Partial review: ${reasons.join('; ')}.` : null; +} + export async function runFinalizePhase( env: ReviewRuntime, job: PersistedReviewJob, @@ -237,9 +262,11 @@ export async function runFinalizePhase( severityDistribution[sev] = (severityDistribution[sev] || 0) + 1; } - const partialErrorMessage = hasFailures - ? `Partial review: ${failedFileCount} of ${files.length} file${files.length === 1 ? '' : 's'} could not be reviewed.` - : null; + const partialErrorMessage = partialReviewMessage({ + failedFileCount: hasFailures ? failedFileCount : 0, + reviewedFileCount: files.length, + filesOverCap, + }); await env.jobs.completeJob(job.id, { verdict: verdictSummary.verdict, fileCount: files.length, diff --git a/packages/schema/src/schema-repo-config.ts b/packages/schema/src/schema-repo-config.ts index 0ef60b69..12956280 100644 --- a/packages/schema/src/schema-repo-config.ts +++ b/packages/schema/src/schema-repo-config.ts @@ -31,7 +31,11 @@ export const reviewConfigSchema = z.object({ large_file_threshold_lines: z.number().int().min(1).max(5_000).default(200), max_diff_lines_per_file: z.number().int().min(1).max(5_000).default(800), batch_small_files: z.boolean().default(true), - max_total_diff_chars: z.number().int().min(1).max(500_000).default(150_000), + // Pathological-input valve, NOT an operating limit: `max_files` and `max_diff_lines_per_file` + // are the controls. This sat in the schema unenforced for a long time, so its 150,000 default was + // never sized against a real ceiling; the moment it started dropping files it silently cut a + // 250-file pull request down to 25. A 200-file review at ~7.5k chars per file needs ~1.5M. + max_total_diff_chars: z.number().int().min(1).max(20_000_000).default(4_000_000), // Presentation cap: comments actually posted to the PR. Findings past this are still recorded // (disposition 'cap') and shown on the dashboard; nothing upstream of posting should read it. max_comments: z.number().int().min(1).max(150).default(10), diff --git a/test/review/fragmented-packing.spec.ts b/test/review/fragmented-packing.spec.ts index 57f92ca2..be8f830f 100644 --- a/test/review/fragmented-packing.spec.ts +++ b/test/review/fragmented-packing.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { planReviewUnits, unitFiles } from '@server/core/review'; import { wantsFileContext } from '@server/prompts/file-review'; import { filterReviewableFiles } from '@server/core/diff'; -import { defaultRepoConfig } from '@codraoss/schema'; +import { defaultRepoConfig, reviewMaxFilesRange } from '@codraoss/schema'; import type { FileDiff } from '@server/core/diff'; // Bins deliberately get no whole-file context: they exist to save subrequests, and four extra GitHub @@ -107,6 +107,16 @@ describe('max_total_diff_chars', () => { expect(result.skipped).toBe(0); }); + // The default has to clear a full-size review, or it becomes a second, much tighter file limit and + // `max_files` stops meaning anything. It cut a real 250-file pull request down to 25 at 150,000. + it('defaults high enough that max_files stays the binding limit', () => { + const AVERAGE_CHARS_PER_FILE = 7_500; + const budget = defaultRepoConfig.review.max_total_diff_chars; + + expect(budget / AVERAGE_CHARS_PER_FILE).toBeGreaterThan(reviewMaxFilesRange.default); + expect(budget / AVERAGE_CHARS_PER_FILE).toBeGreaterThan(reviewMaxFilesRange.max); + }); + // A single oversized file is truncated by `max_diff_lines_per_file`, not dropped -- otherwise the // job reviews nothing at all and reports success. it('always keeps the first file, however large', () => { diff --git a/test/review/partial-review-message.spec.ts b/test/review/partial-review-message.spec.ts new file mode 100644 index 00000000..acd7f075 --- /dev/null +++ b/test/review/partial-review-message.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { partialReviewMessage } from '../../packages/core/src/review/finalize'; + +// A review can fall short of the pull request two ways: files that failed, and files the limits never +// took. Only the first was ever reported, and the second used to be visible only in the PR comment +// that no longer carries it -- so a 250-file pull request reviewed 25 and reported plain success. +describe('partialReviewMessage', () => { + it('says nothing when the whole diff was reviewed', () => { + expect(partialReviewMessage({ failedFileCount: 0, reviewedFileCount: 12, filesOverCap: 0 })).toBeNull(); + }); + + it('reports files that failed to review', () => { + const message = partialReviewMessage({ failedFileCount: 1, reviewedFileCount: 2, filesOverCap: 0 }); + + expect(message).toBe('Partial review: 1 of 2 files could not be reviewed.'); + }); + + it('reports files the limits left out', () => { + const message = partialReviewMessage({ failedFileCount: 0, reviewedFileCount: 25, filesOverCap: 225 }); + + expect(message).toContain('225 files left out by the file and diff-size limits'); + expect(message).toMatch(/^Partial review: /); + }); + + it('reports both causes when both apply', () => { + const message = partialReviewMessage({ failedFileCount: 2, reviewedFileCount: 25, filesOverCap: 225 }); + + expect(message).toContain('2 of 25 files could not be reviewed'); + expect(message).toContain('225 files left out'); + }); + + it('gets singulars right', () => { + expect(partialReviewMessage({ failedFileCount: 1, reviewedFileCount: 1, filesOverCap: 1 })) + .toBe('Partial review: 1 of 1 file could not be reviewed; 1 file left out by the file and diff-size limits.'); + }); +}); From f30af3e5cefc881f364d479d14063344f80f2b57 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Wed, 19 Aug 2026 23:23:27 +0530 Subject: [PATCH 2/6] fix: update MAX_TOTAL_DIFF_CHARS to improve handling of large reviews --- packages/core/src/constants.ts | 6 +++ packages/core/src/diff/index.ts | 5 ++- packages/schema/src/schema-repo-config.ts | 5 --- test/review/fragmented-packing.spec.ts | 45 ++++++++++++----------- 4 files changed, 33 insertions(+), 28 deletions(-) diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index daade712..f047270e 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -6,6 +6,12 @@ export const BIN_DIFF_CHAR_BUDGET = 24_000; // Files with many scattered hunks get whole-file context; the line floor excludes trivial files. export const FRAGMENTED_HUNK_THRESHOLD = 5; export const FRAGMENTED_MIN_LINES = 60; +// Whole-job input ceiling: a pathological-input valve, NOT an operating limit -- `max_files` and +// `max_diff_lines_per_file` are the controls. Deliberately a constant and not repo config: it was a +// config field with no UI and no file source, so every stored config carried a value nobody chose, +// and raising the schema default left those rows untouched. A 500-file review at ~7.5k chars per +// file needs ~3.8M, so this clears the largest review `max_files` permits. +export const MAX_TOTAL_DIFF_CHARS = 4_000_000; export const DIFF_CACHE_TTL_SECONDS = 6 * 60 * 60; // Phase Control & Timers diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index ab20686e..37405e47 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -1,4 +1,5 @@ import picomatch from 'picomatch'; +import { MAX_TOTAL_DIFF_CHARS } from '../constants'; import type { RepoConfig } from '@codraoss/schema'; import { type DiffLineKind, @@ -267,6 +268,8 @@ export function filterReviewableFiles( files: FileDiff[], config: RepoConfig['review'], maxFiles: number, + // Overridable for tests; production always uses the constant. + maxTotalDiffChars: number = MAX_TOTAL_DIFF_CHARS, ): { files: FileDiff[]; skipped: number } { const customMatchers = config.skip_files.map((pattern) => picomatch(pattern, { dot: true })); @@ -289,7 +292,7 @@ export function filterReviewableFiles( (sum, hunk) => sum + hunk.lines.reduce((lineSum, line) => lineSum + line.content.length + 1, 0), 0, ); - if (kept.length > 0 && totalChars + fileChars > config.max_total_diff_chars) break; + if (kept.length > 0 && totalChars + fileChars > maxTotalDiffChars) break; kept.push(file); totalChars += fileChars; } diff --git a/packages/schema/src/schema-repo-config.ts b/packages/schema/src/schema-repo-config.ts index 12956280..b8964aee 100644 --- a/packages/schema/src/schema-repo-config.ts +++ b/packages/schema/src/schema-repo-config.ts @@ -31,11 +31,6 @@ export const reviewConfigSchema = z.object({ large_file_threshold_lines: z.number().int().min(1).max(5_000).default(200), max_diff_lines_per_file: z.number().int().min(1).max(5_000).default(800), batch_small_files: z.boolean().default(true), - // Pathological-input valve, NOT an operating limit: `max_files` and `max_diff_lines_per_file` - // are the controls. This sat in the schema unenforced for a long time, so its 150,000 default was - // never sized against a real ceiling; the moment it started dropping files it silently cut a - // 250-file pull request down to 25. A 200-file review at ~7.5k chars per file needs ~1.5M. - max_total_diff_chars: z.number().int().min(1).max(20_000_000).default(4_000_000), // Presentation cap: comments actually posted to the PR. Findings past this are still recorded // (disposition 'cap') and shown on the dashboard; nothing upstream of posting should read it. max_comments: z.number().int().min(1).max(150).default(10), diff --git a/test/review/fragmented-packing.spec.ts b/test/review/fragmented-packing.spec.ts index be8f830f..19338d17 100644 --- a/test/review/fragmented-packing.spec.ts +++ b/test/review/fragmented-packing.spec.ts @@ -3,6 +3,7 @@ import { planReviewUnits, unitFiles } from '@server/core/review'; import { wantsFileContext } from '@server/prompts/file-review'; import { filterReviewableFiles } from '@server/core/diff'; import { defaultRepoConfig, reviewMaxFilesRange } from '@codraoss/schema'; +import { MAX_TOTAL_DIFF_CHARS } from '../../packages/core/src/constants'; import type { FileDiff } from '@server/core/diff'; // Bins deliberately get no whole-file context: they exist to save subrequests, and four extra GitHub @@ -82,47 +83,47 @@ describe('fragmented files and bin packing', () => { }); }); -// Declared in the config schema since it was added, and enforced nowhere: a pull request under the -// file count could carry an unbounded amount of diff. It is the only knob that bounds total job input. -describe('max_total_diff_chars', () => { +// The whole-job input ceiling. It lived in repo config with no UI and no file source, so every stored +// config carried a 150,000 nobody chose -- and when enforcement was added it silently cut a real +// 250-file pull request to 25. Raising the schema default did not help those rows: it is a constant now. +describe('the total diff-char ceiling', () => { const wide = (path: string) => file(path, { hunks: 1, linesPerHunk: 40 }); - const review = (max: number) => ({ ...defaultRepoConfig.review, max_total_diff_chars: max }); + const charsIn = (f: FileDiff) => f.hunks[0].lines.reduce((sum, l) => sum + l.content.length + 1, 0); it('stops taking files once the budget is spent', () => { const files = [wide('a.ts'), wide('b.ts'), wide('c.ts')]; - const oneFile = files[0].hunks[0].lines.reduce((sum, l) => sum + l.content.length + 1, 0); - const result = filterReviewableFiles(files, review(oneFile * 2), 50); + const result = filterReviewableFiles(files, defaultRepoConfig.review, 50, charsIn(files[0]) * 2); expect(result.files.map((f) => f.path)).toEqual(['a.ts', 'b.ts']); expect(result.skipped).toBe(1); }); - it('keeps everything when the budget is generous', () => { - const files = [wide('a.ts'), wide('b.ts'), wide('c.ts')]; - - const result = filterReviewableFiles(files, review(1_000_000), 50); + // A single oversized file is truncated by `max_diff_lines_per_file`, not dropped -- otherwise the + // job reviews nothing at all and reports success. + it('always keeps the first file, however large', () => { + const result = filterReviewableFiles([wide('a.ts')], defaultRepoConfig.review, 50, 1); - expect(result.files).toHaveLength(3); + expect(result.files.map((f) => f.path)).toEqual(['a.ts']); expect(result.skipped).toBe(0); }); - // The default has to clear a full-size review, or it becomes a second, much tighter file limit and - // `max_files` stops meaning anything. It cut a real 250-file pull request down to 25 at 150,000. + // The regression: the ceiling has to clear a full-size review, or it becomes a second and much + // tighter file limit and `max_files` stops meaning anything. it('defaults high enough that max_files stays the binding limit', () => { const AVERAGE_CHARS_PER_FILE = 7_500; - const budget = defaultRepoConfig.review.max_total_diff_chars; - expect(budget / AVERAGE_CHARS_PER_FILE).toBeGreaterThan(reviewMaxFilesRange.default); - expect(budget / AVERAGE_CHARS_PER_FILE).toBeGreaterThan(reviewMaxFilesRange.max); + expect(MAX_TOTAL_DIFF_CHARS / AVERAGE_CHARS_PER_FILE).toBeGreaterThan(reviewMaxFilesRange.max); }); - // A single oversized file is truncated by `max_diff_lines_per_file`, not dropped -- otherwise the - // job reviews nothing at all and reports success. - it('always keeps the first file, however large', () => { - const result = filterReviewableFiles([wide('a.ts')], review(1), 50); + // What actually broke: the value was persisted per repo, so raising the default left every existing + // row on the old number. No repo config can reach it now. + it('is not a repo-config field, so no stored config can shrink it', () => { + expect('max_total_diff_chars' in defaultRepoConfig.review).toBe(false); + const stale = { ...defaultRepoConfig.review, max_total_diff_chars: 150_000 } as never; + const files = Array.from({ length: 30 }, (_, i) => wide(`f${i}.ts`)); - expect(result.files.map((f) => f.path)).toEqual(['a.ts']); - expect(result.skipped).toBe(0); + // 30 files of ~8k chars would be cut to ~19 by a 150,000 ceiling. + expect(filterReviewableFiles(files, stale, 50).files).toHaveLength(30); }); }); From d0d7800954b629b68ad25a74870b4e7d32a042c7 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Thu, 20 Aug 2026 08:03:57 +0530 Subject: [PATCH 3/6] refactor(ui): overhaul job-detail and stats views, add status notice and chart primitives --- package.json | 2 +- .../ui/src/components/chart-primitives.tsx | 354 +-- packages/ui/src/index.ts | 45 +- src/client/app.css | 1920 +++++++++-------- .../features/job-detail/job-header.tsx | 471 ++-- .../features/job-detail/job-meta-cards.tsx | 50 +- .../features/job-detail/job-progress.tsx | 151 +- .../job-detail/job-review-overview.tsx | 204 +- .../features/job-detail/job-status-notice.tsx | 141 ++ .../features/stats/chart-primitives.tsx | 162 +- .../features/stats/metrics-grid-charts.tsx | 507 ++--- .../features/stats/metrics-grid-prefetch.ts | 33 +- .../features/stats/metrics-grid.tsx | 71 +- .../components/features/stats/stats-grid.tsx | 268 +-- src/client/components/layout/account-menu.tsx | 359 +-- src/client/components/layout/app-shell.tsx | 427 ++-- src/client/components/shared/jobs-table.tsx | 626 +++--- src/client/hooks/use-fit-rows.ts | 140 ++ src/client/pages/dashboard.tsx | 252 +-- src/client/pages/job-detail.tsx | 216 +- src/client/pages/stats.tsx | 155 +- 21 files changed, 3500 insertions(+), 3054 deletions(-) create mode 100644 src/client/components/features/job-detail/job-status-notice.tsx create mode 100644 src/client/hooks/use-fit-rows.ts diff --git a/package.json b/package.json index e3a84df0..876c9bb1 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "deploy": "npm run build && npm run migrate && cd apps/worker && wrangler deploy", "dev": "concurrently -k -n CLIENT,WORKER -c cyan,green \"npm:dev:client\" \"npm:dev:worker\"", "dev:client": "vite build --watch --mode development", - "dev:worker": "cd apps/worker && wrangler dev --local", + "dev:worker": "cd apps/worker && wrangler dev --local --env-file ../../.dev.vars", "lint": "eslint src test scripts packages apps", "lint:all": "npm run lint --workspaces --if-present", "start": "npm run dev", diff --git a/packages/ui/src/components/chart-primitives.tsx b/packages/ui/src/components/chart-primitives.tsx index d46f1013..6ac6e7cc 100644 --- a/packages/ui/src/components/chart-primitives.tsx +++ b/packages/ui/src/components/chart-primitives.tsx @@ -1,163 +1,191 @@ -import { Children, type ReactNode } from 'react'; -import { LayerCard } from './layer-card'; -import { cn } from '../lib/utils'; - -export function CardDots() { - return ( -
- ); -} - -export function GraphShell({ - title, - icon, - legend, - children, - className = '', -}: { - title: string; - icon?: ReactNode; - legend?: ReactNode; - children: ReactNode; - className?: string; -}) { - return ( - - -
- {icon && {icon}} -

- {title} -

-
- {legend && ( -
- {legend} -
- )} -
{children}
-
- ); -} - -export function LegendChip({ - color, - hatched, - dashed, - label, -}: { - color?: string; - hatched?: boolean; - dashed?: boolean; - label: string; -}) { - return ( - - {dashed ? ( - - ) : ( - - )} - {label} - - ); -} - - - -export function ChartDefs({ isDark }: { isDark: boolean }) { - const hatch = isDark ? 'rgba(228,228,231,0.5)' : 'rgba(63,63,70,0.4)'; - const hatchBg = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)'; - return ( - - - - - - - - - - - - - - - ); -} - -const METER_ROW_PX = 20; -const METER_GAP_PX = 14; - -export function MeterList({ visible, children }: { visible: number; children: ReactNode }) { - const scrolls = Children.count(children) > visible; - - return ( -
-
- {children} -
-
- ); -} - -export function TickMeter({ - label, - value, - max, - color, - valueLabel, -}: { - label: string; - value: number; - max: number; - color: string; - valueLabel: string; -}) { - const SEGMENTS = 26; - const filled = value > 0 ? Math.max(1, Math.round((value / Math.max(max, 1)) * SEGMENTS)) : 0; - - return ( -
- - {label} - -
- {Array.from({ length: SEGMENTS }).map((_, i) => ( - - ))} -
- - {valueLabel} - -
- ); -} +import { Children, type ReactNode } from 'react'; +import { cn } from '../lib/utils'; + +export function CardDots() { + return ( +
+ ); +} + +export function GraphShell({ + title, + icon, + legend, + children, + className = '', +}: { + title: string; + icon?: ReactNode; + legend?: ReactNode; + children: ReactNode; + className?: string; +}) { + return ( + // Same chrome as the dashboard stat cards: card face carries the title, the chart itself sits + // in a recessed inner panel. +
+
+ {icon && {icon}} +

+ {title} +

+
+ +
+ {/* Dot texture lives on the recessed face, where the chart reads against it. */} + + {legend && ( +
+ {legend} +
+ )} +
{children}
+
+
+ ); +} + +export interface SeriesMarkerProps { + /** Flat CSS colour. Ignored when `hatched` is set, which paints its own fill. */ + color?: string; + /** The cross-hatched fill used for the input-token series. */ + hatched?: boolean; + /** A dashed rule instead of a swatch, for series drawn as a dashed line. */ + dashed?: boolean; +} + +/** + * The swatch that identifies a series. Shared by the legend and the tooltip so a series looks the + * same in both - a tooltip dot that doesn't match its legend chip reads as a different series. + */ +export function SeriesMarker({ color, hatched, dashed }: SeriesMarkerProps) { + if (dashed) { + return ( + + ); + } + + return ( + + ); +} + +export function LegendChip({ + color, + hatched, + dashed, + label, +}: SeriesMarkerProps & { label: string }) { + return ( + + + {label} + + ); +} + + + +export function ChartDefs({ isDark }: { isDark: boolean }) { + const hatch = isDark ? 'rgba(228,228,231,0.5)' : 'rgba(63,63,70,0.4)'; + const hatchBg = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)'; + return ( + + + + + + + + + + + + + + + ); +} + +const METER_ROW_PX = 20; +const METER_GAP_PX = 14; + +export function MeterList({ visible, children }: { visible: number; children: ReactNode }) { + const scrolls = Children.count(children) > visible; + + return ( +
+
+ {children} +
+
+ ); +} + +export function TickMeter({ + label, + value, + max, + color, + valueLabel, +}: { + label: string; + value: number; + max: number; + color: string; + valueLabel: string; +}) { + const SEGMENTS = 26; + const filled = value > 0 ? Math.max(1, Math.round((value / Math.max(max, 1)) * SEGMENTS)) : 0; + + return ( +
+ + {label} + +
+ {Array.from({ length: SEGMENTS }).map((_, i) => ( + + ))} +
+ + {valueLabel} + +
+ ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 1525b377..2f8c5898 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,22 +1,23 @@ -// Components -export { Alert } from './components/alert'; -export { Badge } from './components/badge'; -export { badgeVariants } from './components/badge-variants'; -export { Button, LinkButton, type ButtonProps } from './components/button'; -export { buttonVariants } from './components/button-variants'; -export { ConfirmDialog } from './components/confirm-dialog'; -export { Input, type InputProps } from './components/input'; -export { LayerCard } from './components/layer-card'; -export { Select } from './components/select'; -export { Switch, type SwitchProps } from './components/switch'; -export { Text } from './components/text'; -export { Skeleton } from './components/skeleton'; -export { EmptyState } from './components/empty-state'; -export { SectionCard } from './components/section-card'; -export { CopyButton } from './components/copy-button'; -export { BarSparkline } from './components/bar-sparkline'; -export { GithubMark } from './components/github-mark'; -export { LoadError } from './components/load-error'; - -// Chart primitives -export { GraphShell, LegendChip, ChartDefs, MeterList, TickMeter, CardDots } from './components/chart-primitives'; +// Components +export { Alert } from './components/alert'; +export { Badge } from './components/badge'; +export { badgeVariants } from './components/badge-variants'; +export { Button, LinkButton, type ButtonProps } from './components/button'; +export { buttonVariants } from './components/button-variants'; +export { ConfirmDialog } from './components/confirm-dialog'; +export { Input, type InputProps } from './components/input'; +export { LayerCard } from './components/layer-card'; +export { Select } from './components/select'; +export { Switch, type SwitchProps } from './components/switch'; +export { Text } from './components/text'; +export { Skeleton } from './components/skeleton'; +export { EmptyState } from './components/empty-state'; +export { SectionCard } from './components/section-card'; +export { CopyButton } from './components/copy-button'; +export { BarSparkline } from './components/bar-sparkline'; +export { GithubMark } from './components/github-mark'; +export { LoadError } from './components/load-error'; + +// Chart primitives +export { GraphShell, LegendChip, SeriesMarker, ChartDefs, MeterList, TickMeter, CardDots } from './components/chart-primitives'; +export type { SeriesMarkerProps } from './components/chart-primitives'; diff --git a/src/client/app.css b/src/client/app.css index ef0a2a65..b1690900 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -1,958 +1,962 @@ -@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap'); - -@import "tailwindcss"; - -/* dark: utilities follow .dark class, not OS prefers-color-scheme. */ -@custom-variant dark (&:where(.dark, .dark *)); - -/* --ui-* : local neutral surface scale (no runtime design-system dep). */ -:root { - --ui-base: #ffffff; - --ui-canvas: oklch(98.75% 0 0); - --ui-line: oklch(14.5% 0 0 / 0.1); - --ui-fill: oklch(92.2% 0 0); - --ui-subtle: oklch(55.6% 0 0); - --ui-default: oklch(21% 0 0); - --ui-strong: oklch(14.5% 0 0); -} -.dark { - --ui-base: oklch(17% 0 0); - --ui-canvas: oklch(10% 0 0); - --ui-line: oklch(32% 0 0); - --ui-fill: oklch(26.9% 0 0); - --ui-subtle: oklch(70.8% 0 0); - --ui-default: oklch(97% 0 0); - --ui-strong: oklch(98.5% 0 0); -} - -:root { - --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); - --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); -} - -/* LIGHT MODE (:root default) */ -:root { - --background: oklch(100% 0 0); - --foreground: oklch(12% 0.02 115); - --card: oklch(100% 0 0); - --card-foreground: oklch(12% 0.02 115); - --popover: oklch(100% 0 0); - --popover-foreground: oklch(12% 0.02 115); - - /* Lime darkened for AA contrast on white; .dark restores full brightness. */ - --primary: oklch(64% 0.24 115); - --primary-foreground: oklch(100% 0 0); - --btn-primary-bg: oklch(64% 0.24 115); - --btn-primary-fg: oklch(20% 0.02 118); - --btn-primary-border: oklch(72% 0.17 118); - --btn-primary-surface: oklch(95% 0.09 118); - --btn-primary-hover: oklch(90% 0.13 118); - - --secondary: oklch(96.3% 0.003 286.3); - --secondary-foreground:oklch(27.4% 0.006 286.3); - --muted: oklch(96.3% 0.003 286.3); - --muted-foreground: oklch(55.1% 0.011 286.3); - - --accent: oklch(90.9% 0.004 286.3); - --accent-foreground: oklch(20.5% 0.005 286.3); - - --destructive: oklch(55% 0.22 25); - --destructive-foreground: oklch(100% 0 0); - - --border: oklch(90.9% 0.004 286.3); - --input: oklch(90.9% 0.004 286.3); - --ring: oklch(72% 0.22 115); - - --radius: 0.75rem; - --sidebar-width: 240px; - - --success: oklch(64% 0.24 115); - --success-bg: oklch(98% 0.04 115); - --success-border: oklch(85% 0.15 115); - --warning: oklch(56% 0.18 65); - --warning-bg: oklch(98% 0.04 65); - --warning-border: oklch(90% 0.12 65); - --danger: oklch(62% 0.22 25); - --danger-bg: oklch(98% 0.04 25); - --danger-border: oklch(88% 0.14 25); - --info: oklch(68% 0.18 250); - --info-bg: oklch(98% 0.04 250); - --info-border: oklch(88% 0.12 250); - - --shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.02); - --shadow-md: 0 1px 4px oklch(0% 0 0 / 0.03), 0 1px 2px oklch(0% 0 0 / 0.02); - --shadow-lg: 0 4px 16px -4px oklch(0% 0 0 / 0.04), 0 1px 6px -2px oklch(0% 0 0 / 0.03); - - --code-bg: oklch(96.3% 0.003 286.3); - --code-fg: oklch(27.4% 0.006 286.3); - --code-border: oklch(90.9% 0.004 286.3); - - /* True green/red, not brand lime, so diff rows/counts stay distinguishable. */ - --diff-add-bg: oklch(95% 0.06 150); - --diff-add-fg: oklch(48% 0.13 150); - --diff-del-bg: oklch(95% 0.05 27); - --diff-del-fg: oklch(52% 0.16 27); -} - -/* DARK MODE (.dark class on ) */ -.dark { - --background: #000000; - --foreground: oklch(98% 0.005 115); - --card: #09090b; - --card-foreground: oklch(98% 0.005 115); - --popover: #09090b; - --popover-foreground: oklch(98% 0.005 115); - - --primary: oklch(94% 0.23 115); - --primary-foreground: oklch(12% 0.04 115); - - --btn-primary-bg: #CCE800; - --btn-primary-fg: #CCE800; - --btn-primary-border: color-mix(in oklab, #CCE800 50%, transparent); - --btn-primary-surface: color-mix(in oklab, #CCE800 8%, transparent); - --btn-primary-hover: color-mix(in oklab, #CCE800 16%, transparent); - - --secondary: oklch(18% 0.018 115); - --secondary-foreground:oklch(82% 0.012 115); - --muted: oklch(18% 0.018 115); - --muted-foreground: oklch(55% 0.015 115); - - --accent: oklch(18% 0.018 115); - --accent-foreground: oklch(91% 0.010 115); - - --destructive: oklch(60% 0.220 25); - --destructive-foreground: oklch(10% 0.015 115); - - --border: oklch(22% 0.02 115); - --input: oklch(22% 0.02 115); - --ring: oklch(94% 0.23 115); - - --success: oklch(94% 0.23 115); - --success-bg: oklch(18% 0.06 115); - --success-border: oklch(28% 0.10 115); - --warning: oklch(78% 0.165 65); - --warning-bg: oklch(18% 0.080 65); - --warning-border: oklch(35% 0.14 65); - --danger: oklch(70% 0.200 25); - --danger-bg: oklch(18% 0.080 25); - --danger-border: oklch(35% 0.14 25); - --info: oklch(72% 0.160 250); - --info-bg: oklch(18% 0.075 250); - --info-border: oklch(35% 0.12 250); - - --shadow-sm: 0 1px 2px oklch(100% 0 0 / 0.05), 0 1px 2px oklch(0% 0 0 / 0.3); - --shadow-md: 0 4px 12px oklch(0% 0 0 / 0.45), 0 1px 4px oklch(0% 0 0 / 0.25); - --shadow-lg: 0 12px 24px -4px oklch(0% 0 0 / 0.5), 0 4px 12px -2px oklch(0% 0 0 / 0.3); - - --code-bg: oklch(20.5% 0.005 286.3); - --code-fg: oklch(86.5% 0.005 286.3); - --code-border: oklch(27.4% 0.006 286.3); - - --diff-add-bg: oklch(30% 0.06 150); - --diff-add-fg: oklch(82% 0.15 150); - --diff-del-bg: oklch(31% 0.08 27); - --diff-del-fg: oklch(80% 0.16 27); -} - -/* Tailwind v4 theme tokens (@theme inline = dynamic) */ -@theme inline { - --font-sans: 'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; - - /* References raw --ui-* vars so utilities flip with .dark. */ - --color-ui-base: var(--ui-base); - --color-ui-canvas: var(--ui-canvas); - --color-ui-line: var(--ui-line); - --color-ui-fill: var(--ui-fill); - --color-ui-subtle: var(--ui-subtle); - --color-ui-default: var(--ui-default); - --color-ui-strong: var(--ui-strong); - --color-ui-brand: var(--primary); - - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground:var(--destructive-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - - --color-success: var(--success); - --color-success-bg: var(--success-bg); - --color-success-border: var(--success-border); - - --color-warning: var(--warning); - --color-warning-bg: var(--warning-bg); - --color-warning-border: var(--warning-border); - - --color-danger: var(--danger); - --color-danger-bg: var(--danger-bg); - --color-danger-border: var(--danger-border); - - --color-info: var(--info); - --color-info-bg: var(--info-bg); - --color-info-border: var(--info-border); - - /* radius-lg == radius-xl intentionally: cards and .surface share one size. */ - --radius-sm: 0.3125rem; - --radius-md: 0.4375rem; - --radius-lg: 0.6875rem; - --radius-xl: 0.6875rem; - --radius-2xl: 0.875rem; - - --text-xs: 0.75rem; - --text-sm: 0.875rem; - --text-base: 1rem; - --text-lg: clamp(1.125rem, 2vw, 1.25rem); - --text-xl: clamp(1.25rem, 3vw, 1.5rem); - --text-2xl: clamp(1.5rem, 4vw, 2.25rem); - --text-3xl: clamp(2rem, 6vw, 3.5rem); - --text-4xl: clamp(2.5rem, 10vw, 6rem); - --text-display: clamp(3rem, 12vw, 9rem); - - --space-xs: clamp(0.5rem, 1vw, 0.75rem); - --space-sm: clamp(1rem, 2vw, 1.5rem); - --space-md: clamp(1.5rem, 4vw, 3rem); - --space-lg: clamp(3rem, 8vw, 6rem); - --space-xl: clamp(6rem, 12vw, 10rem); -} - -@layer base { - *, *::before, *::after { box-sizing: border-box; } - - html.theme-changing, - html.theme-changing *, - html.theme-changing *::before, - html.theme-changing *::after { - transition: none !important; - } - - html { - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - font-kerning: normal; - } - - body { - background-color: var(--background); - color: var(--foreground); - font-family: var(--font-sans); - line-height: 1.5; - min-height: 100svh; - transition: background-color 0.3s var(--ease-out-expo), - color 0.3s var(--ease-out-expo); - } - - body::before { - content: ''; - pointer-events: none; - position: fixed; - inset: 0; - z-index: -1; - background: none; - transition: opacity 0.4s; - } - - .dark body::before { - background: - radial-gradient(ellipse 60% 45% at 0% 0%, oklch(20% 0.15 115 / 0.15), transparent 60%), - radial-gradient(ellipse 50% 40% at 100% 100%, oklch(10% 0.05 115 / 0.1), transparent 60%); - } - - a { color: inherit; text-decoration: none; } - button, input, textarea, select { font: inherit; } - pre, code { font-family: var(--font-mono); } -} - -@keyframes shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } -} - -@keyframes pulse-ring { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.4; transform: scale(1.25); } -} - -@keyframes fade-up { - from { opacity: 0; transform: translateY(16px); } - to { opacity: 1; transform: translateY(0); } -} - -@keyframes fade-in { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes slide-down { - from { opacity: 0; transform: translateY(-12px); } - to { opacity: 1; transform: translateY(0); } -} - -@keyframes scale-in { - from { opacity: 0; transform: scale(0.96) translateY(8px); } - to { opacity: 1; transform: scale(1) translateY(0); } -} - -@keyframes reveal-left { - from { opacity: 0; transform: translateX(-20px); } - to { opacity: 1; transform: translateX(0); } -} - -@utility animate-fade-in { - animation: fade-in 0.5s var(--ease-out-expo) both; -} - -@utility animate-fade-up { - animation: fade-up 0.6s var(--ease-out-expo) both; -} - -@utility animate-slide-down { - animation: slide-down 0.5s var(--ease-out-expo) both; -} - -@utility animate-scale-in { - animation: scale-in 0.6s var(--ease-out-expo) both; -} - -@utility animate-reveal-left { - animation: reveal-left 0.6s var(--ease-out-expo) both; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -@utility animate-in { - animation-duration: 200ms; - animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - fill-mode: forwards; -} - -@utility animate-out { - animation-duration: 200ms; - animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - fill-mode: forwards; -} - -@utility fade-in-0 { - --tw-enter-opacity: 0; - animation-name: enter; -} - -@utility fade-out-0 { - --tw-exit-opacity: 0; - animation-name: exit; -} - -@utility zoom-in-95 { - --tw-enter-scale: 0.95; - animation-name: enter; -} - -@utility zoom-in-98 { - --tw-enter-scale: 0.98; - animation-name: enter; -} - -@utility zoom-out-95 { - --tw-exit-scale: 0.95; - animation-name: exit; -} - -@utility zoom-out-98 { - --tw-exit-scale: 0.98; - animation-name: exit; -} - -@utility slide-in-from-top-1 { - --tw-enter-translate-y: -4px; - animation-name: enter; -} - -@utility slide-in-from-top-2 { - --tw-enter-translate-y: -8px; - animation-name: enter; -} - -@utility slide-in-from-bottom-1 { - --tw-enter-translate-y: 4px; - animation-name: enter; -} - -@utility slide-in-from-bottom-2 { - --tw-enter-translate-y: 8px; - animation-name: enter; -} - -@keyframes enter { - from { - opacity: var(--tw-enter-opacity, 1); - transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0) scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), 1) rotate(var(--tw-enter-rotate, 0)); - } -} - -@keyframes exit { - to { - opacity: var(--tw-exit-opacity, 1); - transform: translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0) scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), 1) rotate(var(--tw-exit-rotate, 0)); - } -} - -@utility page-enter { - animation: fade-up 0.5s var(--ease-out-expo) both; - & > * { - animation: fade-up 0.5s var(--ease-out-expo) both; - } - & > *:nth-child(1) { animation-delay: 0ms; } - & > *:nth-child(2) { animation-delay: 60ms; } - & > *:nth-child(3) { animation-delay: 120ms; } - & > *:nth-child(4) { animation-delay: 180ms; } - & > *:nth-child(5) { animation-delay: 240ms; } - & > *:nth-child(6) { animation-delay: 300ms; } - - @media (prefers-reduced-motion: reduce) { - animation: none !important; - & > * { animation: none !important; } - } -} - -/* Toggled by JS IntersectionObserver. */ -@utility reveal-on-scroll { - opacity: 0; - transform: translateY(24px); - transition: - opacity 0.65s var(--ease-out-expo), - transform 0.65s var(--ease-out-expo); - - &.is-visible { - opacity: 1; - transform: translateY(0); - } - - @media (prefers-reduced-motion: reduce) { - opacity: 1 !important; - transform: none !important; - transition: none !important; - } -} - -@utility reveal-delay-1 { transition-delay: 80ms !important; } -@utility reveal-delay-2 { transition-delay: 160ms !important; } -@utility reveal-delay-3 { transition-delay: 240ms !important; } -@utility reveal-delay-4 { transition-delay: 320ms !important; } - -/* Scrollbars: neutral grey, auto-hidden via app-shell.tsx toggling data-scrolling. */ -* { - scrollbar-width: thin; - scrollbar-color: transparent transparent; -} - -[data-scrolling] { - scrollbar-color: oklch(0% 0 0 / 0.32) transparent; -} -.dark [data-scrolling] { - scrollbar-color: oklch(100% 0 0 / 0.3) transparent; -} - -/* Transparent border + padding-box clip insets the thumb into a slim bar. */ -::-webkit-scrollbar { - width: 10px; - height: 10px; -} -::-webkit-scrollbar-track { - background: transparent; -} -::-webkit-scrollbar-thumb { - background-color: transparent; - border: 3px solid transparent; - background-clip: padding-box; - border-radius: 999px; - transition: background-color 0.3s ease; -} -[data-scrolling]::-webkit-scrollbar-thumb { - background-color: oklch(0% 0 0 / 0.3); -} -[data-scrolling]::-webkit-scrollbar-thumb:hover { - background-color: oklch(0% 0 0 / 0.45); -} -.dark [data-scrolling]::-webkit-scrollbar-thumb { - background-color: oklch(100% 0 0 / 0.28); -} -.dark [data-scrolling]::-webkit-scrollbar-thumb:hover { - background-color: oklch(100% 0 0 / 0.45); -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } -} - -@utility surface { - @apply bg-card border border-border rounded-xl; - box-shadow: var(--shadow-md); -} - -.surface-static { - transition: none !important; -} - -.surface-static:hover { - box-shadow: var(--shadow-md) !important; - transform: none !important; -} - -.surface-static-shadow { - box-shadow: var(--shadow-md) !important; - transition: none !important; -} - -.surface-static-shadow:hover { - box-shadow: var(--shadow-md) !important; - transform: none !important; -} - -@utility glass { - @apply backdrop-blur-md bg-card/75 border border-border; - background-image: linear-gradient(to bottom right, oklch(100% 0 0 / 0.05), transparent); -} - -@utility surface-hover { - @apply transition-all duration-300; - &:hover { - @apply border-primary/30 shadow-lg shadow-primary/5 -translate-y-[1px]; - } -} - -@utility skeleton { - background: linear-gradient( - 90deg, - var(--muted) 25%, - color-mix(in oklch, var(--muted) 50%, var(--card)) 50%, - var(--muted) 75% - ); - background-size: 200% 100%; - @apply animate-[shimmer_1.8s_linear_infinite] rounded-sm; -} - -/* Unlayered, kept at this specificity so no later utility can beat it. */ -.skeleton { - background: - linear-gradient( - 90deg, - var(--muted) 25%, - color-mix(in oklch, var(--muted) 50%, var(--card)) 50%, - var(--muted) 75% - ) !important; - background-size: 200% 100% !important; - animation: shimmer 1.8s linear infinite !important; -} - -/* Model-call bars: override default lime fill with info/blue. */ -.meter-indicator-info { - background-image: none !important; - background-color: var(--info) !important; -} - -@utility code-block { - @apply m-0 whitespace-pre-wrap break-words px-[1.125rem] py-4 rounded-md font-mono text-[0.775rem] leading-[1.7] overflow-auto tracking-[0.01em]; - background-color: var(--code-bg); - color: var(--code-fg); - border: 1px solid var(--code-border); -} - -@utility severity-tag { - @apply text-[0.64rem] px-[5.5px] py-[1.5px] rounded-[3px] uppercase font-bold tracking-[0.07em] border border-transparent; - - &.P0 { @apply bg-danger-bg text-danger border-danger-border; } - &.P1 { @apply bg-warning-bg text-warning border-warning-border; } - &.P2 { - @apply bg-[oklch(95%_0.06_65)] text-[oklch(50%_0.14_65)] border-[oklch(83%_0.09_65)]; - .dark & { - @apply bg-[oklch(21%_0.07_65)] text-[oklch(72%_0.14_65)] border-[oklch(31%_0.09_65)]; - } - } - &.P3 { @apply bg-info-bg text-info border-info-border; } - &.nit { @apply bg-ui-fill/50 text-ui-subtle border-ui-line; } -} - -@utility category-tag { - @apply text-[0.72rem] text-muted-foreground inline-flex items-center gap-[5px] font-medium; - - &::before { - content: ''; - @apply w-[5px] h-[5px] rounded-full bg-current flex-shrink-0 inline-block; - } - - &.security { @apply text-danger; } - &.performance { @apply text-info; } - &.bugs { @apply text-warning; } - &.correctness { @apply text-success; } - &.quality { - @apply text-[oklch(56%_0.16_295)]; - .dark & { @apply text-[oklch(70%_0.14_295)]; } - } -} - -@utility step-dot { - @apply w-2 h-2 rounded-full flex-shrink-0; - - &.pending { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); } - &.running { - @apply bg-info; - } - &.done { @apply bg-success; } - &.failed { @apply bg-danger; } -} - -@utility pulsing-dot { - @apply w-[7px] h-[7px] rounded-full bg-info inline-block; -} - -.recharts-default-tooltip { - background: var(--card) !important; - border: 1px solid var(--border) !important; - border-radius: 10px !important; - box-shadow: 0 8px 32px oklch(5% 0.01 115 / 0.18) !important; - font-family: var(--font-sans) !important; -} -.dark .recharts-default-tooltip { - box-shadow: 0 8px 32px oklch(0% 0 0 / 0.5) !important; -} - -.app-shell-content { - --background: oklch(97.8% 0.002 286.3); - --card: oklch(100% 0 0); - --muted: oklch(90.9% 0.004 286.3); - --popover: oklch(100% 0 0); - --secondary: oklch(88.5% 0.004 286.3); - --border: oklch(90.9% 0.004 286.3); - --input: oklch(90.9% 0.004 286.3); -} - -.dark .app-shell-content { - /* Cool neutral hue 286.3, not hue 115 which gave the card a warm olive cast. */ - --background: oklch(18% 0.006 286.3); - --card: oklch(18% 0.006 286.3); - --muted: oklch(22% 0.006 286.3); - --popover: oklch(18% 0.006 286.3); - --secondary: oklch(26% 0.007 286.3); - --border: oklch(22% 0.006 286.3); - --input: oklch(22% 0.006 286.3); -} - -/* SharedLayoutBg pill is the sole hover affordance; row itself never transforms. */ -.dashboard-sidebar-action:hover, -.dashboard-sidebar-action:focus-visible, -.dashboard-sidebar-action:active { - transform: none !important; -} - -/* Light beam parked off-screen, sweeps across once on hover/focus. */ -.dashboard-sidebar-shine { - transform: skew(-13deg) translateX(-130%); - transition: transform 0ms linear; - will-change: transform; -} -.dashboard-sidebar-action:hover .dashboard-sidebar-shine, -.dashboard-sidebar-action:focus-visible .dashboard-sidebar-shine { - transform: skew(-13deg) translateX(130%); - transition-duration: 1500ms; - transition-timing-function: var(--ease-out-quart); -} - -@utility chart-card { - @apply bg-card border border-border rounded-lg overflow-hidden relative; - box-shadow: var(--shadow-md); -} - -@utility chart-card-inner { - @apply absolute inset-0 pointer-events-none z-0; - background-image: radial-gradient( - circle, - color-mix(in oklch, var(--primary) 12%, transparent) 1px, - transparent 1px - ); - background-size: 20px 20px; -} - -.chart-card > * { position: relative; z-index: 1; } - -@utility stat-number { - @apply text-2xl md:text-3xl lg:text-[2.25rem] font-bold tracking-[-0.04em] leading-none text-foreground tabular-nums; -} - -/* Geist, scoped locally since global @theme sets --font-sans/mono to app defaults. */ -.ui-font-sans { - font-family: 'Geist', ui-sans-serif, system-ui, sans-serif; -} -.ui-font-mono { - font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-feature-settings: 'tnum' 1; -} - -/* Matches dashboard stat-card chrome; .ui-well is its recessed inner panel. */ -.ui-panel { - font-family: 'Geist', ui-sans-serif, system-ui, sans-serif; - border-radius: var(--radius-lg); - border: 1px solid var(--ui-line); - background: #ffffff; -} -.dark .ui-panel { - background: #000000; - border-color: oklch(0.27 0 0); -} -.ui-well { - background: oklch(97.8% 0.002 286.3); -} -.dark .ui-well { - background: oklch(19% 0 0); -} - -/* Syntax tokens for sugar-high (src/client/lib/highlight.tsx); it emits - color: var(--sh-) per token. */ -:root { - --sh-keyword: oklch(48% 0.19 305); - --sh-string: oklch(46% 0.12 150); - --sh-class: oklch(50% 0.13 65); - --sh-comment: oklch(58% 0.01 260); - --sh-entity: oklch(46% 0.14 260); - --sh-property: oklch(45% 0.11 200); - --sh-identifier: inherit; - --sh-sign: oklch(58% 0.01 260); - --sh-jsxliterals: inherit; - --sh-break: inherit; - --sh-space: inherit; -} -.dark { - --sh-keyword: oklch(75% 0.14 305); - --sh-string: oklch(76% 0.11 150); - --sh-class: oklch(78% 0.12 65); - --sh-comment: oklch(58% 0.01 260); - --sh-entity: oklch(76% 0.1 260); - --sh-property: oklch(78% 0.1 200); -} -.sh__token--comment { font-style: italic; } - -.diff-add { background-color: var(--diff-add-bg); } -.diff-del { background-color: var(--diff-del-bg); } -.diff-add-fg { color: var(--diff-add-fg); } -.diff-del-fg { color: var(--diff-del-fg); } - -@keyframes ui-fade-in { - from { opacity: 0; transform: translateY(2px); } - to { opacity: 1; transform: translateY(0); } -} -.ui-fade-in { - animation: ui-fade-in 0.25s ease-out both; -} - -.diff-tree ul { - list-style: none; - margin: 0; - padding: 0; -} -.diff-tree ul ul { - margin-left: 10px; - padding-left: 8px; - border-left: 1px solid var(--ui-line); -} -.diff-tree li { - position: relative; - margin-top: 2px; -} -.diff-tree ul ul li::before { - content: ""; - position: absolute; - left: -8px; - top: 14px; - width: 6px; - height: 1px; - background-color: var(--ui-line); -} -.diff-tree-children { - display: grid; - /* Implicit column would size to content (auto); pin full width so rows stretch edge-to-edge. */ - grid-template-columns: minmax(0, 1fr); - grid-template-rows: 1fr; - transition: grid-template-rows 0.25s ease-in-out; -} -.diff-tree-children[data-collapsed="true"] { - grid-template-rows: 0fr; -} -.diff-tree-children > div { - overflow: hidden; - min-width: 0; -} - -/* .thin-scroll / .auto-hide-scroll kept as no-op aliases: the treatment is - now global (Scrollbars block above); existing markup referencing them still works. */ - -.diff-tree-scroll { - overscroll-behavior: contain; - scrollbar-gutter: stable; -} - -@utility stat-label { - @apply text-[0.65rem] md:text-[0.7rem] lg:text-[0.72rem] font-semibold tracking-[0.06em] uppercase text-muted-foreground; - .dark & { color: color-mix(in oklch, var(--foreground) 72%, transparent); } -} - -@utility prose { - @apply text-[0.875rem] leading-[1.75] text-foreground; - - & h1, & h2, & h3, & h4 { - @apply font-bold leading-[1.3] mt-[1.4em] mb-[0.4em] tracking-[-0.01em]; - } - & h1 { @apply text-[1.2rem]; } - & h2 { @apply text-[1.05rem]; } - & h3 { @apply text-[0.95rem]; } - & p { @apply my-[0.6em]; } - & ul, & ol { @apply pl-[1.4em] my-[0.5em]; } - & li { @apply my-[0.2em]; } - & strong { @apply font-bold; } - & em { @apply italic; } - & code { - @apply font-mono text-[0.78em] bg-ui-fill/60 text-ui-strong px-[0.3em] py-[0.1em] rounded-[3px] border border-ui-line; - } - & pre { - @apply px-4 py-[0.85rem] rounded-md overflow-x-auto text-[0.78em]; - background-color: var(--code-bg); - color: var(--code-fg); - border: 1px solid var(--code-border); - } - & pre code { - @apply bg-transparent border-none p-0 text-inherit; - } - & blockquote { - @apply border-l-2 border-primary pl-4 text-muted-foreground my-[0.85em]; - } - & a { @apply text-primary underline underline-offset-2; } - & hr { @apply border-border my-[1.5em]; } -} - -/* Sonner toast overrides */ - -[data-sonner-toaster] { - --offset: 1.25rem !important; - --width: min(22rem, calc(100vw - 2rem)) !important; - font-family: var(--font-sans) !important; -} - -.codra-toast { - display: flex !important; - align-items: flex-start !important; - gap: 0.625rem !important; - padding: 0.75rem 0.875rem !important; - border-radius: 0.625rem !important; - border: none !important; - font-family: var(--font-sans) !important; - font-size: 0.8125rem !important; - line-height: 1.45 !important; - box-shadow: - 0 4px 16px oklch(0% 0 0 / 0.10), - 0 1px 4px oklch(0% 0 0 / 0.06), - inset 0 1px 0 oklch(100% 0 0 / 0.05) !important; - - background: oklch(99.5% 0.004 115) !important; - color: oklch(15% 0.02 115) !important; - - animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1) !important; -} - -.dark .codra-toast { - background: oklch(13% 0.018 115) !important; - color: oklch(94% 0.006 115) !important; - box-shadow: - 0 6px 24px oklch(0% 0 0 / 0.5), - 0 1px 6px oklch(0% 0 0 / 0.3), - inset 0 1px 0 oklch(100% 0 0 / 0.04) !important; -} - -.codra-toast-title { - font-size: 0.8125rem !important; - font-weight: 600 !important; - letter-spacing: 0.005em !important; - line-height: 1.35 !important; -} - -.codra-toast-description { - font-size: 0.74rem !important; - font-weight: 400 !important; - opacity: 0.72 !important; - margin-top: 0.15rem !important; - line-height: 1.5 !important; -} - -.codra-toast-icon { - margin-top: 0.05rem !important; - flex-shrink: 0 !important; -} - -.codra-toast-close { - top: 0.55rem !important; - right: 0.55rem !important; - width: 1.25rem !important; - height: 1.25rem !important; - border-radius: 0.3rem !important; - background: oklch(88% 0.006 115 / 0.6) !important; - border: 1px solid oklch(82% 0.008 115 / 0.8) !important; - color: oklch(40% 0.015 115) !important; - transition: background 150ms, opacity 150ms !important; -} - -.dark .codra-toast-close { - background: oklch(22% 0.018 115 / 0.7) !important; - border-color: oklch(30% 0.02 115 / 0.8) !important; - color: oklch(65% 0.012 115) !important; -} - -.codra-toast-close:hover { - background: oklch(82% 0.010 115) !important; - opacity: 1 !important; -} - -.dark .codra-toast-close:hover { - background: oklch(28% 0.022 115) !important; -} - -/* Status color comes from the icon; text stays the default toast color. */ -.codra-toast-loader svg { - color: var(--primary) !important; -} - -.codra-toast-warning { - color: oklch(35% 0.12 65) !important; -} - -.dark .codra-toast-warning { - color: oklch(82% 0.14 65) !important; -} - -.codra-toast-info { - color: oklch(30% 0.12 250) !important; -} - -.dark .codra-toast-info { - color: oklch(80% 0.12 250) !important; -} +@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap'); + +@import "tailwindcss"; + +/* dark: utilities follow .dark class, not OS prefers-color-scheme. */ +@custom-variant dark (&:where(.dark, .dark *)); + +/* --ui-* : local neutral surface scale (no runtime design-system dep). */ +:root { + --ui-base: #ffffff; + --ui-canvas: oklch(98.75% 0 0); + --ui-line: oklch(14.5% 0 0 / 0.1); + --ui-fill: oklch(92.2% 0 0); + --ui-subtle: oklch(55.6% 0 0); + --ui-default: oklch(21% 0 0); + --ui-strong: oklch(14.5% 0 0); +} +.dark { + --ui-base: oklch(17% 0 0); + --ui-canvas: oklch(10% 0 0); + --ui-line: oklch(32% 0 0); + --ui-fill: oklch(26.9% 0 0); + --ui-subtle: oklch(70.8% 0 0); + --ui-default: oklch(97% 0 0); + --ui-strong: oklch(98.5% 0 0); +} + +:root { + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); +} + +/* LIGHT MODE (:root default) */ +:root { + --background: oklch(100% 0 0); + --foreground: oklch(12% 0.02 115); + --card: oklch(100% 0 0); + --card-foreground: oklch(12% 0.02 115); + --popover: oklch(100% 0 0); + --popover-foreground: oklch(12% 0.02 115); + + /* Lime darkened for AA contrast on white; .dark restores full brightness. */ + --primary: oklch(64% 0.24 115); + --primary-foreground: oklch(100% 0 0); + --btn-primary-bg: oklch(64% 0.24 115); + --btn-primary-fg: oklch(20% 0.02 118); + --btn-primary-border: oklch(72% 0.17 118); + --btn-primary-surface: oklch(95% 0.09 118); + --btn-primary-hover: oklch(90% 0.13 118); + + --secondary: oklch(96.3% 0.003 286.3); + --secondary-foreground:oklch(27.4% 0.006 286.3); + --muted: oklch(96.3% 0.003 286.3); + --muted-foreground: oklch(55.1% 0.011 286.3); + + --accent: oklch(90.9% 0.004 286.3); + --accent-foreground: oklch(20.5% 0.005 286.3); + + --destructive: oklch(55% 0.22 25); + --destructive-foreground: oklch(100% 0 0); + + --border: oklch(90.9% 0.004 286.3); + --input: oklch(90.9% 0.004 286.3); + --ring: oklch(72% 0.22 115); + + --radius: 0.75rem; + --sidebar-width: 240px; + + --success: oklch(64% 0.24 115); + --success-bg: oklch(98% 0.04 115); + --success-border: oklch(85% 0.15 115); + --warning: oklch(56% 0.18 65); + --warning-bg: oklch(98% 0.04 65); + --warning-border: oklch(90% 0.12 65); + --danger: oklch(62% 0.22 25); + --danger-bg: oklch(98% 0.04 25); + --danger-border: oklch(88% 0.14 25); + --info: oklch(68% 0.18 250); + --info-bg: oklch(98% 0.04 250); + --info-border: oklch(88% 0.12 250); + + --shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.02); + --shadow-md: 0 1px 4px oklch(0% 0 0 / 0.03), 0 1px 2px oklch(0% 0 0 / 0.02); + --shadow-lg: 0 4px 16px -4px oklch(0% 0 0 / 0.04), 0 1px 6px -2px oklch(0% 0 0 / 0.03); + + --code-bg: oklch(96.3% 0.003 286.3); + --code-fg: oklch(27.4% 0.006 286.3); + --code-border: oklch(90.9% 0.004 286.3); + + /* True green/red, not brand lime, so diff rows/counts stay distinguishable. */ + --diff-add-bg: oklch(95% 0.06 150); + --diff-add-fg: oklch(48% 0.13 150); + --diff-del-bg: oklch(95% 0.05 27); + --diff-del-fg: oklch(52% 0.16 27); +} + +/* DARK MODE (.dark class on ) */ +.dark { + --background: #000000; + --foreground: oklch(98% 0.005 115); + --card: #09090b; + --card-foreground: oklch(98% 0.005 115); + --popover: #09090b; + --popover-foreground: oklch(98% 0.005 115); + + --primary: oklch(94% 0.23 115); + --primary-foreground: oklch(12% 0.04 115); + + --btn-primary-bg: #CCE800; + --btn-primary-fg: #CCE800; + --btn-primary-border: color-mix(in oklab, #CCE800 50%, transparent); + --btn-primary-surface: color-mix(in oklab, #CCE800 8%, transparent); + --btn-primary-hover: color-mix(in oklab, #CCE800 16%, transparent); + + --secondary: oklch(18% 0.018 115); + --secondary-foreground:oklch(82% 0.012 115); + --muted: oklch(18% 0.018 115); + --muted-foreground: oklch(55% 0.015 115); + + --accent: oklch(18% 0.018 115); + --accent-foreground: oklch(91% 0.010 115); + + --destructive: oklch(60% 0.220 25); + --destructive-foreground: oklch(10% 0.015 115); + + --border: oklch(22% 0.02 115); + --input: oklch(22% 0.02 115); + --ring: oklch(94% 0.23 115); + + --success: oklch(94% 0.23 115); + --success-bg: oklch(18% 0.06 115); + --success-border: oklch(28% 0.10 115); + --warning: oklch(78% 0.165 65); + --warning-bg: oklch(18% 0.080 65); + --warning-border: oklch(35% 0.14 65); + --danger: oklch(70% 0.200 25); + --danger-bg: oklch(18% 0.080 25); + --danger-border: oklch(35% 0.14 25); + --info: oklch(72% 0.160 250); + --info-bg: oklch(18% 0.075 250); + --info-border: oklch(35% 0.12 250); + + --shadow-sm: 0 1px 2px oklch(100% 0 0 / 0.05), 0 1px 2px oklch(0% 0 0 / 0.3); + --shadow-md: 0 4px 12px oklch(0% 0 0 / 0.45), 0 1px 4px oklch(0% 0 0 / 0.25); + --shadow-lg: 0 12px 24px -4px oklch(0% 0 0 / 0.5), 0 4px 12px -2px oklch(0% 0 0 / 0.3); + + --code-bg: oklch(20.5% 0.005 286.3); + --code-fg: oklch(86.5% 0.005 286.3); + --code-border: oklch(27.4% 0.006 286.3); + + --diff-add-bg: oklch(24% 0.055 150); + --diff-add-fg: oklch(82% 0.15 150); + --diff-del-bg: oklch(25% 0.075 27); + --diff-del-fg: oklch(80% 0.16 27); +} + +/* Tailwind v4 theme tokens (@theme inline = dynamic) */ +@theme inline { + --font-sans: 'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; + + /* References raw --ui-* vars so utilities flip with .dark. */ + --color-ui-base: var(--ui-base); + --color-ui-canvas: var(--ui-canvas); + --color-ui-line: var(--ui-line); + --color-ui-fill: var(--ui-fill); + --color-ui-subtle: var(--ui-subtle); + --color-ui-default: var(--ui-default); + --color-ui-strong: var(--ui-strong); + --color-ui-brand: var(--primary); + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground:var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + + --color-success: var(--success); + --color-success-bg: var(--success-bg); + --color-success-border: var(--success-border); + + --color-warning: var(--warning); + --color-warning-bg: var(--warning-bg); + --color-warning-border: var(--warning-border); + + --color-danger: var(--danger); + --color-danger-bg: var(--danger-bg); + --color-danger-border: var(--danger-border); + + --color-info: var(--info); + --color-info-bg: var(--info-bg); + --color-info-border: var(--info-border); + + /* radius-lg == radius-xl intentionally: cards and .surface share one size. */ + --radius-sm: 0.3125rem; + --radius-md: 0.4375rem; + --radius-lg: 0.6875rem; + --radius-xl: 0.6875rem; + --radius-2xl: 0.875rem; + + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: clamp(1.125rem, 2vw, 1.25rem); + --text-xl: clamp(1.25rem, 3vw, 1.5rem); + --text-2xl: clamp(1.5rem, 4vw, 2.25rem); + --text-3xl: clamp(2rem, 6vw, 3.5rem); + --text-4xl: clamp(2.5rem, 10vw, 6rem); + --text-display: clamp(3rem, 12vw, 9rem); + + --space-xs: clamp(0.5rem, 1vw, 0.75rem); + --space-sm: clamp(1rem, 2vw, 1.5rem); + --space-md: clamp(1.5rem, 4vw, 3rem); + --space-lg: clamp(3rem, 8vw, 6rem); + --space-xl: clamp(6rem, 12vw, 10rem); +} + +@layer base { + *, *::before, *::after { box-sizing: border-box; } + + html.theme-changing, + html.theme-changing *, + html.theme-changing *::before, + html.theme-changing *::after { + transition: none !important; + } + + html { + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-kerning: normal; + } + + body { + background-color: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + line-height: 1.5; + min-height: 100svh; + transition: background-color 0.3s var(--ease-out-expo), + color 0.3s var(--ease-out-expo); + } + + body::before { + content: ''; + pointer-events: none; + position: fixed; + inset: 0; + z-index: -1; + background: none; + transition: opacity 0.4s; + } + + .dark body::before { + background: + radial-gradient(ellipse 60% 45% at 0% 0%, oklch(20% 0.15 115 / 0.15), transparent 60%), + radial-gradient(ellipse 50% 40% at 100% 100%, oklch(10% 0.05 115 / 0.1), transparent 60%); + } + + a { color: inherit; text-decoration: none; } + button, input, textarea, select { font: inherit; } + pre, code { font-family: var(--font-mono); } +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@keyframes pulse-ring { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(1.25); } +} + +@keyframes fade-up { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slide-down { + from { opacity: 0; transform: translateY(-12px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes scale-in { + from { opacity: 0; transform: scale(0.96) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +@keyframes reveal-left { + from { opacity: 0; transform: translateX(-20px); } + to { opacity: 1; transform: translateX(0); } +} + +@utility animate-fade-in { + animation: fade-in 0.5s var(--ease-out-expo) both; +} + +@utility animate-fade-up { + animation: fade-up 0.6s var(--ease-out-expo) both; +} + +@utility animate-slide-down { + animation: slide-down 0.5s var(--ease-out-expo) both; +} + +@utility animate-scale-in { + animation: scale-in 0.6s var(--ease-out-expo) both; +} + +@utility animate-reveal-left { + animation: reveal-left 0.6s var(--ease-out-expo) both; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@utility animate-in { + animation-duration: 200ms; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + fill-mode: forwards; +} + +@utility animate-out { + animation-duration: 200ms; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + fill-mode: forwards; +} + +@utility fade-in-0 { + --tw-enter-opacity: 0; + animation-name: enter; +} + +@utility fade-out-0 { + --tw-exit-opacity: 0; + animation-name: exit; +} + +@utility zoom-in-95 { + --tw-enter-scale: 0.95; + animation-name: enter; +} + +@utility zoom-in-98 { + --tw-enter-scale: 0.98; + animation-name: enter; +} + +@utility zoom-out-95 { + --tw-exit-scale: 0.95; + animation-name: exit; +} + +@utility zoom-out-98 { + --tw-exit-scale: 0.98; + animation-name: exit; +} + +@utility slide-in-from-top-1 { + --tw-enter-translate-y: -4px; + animation-name: enter; +} + +@utility slide-in-from-top-2 { + --tw-enter-translate-y: -8px; + animation-name: enter; +} + +@utility slide-in-from-bottom-1 { + --tw-enter-translate-y: 4px; + animation-name: enter; +} + +@utility slide-in-from-bottom-2 { + --tw-enter-translate-y: 8px; + animation-name: enter; +} + +@keyframes enter { + from { + opacity: var(--tw-enter-opacity, 1); + transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0) scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), 1) rotate(var(--tw-enter-rotate, 0)); + } +} + +@keyframes exit { + to { + opacity: var(--tw-exit-opacity, 1); + transform: translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0) scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), 1) rotate(var(--tw-exit-rotate, 0)); + } +} + +@utility page-enter { + animation: fade-up 0.5s var(--ease-out-expo) both; + & > * { + animation: fade-up 0.5s var(--ease-out-expo) both; + } + & > *:nth-child(1) { animation-delay: 0ms; } + & > *:nth-child(2) { animation-delay: 60ms; } + & > *:nth-child(3) { animation-delay: 120ms; } + & > *:nth-child(4) { animation-delay: 180ms; } + & > *:nth-child(5) { animation-delay: 240ms; } + & > *:nth-child(6) { animation-delay: 300ms; } + + @media (prefers-reduced-motion: reduce) { + animation: none !important; + & > * { animation: none !important; } + } +} + +/* Toggled by JS IntersectionObserver. */ +@utility reveal-on-scroll { + opacity: 0; + transform: translateY(24px); + transition: + opacity 0.65s var(--ease-out-expo), + transform 0.65s var(--ease-out-expo); + + &.is-visible { + opacity: 1; + transform: translateY(0); + } + + @media (prefers-reduced-motion: reduce) { + opacity: 1 !important; + transform: none !important; + transition: none !important; + } +} + +@utility reveal-delay-1 { transition-delay: 80ms !important; } +@utility reveal-delay-2 { transition-delay: 160ms !important; } +@utility reveal-delay-3 { transition-delay: 240ms !important; } +@utility reveal-delay-4 { transition-delay: 320ms !important; } + +/* Scrollbars: neutral grey, auto-hidden via app-shell.tsx toggling data-scrolling. */ +* { + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} + +[data-scrolling] { + scrollbar-color: oklch(0% 0 0 / 0.32) transparent; +} +.dark [data-scrolling] { + scrollbar-color: oklch(100% 0 0 / 0.3) transparent; +} + +/* Transparent border + padding-box clip insets the thumb into a slim bar. */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background-color: transparent; + border: 3px solid transparent; + background-clip: padding-box; + border-radius: 999px; + transition: background-color 0.3s ease; +} +[data-scrolling]::-webkit-scrollbar-thumb { + background-color: oklch(0% 0 0 / 0.3); +} +[data-scrolling]::-webkit-scrollbar-thumb:hover { + background-color: oklch(0% 0 0 / 0.45); +} +.dark [data-scrolling]::-webkit-scrollbar-thumb { + background-color: oklch(100% 0 0 / 0.28); +} +.dark [data-scrolling]::-webkit-scrollbar-thumb:hover { + background-color: oklch(100% 0 0 / 0.45); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +@utility surface { + @apply bg-card border border-border rounded-xl; + box-shadow: var(--shadow-md); +} + +.surface-static { + transition: none !important; +} + +.surface-static:hover { + box-shadow: var(--shadow-md) !important; + transform: none !important; +} + +.surface-static-shadow { + box-shadow: var(--shadow-md) !important; + transition: none !important; +} + +.surface-static-shadow:hover { + box-shadow: var(--shadow-md) !important; + transform: none !important; +} + +@utility glass { + @apply backdrop-blur-md bg-card/75 border border-border; + background-image: linear-gradient(to bottom right, oklch(100% 0 0 / 0.05), transparent); +} + +@utility surface-hover { + @apply transition-all duration-300; + &:hover { + @apply border-primary/30 shadow-lg shadow-primary/5 -translate-y-[1px]; + } +} + +@utility skeleton { + background: linear-gradient( + 90deg, + var(--muted) 25%, + color-mix(in oklch, var(--muted) 50%, var(--card)) 50%, + var(--muted) 75% + ); + background-size: 200% 100%; + @apply animate-[shimmer_1.8s_linear_infinite] rounded-sm; +} + +/* Unlayered, kept at this specificity so no later utility can beat it. */ +.skeleton { + background: + linear-gradient( + 90deg, + var(--muted) 25%, + color-mix(in oklch, var(--muted) 50%, var(--card)) 50%, + var(--muted) 75% + ) !important; + background-size: 200% 100% !important; + animation: shimmer 1.8s linear infinite !important; +} + +/* Model-call bars: override default lime fill with info/blue. */ +.meter-indicator-info { + background-image: none !important; + background-color: var(--info) !important; +} + +@utility code-block { + @apply m-0 whitespace-pre-wrap break-words px-[1.125rem] py-4 rounded-md font-mono text-[0.775rem] leading-[1.7] overflow-auto tracking-[0.01em]; + background-color: var(--code-bg); + color: var(--code-fg); + border: 1px solid var(--code-border); +} + +@utility severity-tag { + @apply text-[0.64rem] px-[5.5px] py-[1.5px] rounded-[3px] uppercase font-bold tracking-[0.07em] border border-transparent; + + &.P0 { @apply bg-danger-bg text-danger border-danger-border; } + &.P1 { @apply bg-warning-bg text-warning border-warning-border; } + &.P2 { + @apply bg-[oklch(95%_0.06_65)] text-[oklch(50%_0.14_65)] border-[oklch(83%_0.09_65)]; + .dark & { + @apply bg-[oklch(21%_0.07_65)] text-[oklch(72%_0.14_65)] border-[oklch(31%_0.09_65)]; + } + } + &.P3 { @apply bg-info-bg text-info border-info-border; } + &.nit { @apply bg-ui-fill/50 text-ui-subtle border-ui-line; } +} + +@utility category-tag { + @apply text-[0.72rem] text-muted-foreground inline-flex items-center gap-[5px] font-medium; + + &::before { + content: ''; + @apply w-[5px] h-[5px] rounded-full bg-current flex-shrink-0 inline-block; + } + + &.security { @apply text-danger; } + &.performance { @apply text-info; } + &.bugs { @apply text-warning; } + &.correctness { @apply text-success; } + &.quality { + @apply text-[oklch(56%_0.16_295)]; + .dark & { @apply text-[oklch(70%_0.14_295)]; } + } +} + +@utility step-dot { + @apply w-2 h-2 rounded-full flex-shrink-0; + + &.pending { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); } + &.running { + @apply bg-info; + } + &.done { @apply bg-success; } + &.failed { @apply bg-danger; } +} + +@utility pulsing-dot { + @apply w-[7px] h-[7px] rounded-full bg-info inline-block; +} + +.recharts-default-tooltip { + background: var(--card) !important; + border: 1px solid var(--border) !important; + border-radius: 10px !important; + box-shadow: 0 8px 32px oklch(5% 0.01 115 / 0.18) !important; + font-family: var(--font-sans) !important; +} +.dark .recharts-default-tooltip { + box-shadow: 0 8px 32px oklch(0% 0 0 / 0.5) !important; +} + +.app-shell-content { + --background: oklch(97.8% 0.002 286.3); + --card: oklch(100% 0 0); + --muted: oklch(90.9% 0.004 286.3); + --popover: oklch(100% 0 0); + --secondary: oklch(88.5% 0.004 286.3); + --border: oklch(90.9% 0.004 286.3); + --input: oklch(90.9% 0.004 286.3); +} + +.dark .app-shell-content { + /* Cool neutral hue 286.3, not hue 115 which gave the card a warm olive cast. */ + --background: oklch(18% 0.006 286.3); + --card: oklch(18% 0.006 286.3); + --muted: oklch(22% 0.006 286.3); + --popover: oklch(18% 0.006 286.3); + --secondary: oklch(26% 0.007 286.3); + --border: oklch(22% 0.006 286.3); + --input: oklch(22% 0.006 286.3); +} + +/* SharedLayoutBg pill is the sole hover affordance; row itself never transforms. */ +.dashboard-sidebar-action:hover, +.dashboard-sidebar-action:focus-visible, +.dashboard-sidebar-action:active { + transform: none !important; +} + +/* Light beam parked off-screen, sweeps across once on hover/focus. */ +.dashboard-sidebar-shine { + transform: skew(-13deg) translateX(-130%); + transition: transform 0ms linear; + will-change: transform; +} +.dashboard-sidebar-action:hover .dashboard-sidebar-shine, +.dashboard-sidebar-action:focus-visible .dashboard-sidebar-shine { + transform: skew(-13deg) translateX(130%); + transition-duration: 1500ms; + transition-timing-function: var(--ease-out-quart); +} + +@utility chart-card { + @apply bg-card border border-border rounded-lg overflow-hidden relative; + box-shadow: var(--shadow-md); +} + +@utility chart-card-inner { + @apply absolute inset-0 pointer-events-none z-0; + background-image: radial-gradient( + circle, + color-mix(in oklch, var(--primary) 12%, transparent) 1px, + transparent 1px + ); + background-size: 20px 20px; +} + +.chart-card > * { position: relative; z-index: 1; } + +@utility stat-number { + @apply text-2xl md:text-3xl lg:text-[2.25rem] font-bold tracking-[-0.04em] leading-none text-foreground tabular-nums; +} + +/* Geist, scoped locally since global @theme sets --font-sans/mono to app defaults. */ +.ui-font-sans { + font-family: 'Geist', ui-sans-serif, system-ui, sans-serif; +} +.ui-font-mono { + font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-feature-settings: 'tnum' 1; +} + +/* Matches dashboard stat-card chrome; .ui-well is its recessed inner panel. */ +.ui-panel { + font-family: 'Geist', ui-sans-serif, system-ui, sans-serif; + border-radius: var(--radius-lg); + border: 1px solid var(--ui-line); + background: #ffffff; +} +.dark .ui-panel { + background: #000000; + border-color: oklch(0.27 0 0); +} +.ui-well { + background: oklch(97.8% 0.002 286.3); + /* On the recessed face the neutral-500 subtle tone reads washed out in light mode, + so step it down to zinc-600. Dark mode already has enough separation. */ + --ui-subtle: oklch(44.2% 0.017 285.8); +} +.dark .ui-well { + background: oklch(19% 0 0); + --ui-subtle: oklch(70.8% 0 0); +} + +/* Syntax tokens for sugar-high (src/client/lib/highlight.tsx); it emits + color: var(--sh-) per token. */ +:root { + --sh-keyword: oklch(48% 0.19 305); + --sh-string: oklch(46% 0.12 150); + --sh-class: oklch(50% 0.13 65); + --sh-comment: oklch(58% 0.01 260); + --sh-entity: oklch(46% 0.14 260); + --sh-property: oklch(45% 0.11 200); + --sh-identifier: inherit; + --sh-sign: oklch(58% 0.01 260); + --sh-jsxliterals: inherit; + --sh-break: inherit; + --sh-space: inherit; +} +.dark { + --sh-keyword: oklch(75% 0.14 305); + --sh-string: oklch(76% 0.11 150); + --sh-class: oklch(78% 0.12 65); + --sh-comment: oklch(58% 0.01 260); + --sh-entity: oklch(76% 0.1 260); + --sh-property: oklch(78% 0.1 200); +} +.sh__token--comment { font-style: italic; } + +.diff-add { background-color: var(--diff-add-bg); } +.diff-del { background-color: var(--diff-del-bg); } +.diff-add-fg { color: var(--diff-add-fg); } +.diff-del-fg { color: var(--diff-del-fg); } + +@keyframes ui-fade-in { + from { opacity: 0; transform: translateY(2px); } + to { opacity: 1; transform: translateY(0); } +} +.ui-fade-in { + animation: ui-fade-in 0.25s ease-out both; +} + +.diff-tree ul { + list-style: none; + margin: 0; + padding: 0; +} +.diff-tree ul ul { + margin-left: 10px; + padding-left: 8px; + border-left: 1px solid var(--ui-line); +} +.diff-tree li { + position: relative; + margin-top: 2px; +} +.diff-tree ul ul li::before { + content: ""; + position: absolute; + left: -8px; + top: 14px; + width: 6px; + height: 1px; + background-color: var(--ui-line); +} +.diff-tree-children { + display: grid; + /* Implicit column would size to content (auto); pin full width so rows stretch edge-to-edge. */ + grid-template-columns: minmax(0, 1fr); + grid-template-rows: 1fr; + transition: grid-template-rows 0.25s ease-in-out; +} +.diff-tree-children[data-collapsed="true"] { + grid-template-rows: 0fr; +} +.diff-tree-children > div { + overflow: hidden; + min-width: 0; +} + +/* .thin-scroll / .auto-hide-scroll kept as no-op aliases: the treatment is + now global (Scrollbars block above); existing markup referencing them still works. */ + +.diff-tree-scroll { + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +@utility stat-label { + @apply text-[0.65rem] md:text-[0.7rem] lg:text-[0.72rem] font-semibold tracking-[0.06em] uppercase text-muted-foreground; + .dark & { color: color-mix(in oklch, var(--foreground) 72%, transparent); } +} + +@utility prose { + @apply text-[0.875rem] leading-[1.75] text-foreground; + + & h1, & h2, & h3, & h4 { + @apply font-bold leading-[1.3] mt-[1.4em] mb-[0.4em] tracking-[-0.01em]; + } + & h1 { @apply text-[1.2rem]; } + & h2 { @apply text-[1.05rem]; } + & h3 { @apply text-[0.95rem]; } + & p { @apply my-[0.6em]; } + & ul, & ol { @apply pl-[1.4em] my-[0.5em]; } + & li { @apply my-[0.2em]; } + & strong { @apply font-bold; } + & em { @apply italic; } + & code { + @apply font-mono text-[0.78em] bg-ui-fill/60 text-ui-strong px-[0.3em] py-[0.1em] rounded-[3px] border border-ui-line; + } + & pre { + @apply px-4 py-[0.85rem] rounded-md overflow-x-auto text-[0.78em]; + background-color: var(--code-bg); + color: var(--code-fg); + border: 1px solid var(--code-border); + } + & pre code { + @apply bg-transparent border-none p-0 text-inherit; + } + & blockquote { + @apply border-l-2 border-primary pl-4 text-muted-foreground my-[0.85em]; + } + & a { @apply text-primary underline underline-offset-2; } + & hr { @apply border-border my-[1.5em]; } +} + +/* Sonner toast overrides */ + +[data-sonner-toaster] { + --offset: 1.25rem !important; + --width: min(22rem, calc(100vw - 2rem)) !important; + font-family: var(--font-sans) !important; +} + +.codra-toast { + display: flex !important; + align-items: flex-start !important; + gap: 0.625rem !important; + padding: 0.75rem 0.875rem !important; + border-radius: 0.625rem !important; + border: none !important; + font-family: var(--font-sans) !important; + font-size: 0.8125rem !important; + line-height: 1.45 !important; + box-shadow: + 0 4px 16px oklch(0% 0 0 / 0.10), + 0 1px 4px oklch(0% 0 0 / 0.06), + inset 0 1px 0 oklch(100% 0 0 / 0.05) !important; + + background: oklch(99.5% 0.004 115) !important; + color: oklch(15% 0.02 115) !important; + + animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1) !important; +} + +.dark .codra-toast { + background: oklch(13% 0.018 115) !important; + color: oklch(94% 0.006 115) !important; + box-shadow: + 0 6px 24px oklch(0% 0 0 / 0.5), + 0 1px 6px oklch(0% 0 0 / 0.3), + inset 0 1px 0 oklch(100% 0 0 / 0.04) !important; +} + +.codra-toast-title { + font-size: 0.8125rem !important; + font-weight: 600 !important; + letter-spacing: 0.005em !important; + line-height: 1.35 !important; +} + +.codra-toast-description { + font-size: 0.74rem !important; + font-weight: 400 !important; + opacity: 0.72 !important; + margin-top: 0.15rem !important; + line-height: 1.5 !important; +} + +.codra-toast-icon { + margin-top: 0.05rem !important; + flex-shrink: 0 !important; +} + +.codra-toast-close { + top: 0.55rem !important; + right: 0.55rem !important; + width: 1.25rem !important; + height: 1.25rem !important; + border-radius: 0.3rem !important; + background: oklch(88% 0.006 115 / 0.6) !important; + border: 1px solid oklch(82% 0.008 115 / 0.8) !important; + color: oklch(40% 0.015 115) !important; + transition: background 150ms, opacity 150ms !important; +} + +.dark .codra-toast-close { + background: oklch(22% 0.018 115 / 0.7) !important; + border-color: oklch(30% 0.02 115 / 0.8) !important; + color: oklch(65% 0.012 115) !important; +} + +.codra-toast-close:hover { + background: oklch(82% 0.010 115) !important; + opacity: 1 !important; +} + +.dark .codra-toast-close:hover { + background: oklch(28% 0.022 115) !important; +} + +/* Status color comes from the icon; text stays the default toast color. */ +.codra-toast-loader svg { + color: var(--primary) !important; +} + +.codra-toast-warning { + color: oklch(35% 0.12 65) !important; +} + +.dark .codra-toast-warning { + color: oklch(82% 0.14 65) !important; +} + +.codra-toast-info { + color: oklch(30% 0.12 250) !important; +} + +.dark .codra-toast-info { + color: oklch(80% 0.12 250) !important; +} diff --git a/src/client/components/features/job-detail/job-header.tsx b/src/client/components/features/job-detail/job-header.tsx index 081fddf3..8bc3947c 100644 --- a/src/client/components/features/job-detail/job-header.tsx +++ b/src/client/components/features/job-detail/job-header.tsx @@ -1,234 +1,237 @@ -import { Button, ConfirmDialog } from '@codraoss/ui'; -import { useState } from 'react'; -import type { ComponentType } from 'react'; -import { Link } from 'react-router-dom'; -import { - ChevronRight, - ExternalLink, - FolderGit2, - GitBranch, - GitCommitHorizontal, - GitPullRequest, - Loader2, - RotateCcw, - Terminal, - Trash2, -} from 'lucide-react'; -import type { ButtonProps } from '@codraoss/ui'; -import { UpdatesEmailPrompt } from '@client/components/features/dashboard/updates-email-prompt'; -import { AuthorChip, JobStatusLine, MetaChip, VerdictPill } from './job-chips'; -import { formatAbsoluteDate, formatRelativeDate } from './job-chip-utils'; -import type { JobDetail } from '@codraoss/schema'; - -// Lucide's CircleStop strokes the inner square too, which reads as a blob at 14px; filling it -// instead keeps the stop symbol legible. -function StopIcon({ size = 14 }: { size?: number }) { - return ( - - ); -} - -interface JobActionButtonProps { - icon: ComponentType<{ size?: number }>; - label: string; - /** In-flight: swaps the icon for a spinner. Also disables unless `disabled` says otherwise. */ - busy: boolean; - disabled?: boolean; - variant?: ButtonProps['variant']; - className?: string; - onClick: () => void; -} - -// Every header action is the same icon-only button whose only state is "in flight", so the busy flag -// lives here rather than branching the header itself. -function JobActionButton({ - icon: Icon, - label, - busy, - disabled, - variant = 'secondary', - className = 'rounded-[7px]', - onClick, -}: JobActionButtonProps) { - return ( - - ); -} - -interface JobHeaderProps { - job: JobDetail; - isRerunning: boolean; - isStopping: boolean; - isDeleting: boolean; - onRerun: () => void; - onStop: () => void; - onDelete: () => void; -} - -export function JobHeader({ - job, - isRerunning, - isStopping, - isDeleting, - onRerun, - onStop, - onDelete, -}: JobHeaderProps) { - const [stopOpen, setStopOpen] = useState(false); - const [deleteOpen, setDeleteOpen] = useState(false); - - const canStop = job.status === 'running' || job.status === 'queued'; - - return ( - <> - {/* The header is the detail page's version of a table row: same vocabulary as the jobs table. */} -
-
- {/* Deliberately thin: the repo and PR live in the chip row below, so this only carries the way back and the job id. */} -
- - Jobs - - - - {job.id.slice(0, 8)} - -
- -

- - {job.prTitle ?? 'Untitled pull request'} - - -

- -
- - - {job.verdict && } - - - {job.owner}/{job.repo} - - - - #{job.prNumber} - - - {job.commitSha && ( - - {job.commitSha.slice(0, 7)} - - )} - - {/* Branch pair is the widest and least essential chip, so it is capped and drops off first. */} - {job.baseRef && job.headRef && ( - - {job.baseRef} ← {job.headRef} - - )} - - - - - {formatRelativeDate(job.createdAt)} - -
-
- -
- - - setStopOpen(true)} - /> - - {/* Always restarts the review from the beginning (every file), regardless of the job's current status. */} - - - setDeleteOpen(true)} - /> -
-
- - - - - - - - ); -} +import { Button, ConfirmDialog } from '@codraoss/ui'; +import { useState } from 'react'; +import type { ComponentType } from 'react'; +import { Link } from 'react-router-dom'; +import { ChevronRight, ExternalLink, Loader2, RotateCcw, Terminal, Trash2 } from 'lucide-react'; +import type { ButtonProps } from '@codraoss/ui'; +import { UpdatesEmailPrompt } from '@client/components/features/dashboard/updates-email-prompt'; +import { AuthorChip, VerdictPill } from './job-chips'; +import { formatAbsoluteDate, formatRelativeDate } from './job-chip-utils'; +import type { JobDetail } from '@codraoss/schema'; + +// Lucide's CircleStop strokes the inner square too, which reads as a blob at 14px; filling it +// instead keeps the stop symbol legible. +function StopIcon({ size = 14 }: { size?: number }) { + return ( + + ); +} + +interface JobActionButtonProps { + icon: ComponentType<{ size?: number }>; + label: string; + /** In-flight: swaps the icon for a spinner. Also disables unless `disabled` says otherwise. */ + busy: boolean; + disabled?: boolean; + variant?: ButtonProps['variant']; + className?: string; + onClick: () => void; +} + +// Every header action is the same icon-only button whose only state is "in flight", so the busy flag +// lives here rather than branching the header itself. +function JobActionButton({ + icon: Icon, + label, + busy, + disabled, + variant = 'secondary', + className = 'rounded-[7px]', + onClick, +}: JobActionButtonProps) { + return ( + + ); +} + +/** `·` inside a group of related facts, `|` between groups. */ +function Dot() { + return ·; +} + +function Pipe() { + return |; +} + +interface JobHeaderProps { + job: JobDetail; + isRerunning: boolean; + isStopping: boolean; + isDeleting: boolean; + onRerun: () => void; + onStop: () => void; + onDelete: () => void; +} + +export function JobHeader({ + job, + isRerunning, + isStopping, + isDeleting, + onRerun, + onStop, + onDelete, +}: JobHeaderProps) { + const [stopOpen, setStopOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const canStop = job.status === 'running' || job.status === 'queued'; + + return ( + <> + {/* Full-bleed header: a hairline rule under the breadcrumb bar, then the title and the PR's + coordinates. No card - the panels below are the cards, and framing this too would nest a + surface inside a surface. Status, token counts and the step list live in those panels. */} +
+
+
+ + Jobs + + + + {job.id.slice(0, 8)} + +
+ +
+ + + setStopOpen(true)} + /> + + {/* Always restarts the review from the beginning (every file), regardless of the job's current status. */} + + + setDeleteOpen(true)} + /> +
+
+ +
+

+ + {job.prTitle ?? 'Untitled pull request'} + + + {job.verdict && } +

+ + {/* Coordinates, in one readable line rather than a row of chips. */} +
+ + {job.owner}/{job.repo} + + + #{job.prNumber} + {job.commitSha && ( + <> + + + {job.commitSha.slice(0, 7)} + + + )} + + {job.baseRef && job.headRef && ( + <> + + + {job.baseRef} ← {job.headRef} + + + )} + + + + + + {formatRelativeDate(job.createdAt)} + +
+
+
+ + + + + + + + ); +} diff --git a/src/client/components/features/job-detail/job-meta-cards.tsx b/src/client/components/features/job-detail/job-meta-cards.tsx index 1ca022e9..52a5c08a 100644 --- a/src/client/components/features/job-detail/job-meta-cards.tsx +++ b/src/client/components/features/job-detail/job-meta-cards.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { AtSign, ExternalLink, Info, ListChecks, RotateCcw, Zap } from 'lucide-react'; +import { AtSign, Info, ListChecks, RotateCcw, Zap } from 'lucide-react'; import { Link } from 'react-router-dom'; import { cn, formatPreciseDuration } from '@codraoss/ui/utils'; import type { JobDetail, JobStep } from '@codraoss/schema'; @@ -42,12 +42,13 @@ function MetaPanel({ children: ReactNode; }) { return ( -
-
+
+
-

{title}

+

{title}

-
{children}
+ {/* Recessed inner panel, same as the dashboard stat cards. */} +
{children}
); } @@ -97,7 +98,6 @@ function StepRow({ step }: { step: JobStep }) { } export function JobMetaCards({ job }: JobMetaCardsProps) { - const isPartialReview = job.status === 'done' && job.errorMessage?.startsWith('Partial review:'); const steps = job.steps ?? []; const TriggerIcon = TRIGGER_ICON[job.trigger] ?? Zap; @@ -134,19 +134,6 @@ export function JobMetaCards({ job }: JobMetaCardsProps) { - {job.reviewId && ( - - - GitHub - - - )} - {job.retryOfJobId && ( - {job.errorMessage && ( -
-

- - {isPartialReview ? 'Partial review' : 'Error'} -

-

- {job.errorMessage} -

-
- )} {steps.length === 0 ? ( -

No steps recorded yet.

+

No steps recorded yet.

) : ( steps.map((step) => ) )} diff --git a/src/client/components/features/job-detail/job-progress.tsx b/src/client/components/features/job-detail/job-progress.tsx index 97e42fb5..d5dd095c 100644 --- a/src/client/components/features/job-detail/job-progress.tsx +++ b/src/client/components/features/job-detail/job-progress.tsx @@ -1,75 +1,76 @@ -import { FileCode2, Hourglass } from 'lucide-react'; -import type { JobDetail } from '@codraoss/schema'; - -interface JobProgressProps { - job: JobDetail; -} - -export function JobProgress({ job }: JobProgressProps) { - if (job.status !== 'running' && job.status !== 'queued') return null; - - const finishedCount = job.files.filter(f => f.fileStatus === 'done' || f.fileStatus === 'skipped').length; - const total = job.fileCount || 0; - const pct = total > 0 ? Math.round((finishedCount / total) * 100) : 0; - const isQueued = job.status === 'queued'; - - const activeFile = job.files.find(f => f.fileStatus === 'pending'); - const activeFilePath = activeFile?.filePath ?? null; - - const displayPath = activeFilePath - ? activeFilePath.split('/').slice(-2).join('/') - : null; - const prefixPath = activeFilePath && activeFilePath.includes('/') - ? activeFilePath.split('/').slice(0, -2).join('/') + '/' - : null; - - return ( -
-
-
- {isQueued - ? - : - } - - {isQueued ? 'Waiting in queue' : 'Reviewing files'} - -
- - {isQueued ? '-' : `${finishedCount} / ${total}`} - -
- -
-
-
-
- - {!isQueued && ( -
-
- {prefixPath && ( - {prefixPath} - )} - {displayPath - ? {displayPath} - : {Math.max(total - finishedCount, 0)} {total - finishedCount === 1 ? 'file' : 'files'} remaining - } -
- {pct}% -
- )} -
-
- ); -} +import { FileCode2, Hourglass } from 'lucide-react'; +import type { JobDetail } from '@codraoss/schema'; + +interface JobProgressProps { + job: JobDetail; +} + +export function JobProgress({ job }: JobProgressProps) { + if (job.status !== 'running' && job.status !== 'queued') return null; + + const finishedCount = job.files.filter(f => f.fileStatus === 'done' || f.fileStatus === 'skipped').length; + const total = job.fileCount || 0; + const pct = total > 0 ? Math.round((finishedCount / total) * 100) : 0; + const isQueued = job.status === 'queued'; + + const activeFile = job.files.find(f => f.fileStatus === 'pending'); + const activeFilePath = activeFile?.filePath ?? null; + + const displayPath = activeFilePath + ? activeFilePath.split('/').slice(-2).join('/') + : null; + const prefixPath = activeFilePath && activeFilePath.includes('/') + ? activeFilePath.split('/').slice(0, -2).join('/') + '/' + : null; + + return ( +
+
+
+ {isQueued + ? + : + } + + {isQueued ? 'Waiting in queue' : 'Reviewing files'} + +
+ + {isQueued ? '-' : `${finishedCount} / ${total}`} + +
+ + {/* Recessed inner panel, same as the dashboard stat cards. */} +
+
+
+
+ + {!isQueued && ( +
+
+ {prefixPath && ( + {prefixPath} + )} + {displayPath + ? {displayPath} + : {Math.max(total - finishedCount, 0)} {total - finishedCount === 1 ? 'file' : 'files'} remaining + } +
+ {pct}% +
+ )} +
+
+ ); +} diff --git a/src/client/components/features/job-detail/job-review-overview.tsx b/src/client/components/features/job-detail/job-review-overview.tsx index d3e875e1..1acebd31 100644 --- a/src/client/components/features/job-detail/job-review-overview.tsx +++ b/src/client/components/features/job-detail/job-review-overview.tsx @@ -1,101 +1,103 @@ -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import { CheckCircle2, ClipboardList, TriangleAlert } from 'lucide-react'; -import type { JobDetail } from '@codraoss/schema'; -import { reviewSeverities } from '@codraoss/schema/review-limits'; -import { OutlinePill } from './job-chips'; - -import { safeRehypePlugins } from '@codraoss/ui/markdown-plugins'; -interface JobReviewOverviewProps { - job: JobDetail; -} - -export function JobReviewOverview({ job }: JobReviewOverviewProps) { - const hasOverview = !!(job.summaryMarkdown || job.overallCorrectness || (job.overallConfidenceScore !== undefined && job.overallConfidenceScore !== null)); - if (!hasOverview) return null; - - const allComments = job.files.flatMap((f) => f.parsedComments); - const sevCounts = Object.fromEntries( - reviewSeverities.map((s) => [s, allComments.filter((c) => c.severity === s).length]), - ); - - const renderSummary = () => { - if (!job.summaryMarkdown) return ''; - const content = job.summaryMarkdown.replace(/^(✅ \*\*Approved\*\*|💬 \*\*Comments posted\*\*)\n\n/, '').trim(); - - // Strip only the "### ... Codra Review" heading, keep the intro sentence - const stripHeader = (md: string) => md - .replace(/^###\s*([\s\S]*?<\/picture>|💡)\s*Codra Review\s*\n+/, '') - .trim(); - - if (content.startsWith('### 💡 Codra Review') || content.includes('Codra Review')) { - return stripHeader(content); - } - - const shortSha = job.commitSha.slice(0, 10); - const baseUrl = typeof window !== 'undefined' ? window.location.origin : ''; - - return `Here are some automated review suggestions for this pull request.\n\n**Reviewed commit:** \`${shortSha}\`\n\n
\nℹ️ About Codra\n\n
\n\n[Your team has set up Codra to review pull requests in this repo](${baseUrl}/repos). Reviews are triggered when you:\n\n- **Open** a pull request for review\n- **Mark** a draft as ready\n- **Comment** "@codra-app review"\n\nIf Codra has suggestions, it will comment; otherwise it will react with 👍.\n\nCodra can also answer questions or update the PR. Try commenting "@codra-app address that feedback".\n\n
\n\n---\n\n${content}`; - }; - - return ( -
-
-
- -

Review overview

-
- {/* Correctness and confidence read as chips: neutral border, colour only in the leading icon. */} -
- {job.overallCorrectness && (() => { - const incorrect = job.overallCorrectness.toLowerCase().includes('incorrect'); - return ( - - {job.overallCorrectness} - - ); - })()} - {(job.overallConfidenceScore !== undefined && job.overallConfidenceScore !== null) && ( - - Confidence - - {(Number(job.overallConfidenceScore) * 100).toFixed(0)}% - - - )} -
-
- - {/* Markdown's own leading/trailing block margins are zeroed so the card padding alone controls the gap. */} -
-
- - {renderSummary()} - -
-
- -
-
-

Priority triage

- {reviewSeverities.map((sev) => { - const count = sevCounts[sev] || 0; - if (count === 0 && sev !== 'nit') return null; - - return ( -
- {sev} - - {count} - -
- ); - })} -
-
-
- ); -} +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { CheckCircle2, ClipboardList, TriangleAlert } from 'lucide-react'; +import type { JobDetail } from '@codraoss/schema'; +import { reviewSeverities } from '@codraoss/schema/review-limits'; +import { OutlinePill } from './job-chips'; + +import { safeRehypePlugins } from '@codraoss/ui/markdown-plugins'; +interface JobReviewOverviewProps { + job: JobDetail; +} + +export function JobReviewOverview({ job }: JobReviewOverviewProps) { + const hasOverview = !!(job.summaryMarkdown || job.overallCorrectness || (job.overallConfidenceScore !== undefined && job.overallConfidenceScore !== null)); + if (!hasOverview) return null; + + const allComments = job.files.flatMap((f) => f.parsedComments); + const sevCounts = Object.fromEntries( + reviewSeverities.map((s) => [s, allComments.filter((c) => c.severity === s).length]), + ); + + const renderSummary = () => { + if (!job.summaryMarkdown) return ''; + const content = job.summaryMarkdown.replace(/^(✅ \*\*Approved\*\*|💬 \*\*Comments posted\*\*)\n\n/, '').trim(); + + // Strip only the "### ... Codra Review" heading, keep the intro sentence + const stripHeader = (md: string) => md + .replace(/^###\s*([\s\S]*?<\/picture>|💡)\s*Codra Review\s*\n+/, '') + .trim(); + + if (content.startsWith('### 💡 Codra Review') || content.includes('Codra Review')) { + return stripHeader(content); + } + + const shortSha = job.commitSha.slice(0, 10); + const baseUrl = typeof window !== 'undefined' ? window.location.origin : ''; + + return `Here are some automated review suggestions for this pull request.\n\n**Reviewed commit:** \`${shortSha}\`\n\n
\nℹ️ About Codra\n\n
\n\n[Your team has set up Codra to review pull requests in this repo](${baseUrl}/repos). Reviews are triggered when you:\n\n- **Open** a pull request for review\n- **Mark** a draft as ready\n- **Comment** "@codra-app review"\n\nIf Codra has suggestions, it will comment; otherwise it will react with 👍.\n\nCodra can also answer questions or update the PR. Try commenting "@codra-app address that feedback".\n\n
\n\n---\n\n${content}`; + }; + + return ( +
+
+
+ +

Review overview

+
+ {/* Correctness and confidence read as chips: neutral border, colour only in the leading icon. */} +
+ {job.overallCorrectness && (() => { + const incorrect = job.overallCorrectness.toLowerCase().includes('incorrect'); + return ( + + {job.overallCorrectness} + + ); + })()} + {(job.overallConfidenceScore !== undefined && job.overallConfidenceScore !== null) && ( + + Confidence + + {(Number(job.overallConfidenceScore) * 100).toFixed(0)}% + + + )} +
+
+ + {/* Recessed inner panel (same as the dashboard stat cards); markdown's own leading/trailing + block margins are zeroed so the well padding alone controls the gap. */} +
+
+ + {renderSummary()} + +
+
+ + {/* Footer sits on the card face, mirroring the stat cards' delta row. */} +
+
+

Priority triage

+ {reviewSeverities.map((sev) => { + const count = sevCounts[sev] || 0; + if (count === 0 && sev !== 'nit') return null; + + return ( +
+ {sev} + + {count} + +
+ ); + })} +
+
+
+ ); +} diff --git a/src/client/components/features/job-detail/job-status-notice.tsx b/src/client/components/features/job-detail/job-status-notice.tsx new file mode 100644 index 00000000..0c5975cf --- /dev/null +++ b/src/client/components/features/job-detail/job-status-notice.tsx @@ -0,0 +1,141 @@ +import { CircleSlash, History, OctagonAlert, TriangleAlert, type LucideIcon } from 'lucide-react'; +import { cn } from '@codraoss/ui/utils'; +import type { JobDetail } from '@codraoss/schema'; + +interface JobStatusNoticeProps { + job: JobDetail; +} + +type Tone = 'danger' | 'warning' | 'neutral'; + +interface Notice { + tone: Tone; + icon: LucideIcon; + title: string; + /** Plain-language explanation of what happened. */ + hint: string; + /** Raw server message, shown as a mono block only when it adds something the hint doesn't. */ + detail?: string | null; +} + +// Icon tile + border tone per notice kind. Neutral outcomes (superseded/stopped) deliberately +// avoid red: nothing went wrong, the run just stopped mattering. +const TONE: Record = { + danger: { + tile: 'border-danger-border bg-danger-bg', + icon: 'text-danger', + detail: 'border-danger-border/60 bg-danger-bg text-danger', + }, + warning: { + tile: 'border-warning-border bg-warning-bg', + icon: 'text-warning', + detail: 'border-warning-border/60 bg-warning-bg text-warning', + }, + neutral: { + tile: 'border-ui-line bg-ui-fill/40', + icon: 'text-ui-default', + detail: 'border-ui-line ui-well text-ui-subtle', + }, +}; + +function describe(job: JobDetail): Notice | null { + const message = job.errorMessage?.trim() || null; + + if (job.status === 'done' && message?.startsWith('Partial review:')) { + return { + tone: 'warning', + icon: TriangleAlert, + title: 'Partial review', + hint: 'Codra posted a review, but not every file made it in.', + detail: message.replace(/^Partial review:\s*/, ''), + }; + } + + if (job.status === 'superseded') { + return { + tone: 'neutral', + icon: History, + title: 'Superseded', + hint: 'A newer commit or review took over this pull request before this run finished, so it was retired. The latest review for this PR has the current results.', + }; + } + + if (job.status === 'cancelled' || job.status === 'stopped') { + return { + tone: 'neutral', + icon: CircleSlash, + title: job.status === 'stopped' ? 'Review stopped' : 'Review cancelled', + hint: 'This run ended before it finished, so any files below are only the ones reviewed up to that point. Re-run it from the header to start over.', + detail: message, + }; + } + + if (job.status === 'failed') { + return { + tone: 'danger', + icon: OctagonAlert, + title: 'Review failed', + hint: 'Codra could not finish this review. Retry it from the header once the cause below is addressed.', + detail: message, + }; + } + + // Any other status that still carries a message (e.g. a recovered run) shouldn't swallow it. + if (message) { + return { + tone: 'danger', + icon: OctagonAlert, + title: 'Something went wrong', + hint: 'The run reported a problem:', + detail: message, + }; + } + + return null; +} + +/** + * Page-level banner for a run's terminal outcome, rather than a cramped box inside the Job + * details rows: failures read as failures, and superseded/stopped read as neutral facts. + */ +export function JobStatusNotice({ job }: JobStatusNoticeProps) { + const notice = describe(job); + if (!notice) return null; + + const { tone, icon: Icon, title, hint, detail } = notice; + const styles = TONE[tone]; + + return ( +
+
+ + + + +
+

{title}

+

{hint}

+ + {detail && ( +

+ {detail} +

+ )} +
+
+
+ ); +} diff --git a/src/client/components/features/stats/chart-primitives.tsx b/src/client/components/features/stats/chart-primitives.tsx index 718d50c8..a8524d1c 100644 --- a/src/client/components/features/stats/chart-primitives.tsx +++ b/src/client/components/features/stats/chart-primitives.tsx @@ -1,74 +1,88 @@ -import { Skeleton, GraphShell } from '@codraoss/ui'; -import type { ReactNode } from 'react'; -import { Activity, Boxes, Coins, FolderGit2, ShieldCheck } from 'lucide-react'; -import { formatCompact, formatDayRange } from './chart-support'; - -export function ChartTooltip({ active, payload, label }: any) { - if (!active || !payload?.length) return null; - - const endDay: string | undefined = payload[0]?.payload?.endDay; - const heading = - typeof label === 'string' && label.includes('-') ? formatDayRange(label, endDay) : label; - - return ( -
- {label &&

{heading}

} -
- {payload.map((item: any) => ( -
- - {item.name} - - {typeof item.value === 'number' ? formatCompact(item.value) : item.value} - -
- ))} -
-
- ); -} - -function GraphCardSkeleton({ title, icon, className = '' }: { title: string; icon?: ReactNode; className?: string }) { - return ( - -
- -
-
- ); -} - -function GraphBarCardSkeleton({ title, icon, rows = 5, className = '' }: { title: string; icon?: ReactNode; rows?: number; className?: string }) { - return ( - -
- {Array.from({ length: rows }).map((_, i) => ( -
- - - -
- ))} -
-
- ); -} - -export function MetricsGridSkeleton() { - return ( -
-
- } /> - } /> -
-
- } /> - } rows={4} /> - } rows={5} /> -
-
- ); -} +import { Skeleton, GraphShell, SeriesMarker, type SeriesMarkerProps } from '@codraoss/ui'; +import type { ReactNode } from 'react'; +import { Activity, Boxes, Coins, FolderGit2, ShieldCheck } from 'lucide-react'; +import { formatCompact, formatDayRange } from './chart-support'; + +/** Per-`dataKey` marker description, so the tooltip can draw exactly what the legend drew. */ +export type SeriesMarkers = Record; + +/** + * Recharts reports a series' raw `fill`, so gradient- and pattern-backed bars arrive as `url(#id)`, + * which is not a CSS colour - assigning it to `background-color` renders nothing at all. The + * caller's `markers` map is the source of truth; this only covers series it doesn't describe. + */ +function fallbackMarker(color: string | undefined): SeriesMarkerProps { + if (!color || color.startsWith('url(')) return { color: 'currentColor' }; + return { color }; +} + +export function ChartTooltip({ active, payload, label, markers }: any) { + if (!active || !payload?.length) return null; + + const endDay: string | undefined = payload[0]?.payload?.endDay; + const heading = + typeof label === 'string' && label.includes('-') ? formatDayRange(label, endDay) : label; + + return ( +
+ {label &&

{heading}

} +
+ {payload.map((item: any) => ( +
+ + {item.name} + + {typeof item.value === 'number' ? formatCompact(item.value) : item.value} + +
+ ))} +
+
+ ); +} + +function GraphCardSkeleton({ title, icon, className = '' }: { title: string; icon?: ReactNode; className?: string }) { + return ( + +
+ +
+
+ ); +} + +function GraphBarCardSkeleton({ title, icon, rows = 5, className = '' }: { title: string; icon?: ReactNode; rows?: number; className?: string }) { + return ( + +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ ); +} + +/** Rows only - `MetricsGrid` owns the outer wrapper so the skeleton/chart handoff isn't animated. */ +export function MetricsGridSkeleton() { + return ( + <> +
+ } /> + } /> +
+
+ } /> + } rows={4} /> + } rows={5} /> +
+ + ); +} diff --git a/src/client/components/features/stats/metrics-grid-charts.tsx b/src/client/components/features/stats/metrics-grid-charts.tsx index 1fbea113..ceadc9f0 100644 --- a/src/client/components/features/stats/metrics-grid-charts.tsx +++ b/src/client/components/features/stats/metrics-grid-charts.tsx @@ -1,247 +1,260 @@ -import { - Area, - AreaChart, - Bar, - BarChart, - CartesianGrid, - Cell, - Pie, - PieChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; -import { Activity, Boxes, Coins, FolderGit2, ShieldCheck } from 'lucide-react'; -import type { StatsPayload } from '@codraoss/schema'; -import { - ChartTooltip -} from './chart-primitives'; -import { - GraphShell, - LegendChip, - ChartDefs, - MeterList, - TickMeter -} from '@codraoss/ui'; -import { - CHART, - MONO_STACK, - TICK_COLORS_DARK, - TICK_COLORS_LIGHT, - formatCompact, - formatDay, - modelName, -} from './chart-support'; - -// `equidistantPreserveStart` drops labels on a fixed stride (every 2nd, every 3rd, ...) sized to the -// available width, so the dates stay evenly spaced instead of jumping by uneven gaps. -const X_AXIS_PROPS = { - dataKey: 'day', - tickFormatter: formatDay, - interval: 'equidistantPreserveStart' as const, - minTickGap: 12, -}; - -export function MetricsGridCharts({ - stats, - isDark, -}: { - stats: StatsPayload; - isDark: boolean; -}) { - const lime = isDark ? CHART.primaryDark : CHART.primary; - const amber = isDark ? CHART.amberDark : CHART.amber; - const dangerColor = isDark ? CHART.dangerDark : CHART.danger; - const infoColor = isDark ? CHART.infoDark : CHART.info; - const quietColor = isDark ? CHART.quietDark : CHART.quiet; - const dashColor = isDark ? 'rgba(228,228,231,0.75)' : 'rgba(63,63,70,0.65)'; - const tickColors = isDark ? TICK_COLORS_DARK : TICK_COLORS_LIGHT; - // Long ranges arrive pre-combined into multi-day buckets; say so, since each point is a sum, not a day. - const bucketDays = stats.trendBucketDays ?? 1; - const bucketNote = bucketDays > 1 ? {bucketDays}-day totals : null; - const repoMax = Math.max(...stats.topRepos.map((repo) => repo.jobs), 1); - const modelMax = Math.max(...stats.models.map((model) => model.calls), 1); - - // CSS variables don't reliably resolve inside Recharts SVG text, so colors are keyed off the active theme explicitly. - const axisColor = isDark ? 'rgba(228,228,231,0.55)' : 'rgba(63,63,70,0.7)'; - const gridColor = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'; - const cursorColor = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'; - const axisProps = { - fontSize: 10, - tickLine: false, - tickMargin: 8, - axisLine: false, - tick: { fontFamily: MONO_STACK, fill: axisColor }, - } as const; - - const STATUS_COLOR: Record = { - done: lime, - running: infoColor, - queued: quietColor, - failed: dangerColor, - superseded: quietColor, - cancelled: quietColor, - }; - const statusTotal = Math.max(stats.statuses.reduce((sum, s) => sum + s.count, 0), 1); - - return ( -
-
- } - legend={ - <> - - - {bucketNote} - - } - > -
- - - - - - - } cursor={{ stroke: amber, strokeDasharray: '4 4' }} /> - - - - -
-
- - } - legend={ - <> - - - {bucketNote} - - } - > -
- - - - - - - } cursor={{ fill: cursorColor }} /> - {/* Capped so a short range (or a heavily bucketed one) doesn't render a handful of slab-wide bars. */} - - - - -
-
-
- -
- }> -
-
- - - - {stats.statuses.map((s) => ( - - ))} - - - -
- - {formatCompact(statusTotal)} - - Jobs -
-
- -
- {stats.statuses.map((s) => ( -
- - - {s.status} - - - {s.count} - ({Math.round((s.count / statusTotal) * 100)}%) - -
- ))} -
-
-
- - }> - - {stats.topRepos.map((repo, i) => ( - - ))} - - - - }> - - {stats.models.map((model, i) => ( - - ))} - - -
-
- ); -} +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Cell, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { Activity, Boxes, Coins, FolderGit2, ShieldCheck } from 'lucide-react'; +import type { StatsPayload } from '@codraoss/schema'; +import { + ChartTooltip, + type SeriesMarkers +} from './chart-primitives'; +import { + GraphShell, + LegendChip, + ChartDefs, + MeterList, + TickMeter +} from '@codraoss/ui'; +import { + CHART, + MONO_STACK, + TICK_COLORS_DARK, + TICK_COLORS_LIGHT, + formatCompact, + formatDay, + modelName, +} from './chart-support'; + +// `equidistantPreserveStart` drops labels on a fixed stride (every 2nd, every 3rd, ...) sized to the +// available width, so the dates stay evenly spaced instead of jumping by uneven gaps. +const X_AXIS_PROPS = { + dataKey: 'day', + tickFormatter: formatDay, + interval: 'equidistantPreserveStart' as const, + minTickGap: 12, +}; + +export function MetricsGridCharts({ + stats, + isDark, +}: { + stats: StatsPayload; + isDark: boolean; +}) { + const lime = isDark ? CHART.primaryDark : CHART.primary; + const amber = isDark ? CHART.amberDark : CHART.amber; + const dangerColor = isDark ? CHART.dangerDark : CHART.danger; + const infoColor = isDark ? CHART.infoDark : CHART.info; + const quietColor = isDark ? CHART.quietDark : CHART.quiet; + const dashColor = isDark ? 'rgba(228,228,231,0.75)' : 'rgba(63,63,70,0.65)'; + const tickColors = isDark ? TICK_COLORS_DARK : TICK_COLORS_LIGHT; + // Long ranges arrive pre-combined into multi-day buckets; say so, since each point is a sum, not a day. + const bucketDays = stats.trendBucketDays ?? 1; + const bucketNote = bucketDays > 1 ? {bucketDays}-day totals : null; + const repoMax = Math.max(...stats.topRepos.map((repo) => repo.jobs), 1); + const modelMax = Math.max(...stats.models.map((model) => model.calls), 1); + + // CSS variables don't reliably resolve inside Recharts SVG text, so colors are keyed off the active theme explicitly. + const axisColor = isDark ? 'rgba(228,228,231,0.55)' : 'rgba(63,63,70,0.7)'; + const gridColor = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'; + const cursorColor = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'; + const axisProps = { + fontSize: 10, + tickLine: false, + tickMargin: 8, + axisLine: false, + tick: { fontFamily: MONO_STACK, fill: axisColor }, + } as const; + + const STATUS_COLOR: Record = { + done: lime, + running: infoColor, + queued: quietColor, + failed: dangerColor, + superseded: quietColor, + cancelled: quietColor, + }; + const statusTotal = Math.max(stats.statuses.reduce((sum, s) => sum + s.count, 0), 1); + + // One description per series, feeding both the legend chip and the tooltip swatch, so the two + // can't drift apart. Keyed by `dataKey`, which is what Recharts reports back on hover. + const flowMarkers: SeriesMarkers = { + jobs: { color: amber }, + comments: { color: dashColor, dashed: true }, + }; + const tokenMarkers: SeriesMarkers = { + outputTokens: { color: CHART.blue }, + inputTokens: { hatched: true }, + }; + + return ( + // Rows only: `MetricsGrid` owns the outer wrapper. See the note there. + <> +
+ } + legend={ + <> + + + {bucketNote} + + } + > +
+ + + + + + + } cursor={{ stroke: amber, strokeDasharray: '4 4' }} /> + + + + +
+
+ + } + legend={ + <> + + + {bucketNote} + + } + > +
+ + + + + + + } cursor={{ fill: cursorColor }} /> + {/* Capped so a short range (or a heavily bucketed one) doesn't render a handful of slab-wide bars. */} + + + + +
+
+
+ +
+ }> +
+
+ + + + {stats.statuses.map((s) => ( + + ))} + + + +
+ + {formatCompact(statusTotal)} + + Jobs +
+
+ +
+ {stats.statuses.map((s) => ( +
+ + + {s.status} + + + {s.count} + ({Math.round((s.count / statusTotal) * 100)}%) + +
+ ))} +
+
+
+ + }> + + {stats.topRepos.map((repo, i) => ( + + ))} + + + + }> + + {stats.models.map((model, i) => ( + + ))} + + +
+ + ); +} diff --git a/src/client/components/features/stats/metrics-grid-prefetch.ts b/src/client/components/features/stats/metrics-grid-prefetch.ts index b0281a3f..c05a542f 100644 --- a/src/client/components/features/stats/metrics-grid-prefetch.ts +++ b/src/client/components/features/stats/metrics-grid-prefetch.ts @@ -1,7 +1,32 @@ -// The chart chunk is only *rendered* once stats have loaded, so React.lazy alone would delay its -// download until after the fetch resolved -- a waterfall the eager import it replaced never had. -// Calling this on mount puts the ~68 kB gzip request alongside the stats fetch instead of behind it. +// The chart chunk is only *rendered* once stats have loaded, so importing it lazily at render time +// would delay its download until after the fetch resolved -- a waterfall the eager import it +// replaced never had. Calling the prefetch on mount puts the ~68 kB gzip request alongside the +// stats fetch instead of behind it. // Separate from metrics-grid.tsx so that file keeps exporting components only (Fast Refresh). +import type { MetricsGridCharts } from './metrics-grid-charts'; + +type ChartsComponent = typeof MetricsGridCharts; + +let pending: Promise | null = null; +let resolved: ChartsComponent | null = null; + +/** Memoized so the prefetch and the render path share one request and one module instance. */ +export function loadMetricsCharts(): Promise { + pending ??= import('./metrics-grid-charts').then((m) => { + resolved = m.MetricsGridCharts; + return resolved; + }); + return pending; +} + +/** + * The already-loaded component, or null. Lets the grid render charts on the very first commit of a + * later visit, with no fallback frame in between. + */ +export function metricsChartsIfLoaded(): ChartsComponent | null { + return resolved; +} + export function prefetchMetricsCharts() { - void import('./metrics-grid-charts'); + void loadMetricsCharts(); } diff --git a/src/client/components/features/stats/metrics-grid.tsx b/src/client/components/features/stats/metrics-grid.tsx index 36b8548b..e349a498 100644 --- a/src/client/components/features/stats/metrics-grid.tsx +++ b/src/client/components/features/stats/metrics-grid.tsx @@ -1,23 +1,48 @@ -import React, { Suspense } from 'react'; -import type { StatsPayload } from '@codraoss/schema'; -import { MetricsGridSkeleton } from './chart-primitives'; - -// Recharts is only needed once stats have loaded, so it stays out of the initial bundle and the -// same skeleton covers both the fetch and the chunk download. -// Kept warm by `prefetchMetricsCharts` in ./metrics-grid-prefetch: the charts only render once -// `stats` arrives, so `lazy` on its own would not start the download until after the fetch resolved. -const MetricsGridCharts = React.lazy(() => import('./metrics-grid-charts').then(m => ({ default: m.MetricsGridCharts }))); - -export function MetricsGrid({ - stats, - isDark, -}: { - stats: StatsPayload; - isDark: boolean; -}) { - return ( - }> - - - ); -} +import { useEffect, useState } from 'react'; +import type { StatsPayload } from '@codraoss/schema'; +import { MetricsGridSkeleton } from './chart-primitives'; +import { loadMetricsCharts, metricsChartsIfLoaded } from './metrics-grid-prefetch'; + +/** + * Owns the whole loading state - the chart chunk *and* the data - so the skeleton is one element in + * one tree position for the entire wait. + * + * This deliberately avoids `lazy` + `Suspense`: with a fallback, the skeleton renders from a second + * position, so the handoff between "no data yet" and "chunk still downloading" unmounts one + * skeleton and mounts another. Identical markup, but React sees a new element - restarting the + * shimmer and replaying the parent's `page-enter` fade-up, which reads as the cards refreshing + * twice before any content arrives. + */ +export function MetricsGrid({ + stats, + isDark, +}: { + stats: StatsPayload | null; + isDark: boolean; +}) { + const [Charts, setCharts] = useState>( + metricsChartsIfLoaded, + ); + + useEffect(() => { + if (Charts) return; + let active = true; + // Component values are functions, so the updater has to return one rather than be one. + void loadMetricsCharts().then((loaded) => { + if (active) setCharts(() => loaded); + }); + return () => { + active = false; + }; + }, [Charts]); + + // The wrapper is what `page-enter` animates (it's the section's direct child), so it stays + // mounted across the handoff: the skeleton fades up once, then the real cards simply replace it + // in place. Returning the skeleton and the charts as siblings-of-different-shape would mount a + // new direct child and replay the fade-up, which read as the page animating twice. + return ( +
+ {!Charts || !stats ? : } +
+ ); +} diff --git a/src/client/components/features/stats/stats-grid.tsx b/src/client/components/features/stats/stats-grid.tsx index 8b8db681..55c6a16c 100644 --- a/src/client/components/features/stats/stats-grid.tsx +++ b/src/client/components/features/stats/stats-grid.tsx @@ -1,133 +1,135 @@ -import { BarSparkline, Skeleton } from '@codraoss/ui'; -import * as React from 'react'; -import { cn } from '@codraoss/ui/utils'; -import type { LucideIcon } from 'lucide-react'; - -export interface StatDelta { - /** Signed percentage change vs. the previous period. */ - pct: number; - direction: 'up' | 'down' | 'flat'; -} - -export interface StatsItem { - label: string; - /** Numeric part of the value (already formatted); null while loading. */ - value: string | null; - /** Unit suffix rendered smaller next to the value (e.g. "k", "M"). */ - unit?: string; - icon: LucideIcon; - /** Accent color (hex) for the sparkline bars. */ - color: string; - /** Short noun for the footer, e.g. "Reviews Increased by …". */ - noun?: string; - trend?: number[]; - delta?: StatDelta | null; -} - -interface StatsGridProps extends React.HTMLAttributes { - items: StatsItem[]; -} - -/** - * KPI cards: ui-* surface/text tokens, system font stack, mono numerals, and a - * nested value panel with a bar sparkline. - */ -export function StatsGrid({ items, className, ...props }: StatsGridProps) { - return ( -
- {items.map((item) => ( - - ))} -
- ); -} - -function StatCard({ label, value, unit, icon: Icon, color, noun, trend, delta }: StatsItem) { - const loading = value === null; - - return ( -
-
- - {label} -
- -
- {loading ? ( - - ) : ( -

- - {value} - - {unit && {unit}} -

- )} - - {loading ? ( - - ) : ( - trend && - )} -
- - {/* Footer: "Reviews Increased by" ..... ▲ +15% vs prev. period */} - -
- ); -} - -function StatFooter({ - delta, - loading, - noun, -}: { - delta?: StatDelta | null; - loading: boolean; - noun?: string; -}) { - if (loading) { - return ( -
- - -
- ); - } - - const flat = !delta || delta.direction === 'flat'; - const up = delta?.direction === 'up'; - const toneClass = flat - ? 'text-ui-subtle' - : up - ? 'text-emerald-600 dark:text-emerald-400' - : 'text-red-600 dark:text-red-400'; - const prefix = noun ?? 'Value'; - const label = flat ? `${prefix} unchanged` : `${prefix} ${up ? 'Increased' : 'Decreased'} by`; - - return ( -
- {label} - - {!flat && ( - <> - - {up ? '▲' : '▼'} - - - {up ? '+' : '-'} - {Math.abs(delta!.pct)}% - - - )} - vs prev. period - -
- ); -} +import { BarSparkline, Skeleton } from '@codraoss/ui'; +import * as React from 'react'; +import { cn } from '@codraoss/ui/utils'; +import type { LucideIcon } from 'lucide-react'; + +export interface StatDelta { + /** Signed percentage change vs. the previous period. */ + pct: number; + direction: 'up' | 'down' | 'flat'; +} + +export interface StatsItem { + label: string; + /** Numeric part of the value (already formatted); null while loading. */ + value: string | null; + /** Unit suffix rendered smaller next to the value (e.g. "k", "M"). */ + unit?: string; + icon: LucideIcon; + /** Accent color (hex) for the sparkline bars. */ + color: string; + /** Short noun for the footer, e.g. "Reviews Increased by …". */ + noun?: string; + trend?: number[]; + delta?: StatDelta | null; +} + +interface StatsGridProps extends React.HTMLAttributes { + items: StatsItem[]; +} + +/** + * KPI cards: ui-* surface/text tokens, system font stack, mono numerals, and a + * nested value panel with a bar sparkline. + */ +export function StatsGrid({ items, className, ...props }: StatsGridProps) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +function StatCard({ label, value, unit, icon: Icon, color, noun, trend, delta }: StatsItem) { + const loading = value === null; + + return ( +
+
+ + {label} +
+ +
+ {loading ? ( + + ) : ( +

+ + {value} + + {unit && {unit}} +

+ )} + + {loading ? ( + + ) : ( + trend && + )} +
+ + {/* Footer: "Reviews Increased by" ..... ▲ +15% vs prev. period */} + +
+ ); +} + +function StatFooter({ + delta, + loading, + noun, +}: { + delta?: StatDelta | null; + loading: boolean; + noun?: string; +}) { + if (loading) { + return ( + // h-7 == pt-3 + the loaded row's 16px text-xs line box. Without it the card is 4px shorter + // while loading, shifting everything below it (and the dashboard's row-fitting measurement). +
+ + +
+ ); + } + + const flat = !delta || delta.direction === 'flat'; + const up = delta?.direction === 'up'; + const toneClass = flat + ? 'text-ui-subtle' + : up + ? 'text-emerald-600 dark:text-emerald-400' + : 'text-red-600 dark:text-red-400'; + const prefix = noun ?? 'Value'; + const label = flat ? `${prefix} unchanged` : `${prefix} ${up ? 'Increased' : 'Decreased'} by`; + + return ( +
+ {label} + + {!flat && ( + <> + + {up ? '▲' : '▼'} + + + {up ? '+' : '-'} + {Math.abs(delta!.pct)}% + + + )} + vs prev. period + +
+ ); +} diff --git a/src/client/components/layout/account-menu.tsx b/src/client/components/layout/account-menu.tsx index 4c939422..3fd9b5fc 100644 --- a/src/client/components/layout/account-menu.tsx +++ b/src/client/components/layout/account-menu.tsx @@ -1,145 +1,214 @@ -import { GithubMark } from '@codraoss/ui'; -import { Link } from 'react-router-dom'; -import { useEffect, useRef, useState } from 'react'; -import { api } from '@client/lib/api'; -import { LogOut, ChevronsUpDown, UserRound } from 'lucide-react'; -import { cn } from '@codraoss/ui/utils'; -import type { AuthSessionUser } from '@codraoss/schema/api'; - -/** - * Built from scratch (no shared dropdown primitive): a local popover anchored - * to the account row via `absolute bottom-full`, so it opens directly above - * the row and moves with the sidebar. - */ -export function AccountMenu({ user }: { user: AuthSessionUser }) { - const [open, setOpen] = useState(false); - const rootRef = useRef(null); - const triggerRef = useRef(null); - - const name = user.name?.trim() || user.login; - const initial = name.charAt(0).toUpperCase(); - - useEffect(() => { - if (!open) return; - const onPointer = (e: PointerEvent) => { - if (!rootRef.current?.contains(e.target as Node)) setOpen(false); - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - setOpen(false); - triggerRef.current?.focus(); - } - }; - window.addEventListener('pointerdown', onPointer); - window.addEventListener('keydown', onKey); - return () => { - window.removeEventListener('pointerdown', onPointer); - window.removeEventListener('keydown', onKey); - }; - }, [open]); - - return ( -
- - {/* Identity lives in the trigger below, so the panel is purely actions; it stays mounted and animates via CSS, and is `invisible` + `pointer-events-none` when closed so it can't sit on top of rows behind it and swallow clicks. */} -
- setOpen(false)} - > - - Account - - - setOpen(false)} - > - - GitHub profile - - -
- - -
- - -
- ); -} +import { GithubMark } from '@codraoss/ui'; +import { Link } from 'react-router-dom'; +import { useEffect, useRef, useState } from 'react'; +import { api } from '@client/lib/api'; +import { ArrowUpRight, LogOut, ChevronsUpDown, UserRound } from 'lucide-react'; +import { cn } from '@codraoss/ui/utils'; +import type { AuthSessionUser } from '@codraoss/schema/api'; + +/** Shared by the pill trigger and the menu's identity header. */ +function Avatar({ + user, + initial, + size, +}: { + user: AuthSessionUser; + initial: string; + size: number; +}) { + const box = { width: size, height: size }; + + if (user.avatarUrl) { + return ( + + ); + } + + return ( + + {initial} + + ); +} + +/** One row in the menu: icon, label, and an optional trailing affordance. */ +const ITEM = cn( + 'group/item flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-[13px] font-medium', + 'text-ui-default outline-none transition-colors duration-150', +); + +const ITEM_ICON = 'shrink-0 text-ui-subtle transition-colors group-hover/item:text-ui-default'; + +/** + * Built from scratch (no shared dropdown primitive): a local popover anchored + * to the account row via `absolute bottom-full`, so it opens directly above + * the row and moves with the sidebar. + */ +export function AccountMenu({ user }: { user: AuthSessionUser }) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + + const name = user.name?.trim() || user.login; + const initial = name.charAt(0).toUpperCase(); + + useEffect(() => { + if (!open) return; + const onPointer = (e: PointerEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setOpen(false); + triggerRef.current?.focus(); + } + }; + window.addEventListener('pointerdown', onPointer); + window.addEventListener('keydown', onKey); + return () => { + window.removeEventListener('pointerdown', onPointer); + window.removeEventListener('keydown', onKey); + }; + }, [open]); + + return ( +
+ + {/* Repeats the identity as the panel's header so the menu has a subject of its own. Stays + mounted and animates via CSS, and is `invisible` + `pointer-events-none` when closed so it + can't sit on top of rows behind it and swallow clicks. */} +
+
+ + + + {name} + + + @{user.login} + + +
+ +
+ + setOpen(false)} + > + + Account + + + setOpen(false)} + > + + GitHub profile + {/* Marks the one item that leaves the app. */} + + + +
+ + +
+ + {/* Avatar, name over handle, and the double chevron. Geometry (full width, radius, spacing) + matches the sidebar rows above it. */} + +
+ ); +} diff --git a/src/client/components/layout/app-shell.tsx b/src/client/components/layout/app-shell.tsx index 73b52e32..e451056c 100644 --- a/src/client/components/layout/app-shell.tsx +++ b/src/client/components/layout/app-shell.tsx @@ -1,212 +1,215 @@ -import { Outlet, Link } from 'react-router-dom'; -import { useEffect, useState } from 'react'; -import { SharedLayoutBg } from '@codraoss/ui/motion'; -import { api } from '@client/lib/api'; -import { LayoutDashboard, AlignLeft, GitBranch, BarChart2, Sun, Moon, Activity, Settings, Star, X, ArrowUpRight } from 'lucide-react'; -import { cn } from '@codraoss/ui/utils'; -import { useTheme } from '@codraoss/ui/theme'; -import codraDark from '@/assets/codra-fullicon-dark.svg'; -import codraLight from '@/assets/codra-fullicon-light.svg'; -import type { AuthSessionUser } from '@codraoss/schema/api'; - -import { SidebarNavItem } from '@client/components/layout/sidebar-nav-item'; -import { AccountMenu } from '@client/components/layout/account-menu'; -const links = [ - { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, end: true }, - { to: '/jobs', label: 'Jobs', icon: Activity, end: false }, - { to: '/repos', label: 'Repos', icon: GitBranch, end: false }, - { to: '/stats', label: 'Stats', icon: BarChart2, end: false }, - { to: '/settings', label: 'Settings', icon: Settings, end: false }, -]; - - -export function AppShell() { - const { theme, toggleTheme } = useTheme(); - const [sessionUser, setSessionUser] = useState(null); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - - useEffect(() => { - let cancelled = false; - api.getSession() - .then(r => { if (!cancelled) setSessionUser(r.user); }) - .catch(() => { if (!cancelled) setSessionUser(null); }); - return () => { cancelled = true; }; - }, []); - - // Scroll doesn't bubble: listen in capture phase, flag scrolled el with data-scrolling for CSS, clear after 700ms idle. - useEffect(() => { - const timers = new WeakMap(); - const onScroll = (e: Event) => { - let el = e.target as Element | Document | null; - if (el === document) el = document.scrollingElement; - if (!(el instanceof Element)) return; - const node = el; - node.setAttribute('data-scrolling', 'true'); - const prev = timers.get(node); - if (prev !== undefined) window.clearTimeout(prev); - timers.set(node, window.setTimeout(() => node.removeAttribute('data-scrolling'), 700)); - }; - document.addEventListener('scroll', onScroll, true); - return () => document.removeEventListener('scroll', onScroll, true); - }, []); - - return ( -
- - {mobileMenuOpen && ( - /* Hidden from a11y tree: drawer's X is the real focusable close; scrim as a tab stop would double-announce. */ - - -
-
- - - -
- -
- - - - Star on GitHub - - - - {sessionUser && } -
- - - {/* Shell never scrolls; card fills viewport, pages scroll their own body inside it. */} -
- -
- - -
- - {/* Full-width so scrollbar sits at card's inner edge; short pages scroll here, always inside the card, never the window. */} -
-
- -
-
-
-
- ); -} +import { Outlet, Link } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { SharedLayoutBg } from '@codraoss/ui/motion'; +import { api } from '@client/lib/api'; +import { LayoutDashboard, AlignLeft, GitBranch, BarChart2, Sun, Moon, Activity, Settings, Star, X, ArrowUpRight } from 'lucide-react'; +import { cn } from '@codraoss/ui/utils'; +import { useTheme } from '@codraoss/ui/theme'; +import codraDark from '@/assets/codra-fullicon-dark.svg'; +import codraLight from '@/assets/codra-fullicon-light.svg'; +import type { AuthSessionUser } from '@codraoss/schema/api'; + +import { SidebarNavItem } from '@client/components/layout/sidebar-nav-item'; +import { AccountMenu } from '@client/components/layout/account-menu'; +const links = [ + { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, end: true }, + { to: '/jobs', label: 'Jobs', icon: Activity, end: false }, + { to: '/repos', label: 'Repos', icon: GitBranch, end: false }, + { to: '/stats', label: 'Stats', icon: BarChart2, end: false }, + { to: '/settings', label: 'Settings', icon: Settings, end: false }, +]; + + +export function AppShell() { + const { theme, toggleTheme } = useTheme(); + const [sessionUser, setSessionUser] = useState(null); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + useEffect(() => { + let cancelled = false; + api.getSession() + .then(r => { if (!cancelled) setSessionUser(r.user); }) + .catch(() => { if (!cancelled) setSessionUser(null); }); + return () => { cancelled = true; }; + }, []); + + // Scroll doesn't bubble: listen in capture phase, flag scrolled el with data-scrolling for CSS, clear after 700ms idle. + useEffect(() => { + const timers = new WeakMap(); + const onScroll = (e: Event) => { + let el = e.target as Element | Document | null; + if (el === document) el = document.scrollingElement; + if (!(el instanceof Element)) return; + const node = el; + node.setAttribute('data-scrolling', 'true'); + const prev = timers.get(node); + if (prev !== undefined) window.clearTimeout(prev); + timers.set(node, window.setTimeout(() => node.removeAttribute('data-scrolling'), 700)); + }; + document.addEventListener('scroll', onScroll, true); + return () => document.removeEventListener('scroll', onScroll, true); + }, []); + + return ( +
+ + {mobileMenuOpen && ( + /* Hidden from a11y tree: drawer's X is the real focusable close; scrim as a tab stop would double-announce. */ + + +
+
+ + + +
+ +
+ + + + Star on GitHub + + + + {sessionUser && } +
+ + + {/* Shell never scrolls; card fills viewport, pages scroll their own body inside it. */} +
+ +
+ + +
+ + {/* Full-width so scrollbar sits at card's inner edge; short pages scroll here, always inside + the card, never the window. `scrollbar-gutter: stable` keeps the gutter reserved whether + or not the bar is showing: otherwise gaining a scrollbar narrows the content, which can + rewrap text and shift every measurement taken against this box. */} +
+
+ +
+
+
+
+ ); +} diff --git a/src/client/components/shared/jobs-table.tsx b/src/client/components/shared/jobs-table.tsx index d1a92e5e..00cd5902 100644 --- a/src/client/components/shared/jobs-table.tsx +++ b/src/client/components/shared/jobs-table.tsx @@ -1,313 +1,315 @@ import { Skeleton } from '@codraoss/ui'; -import { Link } from 'react-router-dom'; -import { FolderGit2, GitCommitHorizontal, GitPullRequest } from 'lucide-react'; - -import { VerdictPill, MetaChip, AuthorAvatar } from '@client/components/features/job-detail/job-chips'; -import { cn } from '@codraoss/ui/utils'; -import { formatDateTime } from '@client/lib/timezone'; -import { STATUS_DOT, formatRelativeDate, jobDuration, statusLabel } from '@client/lib/job-format'; - -import type { JobSummary } from '@codraoss/schema'; - -type Column = - | 'title' - | 'status' - | 'verdict' - | 'repo' - | 'commit' - | 'pr' - | 'updated' - | 'author'; - -interface JobsTableProps { - jobs: JobSummary[]; - loading: boolean; - /** Columns to show. Defaults to all. */ - columns?: Column[]; - /** Fill the parent's height and scroll the body internally, instead of growing to fit all rows. */ - fill?: boolean; -} - -const DEFAULT_COLUMNS: Column[] = [ - 'title', - 'status', - 'verdict', - 'repo', - 'commit', - 'pr', - 'updated', - 'author', -]; - -/* Title takes all the slack; secondary metadata drops off first on narrow viewports so a row - never wraps and the title never collapses to nothing. */ -const COLUMN_CLASSES: Record = { - title: 'min-w-0 pl-4', - status: 'w-[156px]', - verdict: 'hidden xl:table-cell w-[108px]', - repo: 'hidden md:table-cell w-[176px]', - commit: 'hidden 2xl:table-cell w-[96px]', - pr: 'hidden xl:table-cell w-[76px]', - updated: 'w-[84px]', - author: 'w-12 pr-4', -}; - -function formatDate(value: JobSummary['createdAt']) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return ''; - // Rendered in the account's display time zone (falls back to the browser's). - return formatDateTime(date, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); -} - -function StatusCell({ job }: { job: JobSummary }) { - const duration = jobDuration(job); - const isRunning = job.status === 'running'; - - return ( - - - - {statusLabel(job.status)} - - {duration && ( - - {duration} - - )} - - ); -} - -function JobMobileCard({ job }: { job: JobSummary }) { - return ( - -
-

- {job.prTitle ?? 'Untitled PR'} -

- - {formatRelativeDate(job.createdAt)} - -
- -
- - {job.verdict && } -
- -
- - {job.owner}/{job.repo} - - #{job.prNumber} -
- - ); -} - -/* Fixed cell height, not vertical padding: padding-based rows grew ~6px on verdict-pill rows and - broke the vertical rhythm. */ -const CELL = 'h-12 border-t border-ui-line px-2.5 align-middle'; - -/* Top border goes transparent, not 0-width, so it can't double up with whatever sits above the - table without changing row height. */ -const ROW_DIVIDERS = 'first:[&>td]:border-transparent'; - -export function JobsTable({ jobs, loading, columns, fill = false }: JobsTableProps) { - const cols: Column[] = columns ?? DEFAULT_COLUMNS; - const show = (column: Column) => cols.includes(column); - - return ( -
-
- {loading && jobs.length === 0 - ? Array.from({ length: 6 }).map((_, i) => ( -
- -
- - -
-
- - -
-
- )) - : jobs.map((job) => )} -
- -
- - - {loading && jobs.length === 0 - ? Array.from({ length: 8 }).map((_, i) => ( - - {show('title') && ( - - )} - {show('status') && ( - - )} - {show('verdict') && ( - - )} - {show('repo') && ( - - )} - {show('commit') && ( - - )} - {show('pr') && ( - - )} - {show('updated') && ( - - )} - {show('author') && ( - - )} - - )) - : jobs.map((job) => ( - - {show('title') && ( - - )} - - {show('status') && ( - - )} - - {show('verdict') && ( - - )} - - {show('repo') && ( - - )} - - {show('commit') && ( - - )} - - {show('pr') && ( - - )} - - {show('updated') && ( - - )} - - {show('author') && ( - - )} - - ))} - -
- - - - - - - - - - - - - - - - - - - - - - - -
- {/* `after:` stretches this link across the row, making the whole row one click target. */} - - {job.prTitle ?? 'Untitled PR'} - - - - - {job.verdict && } - - - {job.owner}/{job.repo} - - - {job.commitSha ? ( - - {job.commitSha.slice(0, 7)} - - ) : ( - - - )} - - - #{job.prNumber} - - - - {formatRelativeDate(job.createdAt)} - - - - - -
-
-
- ); -} +import { Link } from 'react-router-dom'; +import { FolderGit2, GitCommitHorizontal, GitPullRequest } from 'lucide-react'; + +import { VerdictPill, MetaChip, AuthorAvatar } from '@client/components/features/job-detail/job-chips'; +import { cn } from '@codraoss/ui/utils'; +import { formatDateTime } from '@client/lib/timezone'; +import { STATUS_DOT, formatRelativeDate, jobDuration, statusLabel } from '@client/lib/job-format'; + +import type { JobSummary } from '@codraoss/schema'; + +type Column = + | 'title' + | 'status' + | 'verdict' + | 'repo' + | 'commit' + | 'pr' + | 'updated' + | 'author'; + +interface JobsTableProps { + jobs: JobSummary[]; + loading: boolean; + /** Columns to show. Defaults to all. */ + columns?: Column[]; + /** Fill the parent's height and scroll the body internally, instead of growing to fit all rows. */ + fill?: boolean; + /** Placeholder rows drawn while loading. Match the expected result count to avoid a layout jump. */ + skeletonRows?: number; +} + +const DEFAULT_COLUMNS: Column[] = [ + 'title', + 'status', + 'verdict', + 'repo', + 'commit', + 'pr', + 'updated', + 'author', +]; + +/* Title takes all the slack; secondary metadata drops off first on narrow viewports so a row + never wraps and the title never collapses to nothing. */ +const COLUMN_CLASSES: Record = { + title: 'min-w-0 pl-4', + status: 'w-[156px]', + verdict: 'hidden xl:table-cell w-[108px]', + repo: 'hidden md:table-cell w-[176px]', + commit: 'hidden 2xl:table-cell w-[96px]', + pr: 'hidden xl:table-cell w-[76px]', + updated: 'w-[84px]', + author: 'w-12 pr-4', +}; + +function formatDate(value: JobSummary['createdAt']) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + // Rendered in the account's display time zone (falls back to the browser's). + return formatDateTime(date, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function StatusCell({ job }: { job: JobSummary }) { + const duration = jobDuration(job); + const isRunning = job.status === 'running'; + + return ( + + + + {statusLabel(job.status)} + + {duration && ( + + {duration} + + )} + + ); +} + +function JobMobileCard({ job }: { job: JobSummary }) { + return ( + +
+

+ {job.prTitle ?? 'Untitled PR'} +

+ + {formatRelativeDate(job.createdAt)} + +
+ +
+ + {job.verdict && } +
+ +
+ + {job.owner}/{job.repo} + + #{job.prNumber} +
+ + ); +} + +/* Fixed cell height, not vertical padding: padding-based rows grew ~6px on verdict-pill rows and + broke the vertical rhythm. */ +const CELL = 'h-12 border-t border-ui-line px-2.5 align-middle'; + +/* Top border goes transparent, not 0-width, so it can't double up with whatever sits above the + table without changing row height. */ +const ROW_DIVIDERS = 'first:[&>td]:border-transparent'; + +export function JobsTable({ jobs, loading, columns, fill = false, skeletonRows }: JobsTableProps) { + const cols: Column[] = columns ?? DEFAULT_COLUMNS; + const show = (column: Column) => cols.includes(column); + + return ( +
+
+ {loading && jobs.length === 0 + ? Array.from({ length: skeletonRows ?? 6 }).map((_, i) => ( +
+ +
+ + +
+
+ + +
+
+ )) + : jobs.map((job) => )} +
+ +
+ + + {loading && jobs.length === 0 + ? Array.from({ length: skeletonRows ?? 8 }).map((_, i) => ( + + {show('title') && ( + + )} + {show('status') && ( + + )} + {show('verdict') && ( + + )} + {show('repo') && ( + + )} + {show('commit') && ( + + )} + {show('pr') && ( + + )} + {show('updated') && ( + + )} + {show('author') && ( + + )} + + )) + : jobs.map((job) => ( + + {show('title') && ( + + )} + + {show('status') && ( + + )} + + {show('verdict') && ( + + )} + + {show('repo') && ( + + )} + + {show('commit') && ( + + )} + + {show('pr') && ( + + )} + + {show('updated') && ( + + )} + + {show('author') && ( + + )} + + ))} + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ {/* `after:` stretches this link across the row, making the whole row one click target. */} + + {job.prTitle ?? 'Untitled PR'} + + + + + {job.verdict && } + + + {job.owner}/{job.repo} + + + {job.commitSha ? ( + + {job.commitSha.slice(0, 7)} + + ) : ( + - + )} + + + #{job.prNumber} + + + + {formatRelativeDate(job.createdAt)} + + + + + +
+
+
+ ); +} diff --git a/src/client/hooks/use-fit-rows.ts b/src/client/hooks/use-fit-rows.ts new file mode 100644 index 00000000..f63d84de --- /dev/null +++ b/src/client/hooks/use-fit-rows.ts @@ -0,0 +1,140 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; + +interface FitRowsOptions { + /** Height of one desktop table row, in px. Matches the `h-12` cell in JobsTable. */ + rowHeight?: number; + /** Height of one stacked mobile card, in px. */ + mobileRowHeight?: number; + /** Never ask for fewer than this many rows. */ + min?: number; + /** Never ask for more than this many rows (the API caps `limit` at 100). */ + max?: number; + /** + * Space left below the last row: the page wrapper's bottom padding (`py-8` = 32px) plus the + * panel border, so the table stops short of the scroll container instead of overflowing it. + */ + reserve?: number; +} + +/** Nearest scrollable ancestor, so the measurement is taken against the box the table lives in. */ +function scrollParent(el: HTMLElement): HTMLElement { + let node = el.parentElement; + while (node) { + const { overflowY } = getComputedStyle(node); + if (overflowY === 'auto' || overflowY === 'scroll') return node; + node = node.parentElement; + } + return document.documentElement; +} + +/** + * Every element laid out above `el` inside the scroller: its previous siblings, then its ancestors' + * previous siblings. Deliberately excludes `el`, its ancestors and its descendants - those contain + * the table, whose height is this hook's output, and observing them fed the row count back into + * itself. What is above `el` moves its top edge, so it genuinely needs a re-measure. + */ +function elementsAbove(el: HTMLElement, scroller: HTMLElement): Element[] { + const found: Element[] = []; + let node: HTMLElement | null = el; + + while (node && node !== scroller) { + for (let sib = node.previousElementSibling; sib; sib = sib.previousElementSibling) { + found.push(sib); + } + node = node.parentElement; + } + + return found; +} + +/** + * Fraction of a row a measurement has to clear before the count changes. A few px of layout jitter + * (a scrollbar appearing, a label rewrapping) would otherwise flip `rows` back and forth, and every + * flip refetches at a new `limit`. + */ +const DEADBAND = 0.35; + +/** + * Measures how many rows fit between the returned ref's top edge and the bottom of the scroll + * container, so a list can request exactly as many items as the viewport can show. + * + * `rows` is `null` until the first measurement lands - callers should hold off fetching until then + * so they don't fire one request at a guessed size and a second at the real one. + */ +export function useFitRows({ + rowHeight = 48, + mobileRowHeight = 101, + min = 3, + max = 30, + reserve = 36, +}: FitRowsOptions = {}) { + const ref = useRef(null); + const [rows, setRows] = useState(null); + + const measure = useCallback(() => { + const el = ref.current; + if (!el) return; + + const scroller = scrollParent(el); + // Offset from the scroll container's content top, not the viewport: stays put while the user + // scrolls, so growing the table can't feed back into the row count. + const top = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop; + const available = scroller.clientHeight - top - reserve; + + const wide = typeof window.matchMedia === 'function' + ? window.matchMedia('(min-width: 640px)').matches + : true; + const unit = wide ? rowHeight : mobileRowHeight; + const fits = Math.max(min, Math.min(max, Math.floor(available / unit))); + + setRows((current) => { + if (current === null || fits === current) return fits; + + // A one-row change has to be decisive; anything larger is a real resize, so take it as-is. + if (Math.abs(fits - current) === 1) { + const margin = unit * DEADBAND; + const growing = fits > current; + if (growing && available < (current + 1) * unit + margin) return current; + if (!growing && available > current * unit - margin) return current; + } + + return fits; + }); + }, [rowHeight, mobileRowHeight, min, max, reserve]); + + useLayoutEffect(() => { + measure(); + + const el = ref.current; + if (!el) return; + + // Guarded for jsdom, which has no ResizeObserver; window resize alone is enough there. + // + // Callbacks are coalesced into a frame so a burst of resize notifications measures once, after + // layout has settled. + let frame = 0; + const schedule = () => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + measure(); + }); + }; + + const scroller = scrollParent(el); + const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(schedule) : null; + observer?.observe(scroller); + // Content above shifts the table's top edge: the stat cards settling, or a banner that only + // appears once its own request resolves. + for (const node of elementsAbove(el, scroller)) observer?.observe(node); + + window.addEventListener('resize', schedule); + return () => { + if (frame) cancelAnimationFrame(frame); + observer?.disconnect(); + window.removeEventListener('resize', schedule); + }; + }, [measure]); + + return { ref, rows }; +} diff --git a/src/client/pages/dashboard.tsx b/src/client/pages/dashboard.tsx index bc1dae78..76de9dea 100644 --- a/src/client/pages/dashboard.tsx +++ b/src/client/pages/dashboard.tsx @@ -1,121 +1,131 @@ -import { Button, EmptyState, LoadError } from '@codraoss/ui'; -import { useState } from 'react'; -import { api } from '@client/lib/api'; -import type { StatsPayload, JobSummary } from '@codraoss/schema'; -import { ArrowRight, GitPullRequest, Activity } from 'lucide-react'; -import { JobsTable } from '@client/components/shared/jobs-table'; -import { PageHeaderActions } from '@client/components/shared/page-header-actions'; -import { Link } from 'react-router-dom'; - -import { PageHeader } from '@client/components/layout/page-header'; -import { OverviewStats } from '@client/components/features/stats/overview-stats'; -import { usePolling } from '@client/hooks/use-polling'; -import { useStatsRange } from '@client/hooks/use-stats-range'; - -export function DashboardPage() { - const [stats, setStats] = useState(null); - const [recentJobs, setRecentJobs] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); - - const [days, setDays] = useStatsRange(); - - // Clears stats to show skeletons while the new range loads; recent-jobs is range-independent and keeps its data. - const changeDays = (next: number) => { - setStats(null); - setDays(next); - }; - - const load = async (manual = false) => { - if (manual) setRefreshing(true); - try { - const [statsRes, jobsRes] = await Promise.all([ - api.getStats(days), - api.getJobs({ limit: 10 }), - ]); - setStats(statsRes.stats); - setRecentJobs(jobsRes.jobs); - setError(null); - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to refresh dashboard.'); - } finally { - setLoading(false); - setRefreshing(false); - } - }; - - usePolling(load, 15_000, [days]); - - - return ( -
- - load(true)} - refreshing={refreshing} - /> - } - /> - - {error && ( - load(true)} - retrying={refreshing} - /> - )} - - - -
-
-
- -

Recent reviews

-
- - - -
- -
- {(loading || recentJobs.length > 0) && ( - - )} - - {!loading && recentJobs.length === 0 && ( - } - title="No jobs yet" - description="Your pull request reviews will appear here" - hints={[ - 'Once you open a PR in any of the connected repos, analysis triggers automatically', - 'To trigger manually, comment @codra on any PR', - ]} - linkAction={{ - label: 'See how to interact with Codra', - href: 'https://github.com/devarshishimpi/codra#readme', - }} - className="rounded-none border-0" - /> - )} -
-
-
- ); -} - +import { Button, EmptyState, LoadError } from '@codraoss/ui'; +import { useState } from 'react'; +import { api } from '@client/lib/api'; +import type { StatsPayload, JobSummary } from '@codraoss/schema'; +import { ArrowRight, GitPullRequest, Activity } from 'lucide-react'; +import { JobsTable } from '@client/components/shared/jobs-table'; +import { PageHeaderActions } from '@client/components/shared/page-header-actions'; +import { Link } from 'react-router-dom'; + +import { PageHeader } from '@client/components/layout/page-header'; +import { OverviewStats } from '@client/components/features/stats/overview-stats'; +import { useFitRows } from '@client/hooks/use-fit-rows'; +import { usePolling } from '@client/hooks/use-polling'; +import { useStatsRange } from '@client/hooks/use-stats-range'; + +export function DashboardPage() { + const [stats, setStats] = useState(null); + const [recentJobs, setRecentJobs] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + const [days, setDays] = useStatsRange(); + + // Ask for exactly as many recent jobs as fit under the stats cards, so the panel fills the + // viewport without spilling into a page scroll. `null` until the first measurement lands. + const { ref: tableRef, rows } = useFitRows({ min: 4, max: 30 }); + + // Clears stats to show skeletons while the new range loads; recent-jobs is range-independent and keeps its data. + const changeDays = (next: number) => { + setStats(null); + setDays(next); + }; + + const load = async (manual = false) => { + if (rows === null) return; + if (manual) setRefreshing(true); + try { + const [statsRes, jobsRes] = await Promise.all([ + api.getStats(days), + api.getJobs({ limit: rows }), + ]); + setStats(statsRes.stats); + setRecentJobs(jobsRes.jobs); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to refresh dashboard.'); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + usePolling(load, 15_000, [days, rows]); + + + return ( +
+ + load(true)} + refreshing={refreshing} + /> + } + /> + + {error && ( + load(true)} + retrying={refreshing} + /> + )} + + + +
+
+
+ +

Recent reviews

+
+ + + +
+ +
+ {/* Nothing renders until the measurement lands. JobsTable falls back to 8 skeleton rows + when `skeletonRows` is undefined, so rendering it early painted a too-long table that + then shrank to the fitted count. `useFitRows` measures in a layout effect, so `rows` + is set before the first paint - this costs no visible delay. */} + {rows !== null && (loading || recentJobs.length > 0) && ( + + )} + + {!loading && recentJobs.length === 0 && ( + } + title="No jobs yet" + description="Your pull request reviews will appear here" + hints={[ + 'Once you open a PR in any of the connected repos, analysis triggers automatically', + 'To trigger manually, comment @codra on any PR', + ]} + linkAction={{ + label: 'See how to interact with Codra', + href: 'https://github.com/devarshishimpi/codra#readme', + }} + className="rounded-none border-0" + /> + )} +
+
+
+ ); +} + diff --git a/src/client/pages/job-detail.tsx b/src/client/pages/job-detail.tsx index 7552493b..97d4e6d9 100644 --- a/src/client/pages/job-detail.tsx +++ b/src/client/pages/job-detail.tsx @@ -1,106 +1,110 @@ -import { LoadError } from '@codraoss/ui'; -import { useState } from 'react'; -import { useParams } from 'react-router-dom'; -import { LazyMotion, m, domMax } from 'motion/react'; -import { ClipboardList, FileDiff } from 'lucide-react'; -import { useJobDetail } from '@client/hooks/use-job-detail'; -import { JobHeader } from '@client/components/features/job-detail/job-header'; -import { JobProgress } from '@client/components/features/job-detail/job-progress'; -import { JobMetaCards } from '@client/components/features/job-detail/job-meta-cards'; -import { JobReviewOverview } from '@client/components/features/job-detail/job-review-overview'; -import { JobFindingsList } from '@client/components/features/job-detail/job-findings-list'; -import { JobDiffs } from '@client/components/features/job-detail/job-diffs'; -import { JobDetailSkeleton } from '@client/components/features/job-detail/job-skeleton'; -import { cn } from '@codraoss/ui/utils'; - -type DetailTab = 'overview' | 'files'; - -const TABS: Array<{ id: DetailTab; label: string; icon: typeof ClipboardList }> = [ - { id: 'overview', label: 'Overview', icon: ClipboardList }, - { id: 'files', label: 'Files changed', icon: FileDiff }, -]; - -export function JobDetailPage() { - const { id = '' } = useParams(); - const [tab, setTab] = useState('overview'); - const { - job, - error, - isRerunning, - isStopping, - isDeleting, - handleRerun, - handleStop, - handleDelete, - } = useJobDetail(id); - - if (!job) { - return ; - } - - return ( -
- - - {error && } - - - - {/* domMax, not domAnimation: the underline uses `layoutId`, which needs the layout feature. */} - - - - - {tab === 'overview' ? ( -
- - - -
- ) : ( - - )} -
- ); -} +import { LoadError } from '@codraoss/ui'; +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { LazyMotion, m, domMax } from 'motion/react'; +import { ClipboardList, FileDiff } from 'lucide-react'; +import { useJobDetail } from '@client/hooks/use-job-detail'; +import { JobHeader } from '@client/components/features/job-detail/job-header'; +import { JobProgress } from '@client/components/features/job-detail/job-progress'; +import { JobStatusNotice } from '@client/components/features/job-detail/job-status-notice'; +import { JobMetaCards } from '@client/components/features/job-detail/job-meta-cards'; +import { JobReviewOverview } from '@client/components/features/job-detail/job-review-overview'; +import { JobFindingsList } from '@client/components/features/job-detail/job-findings-list'; +import { JobDiffs } from '@client/components/features/job-detail/job-diffs'; +import { JobDetailSkeleton } from '@client/components/features/job-detail/job-skeleton'; +import { cn } from '@codraoss/ui/utils'; + +type DetailTab = 'overview' | 'files'; + +const TABS: Array<{ id: DetailTab; label: string; icon: typeof ClipboardList }> = [ + { id: 'overview', label: 'Overview', icon: ClipboardList }, + { id: 'files', label: 'Files changed', icon: FileDiff }, +]; + +export function JobDetailPage() { + const { id = '' } = useParams(); + const [tab, setTab] = useState('overview'); + const { + job, + error, + isRerunning, + isStopping, + isDeleting, + handleRerun, + handleStop, + handleDelete, + } = useJobDetail(id); + + if (!job) { + return ; + } + + return ( +
+ + + {error && } + + + + {/* Terminal outcome (failed / superseded / stopped / partial) gets its own banner above the tabs. */} + + + {/* domMax, not domAnimation: the underline uses `layoutId`, which needs the layout feature. */} + + + + + {tab === 'overview' ? ( +
+ + + +
+ ) : ( + + )} +
+ ); +} diff --git a/src/client/pages/stats.tsx b/src/client/pages/stats.tsx index fd93d213..3825bf62 100644 --- a/src/client/pages/stats.tsx +++ b/src/client/pages/stats.tsx @@ -1,80 +1,75 @@ -import { LoadError } from '@codraoss/ui'; -import { useEffect, useState } from 'react'; -import { PageHeaderActions } from '@client/components/shared/page-header-actions'; -import { PageHeader } from '@client/components/layout/page-header'; -import { useIsDarkMode } from '@codraoss/ui/hooks'; -import { usePolling } from '@client/hooks/use-polling'; -import { useStatsRange } from '@client/hooks/use-stats-range'; -import { api } from '@client/lib/api'; -import type { StatsPayload } from '@codraoss/schema'; - - -import { MetricsGridSkeleton } from '@client/components/features/stats/chart-primitives'; -import { MetricsGrid } from '@client/components/features/stats/metrics-grid'; -import { prefetchMetricsCharts } from '@client/components/features/stats/metrics-grid-prefetch'; -// Skeletons reuse GraphShell so the card chrome (border, title, icon) stays put; only the chart body is skeletoned. - - -export function StatsPage() { - const [stats, setStats] = useState(null); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); - const [days, setDays] = useStatsRange(); - const isDark = useIsDarkMode(); - - // Downloads the lazy chart chunk in parallel with the first stats fetch rather than after it. - useEffect(prefetchMetricsCharts, []); - - // Switching the range reloads every metric; clear current data first so skeletons show while it loads. - const changeDays = (next: number) => { - setStats(null); - setDays(next); - }; - - const load = async (manual = false) => { - if (manual) setRefreshing(true); - try { - const res = await api.getStats(days); - setStats(res.stats); - setError(null); - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to load stats.'); - } finally { - setRefreshing(false); - } - }; - - usePolling(load, 30_000, [days]); - - return ( -
- load(true)} - refreshing={refreshing} - /> - } - /> - - {error && ( - load(true)} - retrying={refreshing} - /> - )} - - {stats ? ( - - ) : ( - - )} -
- ); -} +import { LoadError } from '@codraoss/ui'; +import { useEffect, useState } from 'react'; +import { PageHeaderActions } from '@client/components/shared/page-header-actions'; +import { PageHeader } from '@client/components/layout/page-header'; +import { useIsDarkMode } from '@codraoss/ui/hooks'; +import { usePolling } from '@client/hooks/use-polling'; +import { useStatsRange } from '@client/hooks/use-stats-range'; +import { api } from '@client/lib/api'; +import type { StatsPayload } from '@codraoss/schema'; + + +import { MetricsGrid } from '@client/components/features/stats/metrics-grid'; +import { prefetchMetricsCharts } from '@client/components/features/stats/metrics-grid-prefetch'; + + +export function StatsPage() { + const [stats, setStats] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [days, setDays] = useStatsRange(); + const isDark = useIsDarkMode(); + + // Downloads the lazy chart chunk in parallel with the first stats fetch rather than after it. + useEffect(prefetchMetricsCharts, []); + + // Switching the range reloads every metric; clear current data first so skeletons show while it loads. + const changeDays = (next: number) => { + setStats(null); + setDays(next); + }; + + const load = async (manual = false) => { + if (manual) setRefreshing(true); + try { + const res = await api.getStats(days); + setStats(res.stats); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load stats.'); + } finally { + setRefreshing(false); + } + }; + + usePolling(load, 30_000, [days]); + + return ( +
+ load(true)} + refreshing={refreshing} + /> + } + /> + + {error && ( + load(true)} + retrying={refreshing} + /> + )} + + {/* MetricsGrid owns the skeleton too: branching here as well would mount a second one. */} + +
+ ); +} From 83028a3ba9a418caf87dbb7b9b626829504086fd Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Thu, 20 Aug 2026 18:38:31 +0530 Subject: [PATCH 4/6] refactor: complete monorepo migration and add route-level authorization Finish moving legacy src/client and src/server code into apps/dashboard, apps/worker, and packages/* (api, core, db, models, schema, ui), removing the now-empty legacy directories. Introduce an authorize middleware in packages/api along with dashboard-side useSession/useCan hooks, a routes module, and nav config to gate UI and API access by permission. Update CONTRIBUTING.md to describe the finished workspace layout, and adjust build/test config (eslint, tsconfig, vite/vitest, package.json) for the new structure. --- .gitattributes | 7 + CONTRIBUTING.md | 4 +- index.html => apps/dashboard/index.html | 2 +- {src/client => apps/dashboard/src}/app.css | 1579 +++++++---------- .../src}/assets/codra-fullicon-dark.svg | 0 .../src}/assets/codra-fullicon-light.svg | 0 .../dashboard/src}/assets/icons/nit-icon.svg | 0 .../dashboard/src}/assets/icons/p0-icon.svg | 0 .../dashboard/src}/assets/icons/p1-icon.svg | 0 .../dashboard/src}/assets/icons/p2-icon.svg | 0 .../dashboard/src}/assets/icons/p3-icon.svg | 0 .../features/account/detail-rows.tsx | 0 .../features/account/details-section.tsx | 0 .../features/account/profile-card.tsx | 0 .../dashboard/updates-email-prompt.tsx | 0 .../features/job-detail/comment-card.tsx | 0 .../features/job-detail/constants.ts | 0 .../features/job-detail/context-snippet.tsx | 0 .../job-detail/diff-file-panel-utils.ts | 0 .../features/job-detail/diff-file-panel.tsx | 0 .../features/job-detail/diff-file-tree.tsx | 0 .../features/job-detail/file-finding.tsx | 0 .../features/job-detail/job-chip-utils.ts | 0 .../features/job-detail/job-chips.tsx | 342 ++-- .../features/job-detail/job-diffs.tsx | 0 .../features/job-detail/job-findings-list.tsx | 0 .../features/job-detail/job-header.tsx | 474 ++--- .../features/job-detail/job-meta-cards.tsx | 322 ++-- .../features/job-detail/job-progress.tsx | 152 +- .../job-detail/job-review-overview.tsx | 206 +-- .../features/job-detail/job-skeleton.tsx | 0 .../features/job-detail/job-status-notice.tsx | 0 .../features/job-detail/status-badge.tsx | 0 .../features/models/model-chain.tsx | 0 .../components/features/models/model-route.ts | 0 .../features/repos/repo-model-modal.tsx | 0 .../components/features/repos/repo-route.ts | 0 .../components/features/repos/repo-row.tsx | 0 .../features/reviews/live-review-stepper.tsx | 0 .../features/settings/about-section.tsx | 2 +- .../settings/default-models-section.tsx | 0 .../features/settings/field-label.tsx | 0 .../features/settings/new-provider-form.tsx | 0 .../features/settings/provider-list.tsx | 0 .../features/settings/provider-row.tsx | 0 .../features/settings/review-section.tsx | 0 .../features/settings/settings-support.ts | 0 .../features/stats/chart-primitives.tsx | 176 +- .../features/stats/chart-support.ts | 0 .../features/stats/metrics-grid-charts.tsx | 520 +++--- .../features/stats/metrics-grid-prefetch.ts | 0 .../features/stats/metrics-grid.tsx | 96 +- .../features/stats/overview-stats.tsx | 0 .../components/features/stats/stats-grid.tsx | 270 +-- .../features/stats/time-range-select.tsx | 0 .../src}/components/layout/account-menu.tsx | 428 ++--- .../src}/components/layout/app-shell.tsx | 434 ++--- .../src}/components/layout/page-header.tsx | 0 .../components/layout/sidebar-nav-item.tsx | 0 .../src}/components/shared/jobs-table.tsx | 0 .../components/shared/page-header-actions.tsx | 0 .../shared/route-error-boundary.tsx | 0 apps/dashboard/src/hooks/use-can.ts | 9 + .../dashboard/src}/hooks/use-fit-rows.ts | 280 +-- .../dashboard/src}/hooks/use-job-detail.ts | 0 .../dashboard/src}/hooks/use-polling.ts | 0 .../src}/hooks/use-provider-settings.ts | 0 .../src}/hooks/use-review-settings.ts | 0 apps/dashboard/src/hooks/use-session.tsx | 35 + .../dashboard/src}/hooks/use-stats-range.ts | 0 {src/client => apps/dashboard/src}/lib/api.ts | 0 .../dashboard/src}/lib/batch-groups.ts | 0 .../dashboard/src}/lib/diffs-cache.ts | 0 .../dashboard/src}/lib/job-format.ts | 0 .../dashboard/src}/lib/timezone.ts | 0 apps/dashboard/src/main.tsx | 54 + apps/dashboard/src/nav.ts | 20 + .../dashboard/src}/pages/account.tsx | 0 .../dashboard/src}/pages/dashboard.tsx | 262 +-- .../dashboard/src}/pages/job-detail.tsx | 220 +-- .../dashboard/src}/pages/job-logs.tsx | 768 ++++---- .../dashboard/src}/pages/jobs.tsx | 0 .../dashboard/src}/pages/landing.tsx | 0 .../dashboard/src}/pages/login.tsx | 0 .../dashboard/src}/pages/not-found.tsx | 0 .../dashboard/src}/pages/repos.tsx | 0 .../dashboard/src}/pages/settings.tsx | 0 .../dashboard/src}/pages/stats.tsx | 150 +- apps/dashboard/src/routes.tsx | 75 + .../worker/src}/adapters/file-review-store.ts | 2 +- .../worker/src}/adapters/index.ts | 4 +- .../worker/src}/adapters/jobs-store.ts | 2 +- .../worker/src}/adapters/platform.ts | 4 +- .../worker/src}/adapters/services.ts | 4 +- .../worker/src}/adapters/settings-store.ts | 4 +- apps/worker/src/api-deps.ts | 36 +- .../server => apps/worker/src}/core/config.ts | 2 +- .../worker/src}/core/job-recovery.ts | 4 +- .../server => apps/worker/src}/core/logger.ts | 0 {src/server => apps/worker/src}/core/oauth.ts | 2 +- .../worker/src}/core/review/index.ts | 4 +- {src/server => apps/worker/src}/core/rpc.ts | 0 .../worker/src}/core/sessions.ts | 2 +- .../worker/src}/core/telemetry.ts | 4 +- .../worker/src}/core/updates-email.ts | 2 +- apps/worker/src/env.ts | 6 +- apps/worker/src/index.ts | 6 +- .../src/ports/cloudflare-orchestrator.ts | 6 +- .../worker/src}/services/formatter.ts | 0 eslint.config.js | 348 ++-- package.json | 3 +- packages/api/src/index.ts | 18 +- packages/api/src/middleware/authorize.ts | 55 + packages/api/src/ports.ts | 29 + packages/api/src/router.ts | 30 +- packages/api/src/routes/api/auth.ts | 10 +- packages/api/src/routes/api/jobs.ts | 23 + packages/api/src/routes/api/models.ts | 23 + packages/api/src/routes/api/repos.ts | 13 + packages/api/src/routes/api/settings.ts | 5 + packages/api/src/routes/api/stats.ts | 3 + packages/api/src/routes/webhook.ts | 13 + packages/core/src/claim-checks.ts | 662 +++---- packages/core/src/diff/index.ts | 608 +++---- packages/core/src/diff/position.ts | 322 ++-- packages/core/src/finding-gates.ts | 308 ++-- packages/core/src/fingerprint.ts | 90 +- packages/core/src/index.ts | 56 +- packages/core/src/logger.ts | 216 +-- packages/core/src/model-output/batch.ts | 288 +-- packages/core/src/model-output/dedupe.ts | 68 +- packages/core/src/model-output/evidence.ts | 244 +-- packages/core/src/model-output/index.ts | 856 ++++----- packages/core/src/model-output/json-batch.ts | 274 +-- packages/core/src/model-output/json.ts | 750 ++++---- packages/core/src/model-output/non-answer.ts | 46 +- packages/core/src/prompts/file-review.ts | 806 ++++----- packages/core/src/prompts/languages.ts | 192 +- packages/core/src/prompts/verify.ts | 316 ++-- packages/core/src/review/bin-runner.ts | 460 ++--- packages/core/src/review/budget.ts | 62 +- packages/core/src/review/diff-cache.ts | 100 +- packages/core/src/review/file-runner.ts | 658 +++---- packages/core/src/review/finalize.ts | 648 +++---- packages/core/src/review/gate-pipeline.ts | 320 ++-- packages/core/src/review/index.ts | 726 ++++---- packages/core/src/review/pack.ts | 180 +- packages/core/src/review/phase-control.ts | 132 +- packages/core/src/review/phase.ts | 720 ++++---- packages/core/src/review/prepare.ts | 152 +- packages/core/src/review/retry-policy.ts | 202 +-- packages/core/src/review/telemetry.ts | 164 +- packages/core/src/rules/detect.ts | 294 +-- packages/core/src/rules/table.ts | 266 +-- packages/core/src/token-tracker.ts | 238 +-- packages/core/test/in-memory.ts | 830 ++++----- packages/core/test/logger.spec.ts | 208 +-- packages/core/test/redos-bounds.spec.ts | 144 +- packages/core/test/review-in-memory.spec.ts | 286 +-- packages/core/vitest.config.ts | 18 +- packages/db/package.json | 2 + packages/db/scripts/migrate-env.mjs | 41 +- packages/db/scripts/migrate.mjs | 56 +- packages/db/src/comment-feedback.ts | 232 +-- packages/db/src/file-reviews.ts | 624 +++---- packages/db/src/model-configs.ts | 768 ++++---- packages/db/src/repo-configs.ts | 422 ++--- packages/db/src/review-comment-sql.ts | 174 +- .../models/src/internal/model-review-file.ts | 490 ++--- packages/models/src/internal/model-support.ts | 268 +-- packages/models/src/limits.ts | 266 +-- packages/models/src/providers/anthropic.ts | 178 +- packages/models/src/providers/cloudflare.ts | 568 +++--- packages/models/src/providers/google.ts | 698 ++++---- packages/models/src/providers/openai.ts | 206 +-- packages/models/src/providers/vertex.ts | 626 +++---- .../models/test/model/batch-routing.spec.ts | 376 ++-- .../models/test/model/catalog-nvidia.spec.ts | 182 +- .../test/model/chain-progress-store.spec.ts | 576 +++--- .../models/test/model/chain-resume.spec.ts | 220 +-- packages/models/test/model/cloudflare.spec.ts | 230 +-- .../models/test/model/config-cache.spec.ts | 150 +- .../models/test/model/gemini-schema.spec.ts | 150 +- packages/models/test/model/limits.spec.ts | 272 +-- .../models/test/model/output-batch.spec.ts | 166 +- packages/models/test/model/output.spec.ts | 462 ++--- .../test/model/rate-limit-parse.spec.ts | 154 +- .../test/model/service-chunking.spec.ts | 484 ++--- .../test/model/service-fallbacks.spec.ts | 804 ++++----- .../model/service-grammar-rejection.spec.ts | 490 ++--- .../test/model/service-requests.spec.ts | 338 ++-- .../models/test/model/service-retries.spec.ts | 456 ++--- packages/models/test/url-guard.spec.ts | 168 +- packages/schema/src/api.ts | 34 + packages/ui/package.json | 12 +- packages/ui/scripts/copy-styles.mjs | 8 + .../ui/src/components/chart-primitives.tsx | 382 ++-- packages/ui/src/index.ts | 46 +- packages/ui/src/lib/file-tree.ts | 110 +- packages/ui/src/lib/prompt-diff.ts | 184 +- packages/ui/src/lib/utils.ts | 78 +- packages/ui/src/styles/tokens.css | 345 ++++ scripts/outdated-rate.ts | 4 +- src/client/main.tsx | 107 -- src/server/core/claim-checks.ts | 2 - src/server/core/diff/index.ts | 2 - src/server/core/fingerprint.ts | 4 - src/server/core/http.ts | 8 - src/server/core/model-output/index.ts | 2 - src/server/core/rules/detect.ts | 2 - src/server/core/rules/table.ts | 2 - src/server/core/timeout.ts | 3 - src/server/core/token-tracker.ts | 2 - src/server/core/verify.ts | 2 - src/server/env.d.ts | 32 - src/server/env.ts | 42 - src/server/prompts/file-review.ts | 2 - src/server/prompts/languages.ts | 2 - src/server/prompts/summary.ts | 2 - src/server/prompts/verify.ts | 2 - test/api/authorize.spec.ts | 128 ++ test/api/quota.spec.ts | 130 ++ test/api/router-options.spec.ts | 86 + test/db/migrate-extra-dir.spec.ts | 82 + test/diff-from-files.spec.ts | 2 +- test/diff.spec.ts | 2 +- test/e2e/router-extensions.spec.tsx | 91 + test/findings/absence-gate.spec.ts | 6 +- test/findings/blame-gate.spec.ts | 4 +- test/findings/claim-checks.spec.ts | 4 +- test/findings/claim-types.spec.ts | 4 +- test/findings/dedupe.spec.ts | 2 +- test/findings/evidence-grounding.spec.ts | 6 +- test/findings/gold-set.spec.ts | 4 +- test/findings/language-gates.spec.ts | 2 +- test/findings/non-answer.spec.ts | 2 +- test/findings/prompts-batch-review.spec.ts | 4 +- test/findings/prompts-file-context.spec.ts | 4 +- test/findings/prompts-file-review.spec.ts | 6 +- test/findings/prompts-intent.spec.ts | 4 +- test/findings/review-verify.spec.ts | 2 +- test/findings/rules-detect.spec.ts | 4 +- test/findings/rules-pipeline.spec.ts | 6 +- test/findings/undecidable-claims.spec.ts | 6 +- test/findings/verify.spec.ts | 4 +- test/helpers.ts | 20 +- test/mocks/fixtures.ts | 2 +- test/review/chunk-concurrency.spec.ts | 2 +- test/review/comments.spec.ts | 278 +-- test/review/fragmented-packing.spec.ts | 6 +- test/review/pack.spec.ts | 2 +- test/review/pipeline-regression.spec.ts | 286 +-- test/review/quota-deferral.spec.ts | 428 ++--- test/review/secondary-reviewer.spec.ts | 2 +- test/token-tracker.spec.ts | 2 +- tsconfig.base.json | 5 - tsconfig.json | 12 +- vite.config.ts | 12 +- vitest.config.ts | 6 +- 259 files changed, 17810 insertions(+), 17030 deletions(-) create mode 100644 .gitattributes rename index.html => apps/dashboard/index.html (89%) rename {src/client => apps/dashboard/src}/app.css (54%) rename {src/client => apps/dashboard/src}/assets/codra-fullicon-dark.svg (100%) rename {src/client => apps/dashboard/src}/assets/codra-fullicon-light.svg (100%) rename {src/client => apps/dashboard/src}/assets/icons/nit-icon.svg (100%) rename {src/client => apps/dashboard/src}/assets/icons/p0-icon.svg (100%) rename {src/client => apps/dashboard/src}/assets/icons/p1-icon.svg (100%) rename {src/client => apps/dashboard/src}/assets/icons/p2-icon.svg (100%) rename {src/client => apps/dashboard/src}/assets/icons/p3-icon.svg (100%) rename {src/client => apps/dashboard/src}/components/features/account/detail-rows.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/account/details-section.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/account/profile-card.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/dashboard/updates-email-prompt.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/job-detail/comment-card.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/job-detail/constants.ts (100%) rename {src/client => apps/dashboard/src}/components/features/job-detail/context-snippet.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/job-detail/diff-file-panel-utils.ts (100%) rename {src/client => apps/dashboard/src}/components/features/job-detail/diff-file-panel.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/job-detail/diff-file-tree.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/job-detail/file-finding.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/job-detail/job-chip-utils.ts (100%) rename {src/client => apps/dashboard/src}/components/features/job-detail/job-chips.tsx (96%) rename {src/client => apps/dashboard/src}/components/features/job-detail/job-diffs.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/job-detail/job-findings-list.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/job-detail/job-header.tsx (97%) rename {src/client => apps/dashboard/src}/components/features/job-detail/job-meta-cards.tsx (96%) rename {src/client => apps/dashboard/src}/components/features/job-detail/job-progress.tsx (97%) rename {src/client => apps/dashboard/src}/components/features/job-detail/job-review-overview.tsx (98%) rename {src/client => apps/dashboard/src}/components/features/job-detail/job-skeleton.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/job-detail/job-status-notice.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/job-detail/status-badge.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/models/model-chain.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/models/model-route.ts (100%) rename {src/client => apps/dashboard/src}/components/features/repos/repo-model-modal.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/repos/repo-route.ts (100%) rename {src/client => apps/dashboard/src}/components/features/repos/repo-row.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/reviews/live-review-stepper.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/settings/about-section.tsx (97%) rename {src/client => apps/dashboard/src}/components/features/settings/default-models-section.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/settings/field-label.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/settings/new-provider-form.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/settings/provider-list.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/settings/provider-row.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/settings/review-section.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/settings/settings-support.ts (100%) rename {src/client => apps/dashboard/src}/components/features/stats/chart-primitives.tsx (97%) rename {src/client => apps/dashboard/src}/components/features/stats/chart-support.ts (100%) rename {src/client => apps/dashboard/src}/components/features/stats/metrics-grid-charts.tsx (97%) rename {src/client => apps/dashboard/src}/components/features/stats/metrics-grid-prefetch.ts (100%) rename {src/client => apps/dashboard/src}/components/features/stats/metrics-grid.tsx (97%) rename {src/client => apps/dashboard/src}/components/features/stats/overview-stats.tsx (100%) rename {src/client => apps/dashboard/src}/components/features/stats/stats-grid.tsx (97%) rename {src/client => apps/dashboard/src}/components/features/stats/time-range-select.tsx (100%) rename {src/client => apps/dashboard/src}/components/layout/account-menu.tsx (97%) rename {src/client => apps/dashboard/src}/components/layout/app-shell.tsx (73%) rename {src/client => apps/dashboard/src}/components/layout/page-header.tsx (100%) rename {src/client => apps/dashboard/src}/components/layout/sidebar-nav-item.tsx (100%) rename {src/client => apps/dashboard/src}/components/shared/jobs-table.tsx (100%) rename {src/client => apps/dashboard/src}/components/shared/page-header-actions.tsx (100%) rename {src/client => apps/dashboard/src}/components/shared/route-error-boundary.tsx (100%) create mode 100644 apps/dashboard/src/hooks/use-can.ts rename {src/client => apps/dashboard/src}/hooks/use-fit-rows.ts (97%) rename {src/client => apps/dashboard/src}/hooks/use-job-detail.ts (100%) rename {src/client => apps/dashboard/src}/hooks/use-polling.ts (100%) rename {src/client => apps/dashboard/src}/hooks/use-provider-settings.ts (100%) rename {src/client => apps/dashboard/src}/hooks/use-review-settings.ts (100%) create mode 100644 apps/dashboard/src/hooks/use-session.tsx rename {src/client => apps/dashboard/src}/hooks/use-stats-range.ts (100%) rename {src/client => apps/dashboard/src}/lib/api.ts (100%) rename {src/client => apps/dashboard/src}/lib/batch-groups.ts (100%) rename {src/client => apps/dashboard/src}/lib/diffs-cache.ts (100%) rename {src/client => apps/dashboard/src}/lib/job-format.ts (100%) rename {src/client => apps/dashboard/src}/lib/timezone.ts (100%) create mode 100644 apps/dashboard/src/main.tsx create mode 100644 apps/dashboard/src/nav.ts rename {src/client => apps/dashboard/src}/pages/account.tsx (100%) rename {src/client => apps/dashboard/src}/pages/dashboard.tsx (97%) rename {src/client => apps/dashboard/src}/pages/job-detail.tsx (97%) rename {src/client => apps/dashboard/src}/pages/job-logs.tsx (97%) rename {src/client => apps/dashboard/src}/pages/jobs.tsx (100%) rename {src/client => apps/dashboard/src}/pages/landing.tsx (100%) rename {src/client => apps/dashboard/src}/pages/login.tsx (100%) rename {src/client => apps/dashboard/src}/pages/not-found.tsx (100%) rename {src/client => apps/dashboard/src}/pages/repos.tsx (100%) rename {src/client => apps/dashboard/src}/pages/settings.tsx (100%) rename {src/client => apps/dashboard/src}/pages/stats.tsx (97%) create mode 100644 apps/dashboard/src/routes.tsx rename {src/server => apps/worker/src}/adapters/file-review-store.ts (89%) rename {src/server => apps/worker/src}/adapters/index.ts (96%) rename {src/server => apps/worker/src}/adapters/jobs-store.ts (88%) rename {src/server => apps/worker/src}/adapters/platform.ts (90%) rename {src/server => apps/worker/src}/adapters/services.ts (94%) rename {src/server => apps/worker/src}/adapters/settings-store.ts (92%) rename {src/server => apps/worker/src}/core/config.ts (98%) rename {src/server => apps/worker/src}/core/job-recovery.ts (97%) rename {src/server => apps/worker/src}/core/logger.ts (100%) rename {src/server => apps/worker/src}/core/oauth.ts (94%) rename {src/server => apps/worker/src}/core/review/index.ts (94%) rename {src/server => apps/worker/src}/core/rpc.ts (100%) rename {src/server => apps/worker/src}/core/sessions.ts (95%) rename {src/server => apps/worker/src}/core/telemetry.ts (98%) rename {src/server => apps/worker/src}/core/updates-email.ts (96%) rename {src/server => apps/worker/src}/services/formatter.ts (100%) create mode 100644 packages/api/src/middleware/authorize.ts create mode 100644 packages/ui/scripts/copy-styles.mjs create mode 100644 packages/ui/src/styles/tokens.css delete mode 100644 src/client/main.tsx delete mode 100644 src/server/core/claim-checks.ts delete mode 100644 src/server/core/diff/index.ts delete mode 100644 src/server/core/fingerprint.ts delete mode 100644 src/server/core/http.ts delete mode 100644 src/server/core/model-output/index.ts delete mode 100644 src/server/core/rules/detect.ts delete mode 100644 src/server/core/rules/table.ts delete mode 100644 src/server/core/timeout.ts delete mode 100644 src/server/core/token-tracker.ts delete mode 100644 src/server/core/verify.ts delete mode 100644 src/server/env.d.ts delete mode 100644 src/server/env.ts delete mode 100644 src/server/prompts/file-review.ts delete mode 100644 src/server/prompts/languages.ts delete mode 100644 src/server/prompts/summary.ts delete mode 100644 src/server/prompts/verify.ts create mode 100644 test/api/authorize.spec.ts create mode 100644 test/api/quota.spec.ts create mode 100644 test/api/router-options.spec.ts create mode 100644 test/db/migrate-extra-dir.spec.ts create mode 100644 test/e2e/router-extensions.spec.tsx diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d608c829 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# LF in the repository and on disk, on every platform. eol=lf rather than plain text=auto because +# this working tree is shared byte-for-byte through Dropbox: if one machine checked out CRLF the +# other would see the whole tree as modified after every sync. +* text=auto eol=lf + +*.png binary +*.ico binary diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72be07f9..0859131e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ Before we can merge your pull request, you must sign our Contributor License Agr ## 📦 Repository Layout -Codra is migrating to an npm workspace monorepo. The repository is structured into `apps/` (deployable entrypoints) and `packages/` (reusable modules): +Codra is an npm workspace monorepo. The repository is structured into `apps/` (deployable entrypoints) and `packages/` (reusable modules): ```text packages/ @@ -31,7 +31,7 @@ apps/ └── dashboard/ # React SPA frontend (depends on ui, schema) ``` -**Note:** We are incrementally migrating code from the legacy `src/` directory into this workspace structure. New logic should be placed in the appropriate `packages/` or `apps/` directory when possible. +**Note:** The `packages/*` modules are published to npm as `@codraoss/*`; the workspace consumes them as TypeScript source and only the published tarballs carry compiled output. Reusable logic belongs in a package, deployment wiring in `apps/worker`, and UI in `apps/dashboard`. --- diff --git a/index.html b/apps/dashboard/index.html similarity index 89% rename from index.html rename to apps/dashboard/index.html index ab46f36e..ecc9b210 100644 --- a/index.html +++ b/apps/dashboard/index.html @@ -11,6 +11,6 @@
- + diff --git a/src/client/app.css b/apps/dashboard/src/app.css similarity index 54% rename from src/client/app.css rename to apps/dashboard/src/app.css index b1690900..04e24c96 100644 --- a/src/client/app.css +++ b/apps/dashboard/src/app.css @@ -1,962 +1,617 @@ -@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap'); - -@import "tailwindcss"; - -/* dark: utilities follow .dark class, not OS prefers-color-scheme. */ -@custom-variant dark (&:where(.dark, .dark *)); - -/* --ui-* : local neutral surface scale (no runtime design-system dep). */ -:root { - --ui-base: #ffffff; - --ui-canvas: oklch(98.75% 0 0); - --ui-line: oklch(14.5% 0 0 / 0.1); - --ui-fill: oklch(92.2% 0 0); - --ui-subtle: oklch(55.6% 0 0); - --ui-default: oklch(21% 0 0); - --ui-strong: oklch(14.5% 0 0); -} -.dark { - --ui-base: oklch(17% 0 0); - --ui-canvas: oklch(10% 0 0); - --ui-line: oklch(32% 0 0); - --ui-fill: oklch(26.9% 0 0); - --ui-subtle: oklch(70.8% 0 0); - --ui-default: oklch(97% 0 0); - --ui-strong: oklch(98.5% 0 0); -} - -:root { - --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); - --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); -} - -/* LIGHT MODE (:root default) */ -:root { - --background: oklch(100% 0 0); - --foreground: oklch(12% 0.02 115); - --card: oklch(100% 0 0); - --card-foreground: oklch(12% 0.02 115); - --popover: oklch(100% 0 0); - --popover-foreground: oklch(12% 0.02 115); - - /* Lime darkened for AA contrast on white; .dark restores full brightness. */ - --primary: oklch(64% 0.24 115); - --primary-foreground: oklch(100% 0 0); - --btn-primary-bg: oklch(64% 0.24 115); - --btn-primary-fg: oklch(20% 0.02 118); - --btn-primary-border: oklch(72% 0.17 118); - --btn-primary-surface: oklch(95% 0.09 118); - --btn-primary-hover: oklch(90% 0.13 118); - - --secondary: oklch(96.3% 0.003 286.3); - --secondary-foreground:oklch(27.4% 0.006 286.3); - --muted: oklch(96.3% 0.003 286.3); - --muted-foreground: oklch(55.1% 0.011 286.3); - - --accent: oklch(90.9% 0.004 286.3); - --accent-foreground: oklch(20.5% 0.005 286.3); - - --destructive: oklch(55% 0.22 25); - --destructive-foreground: oklch(100% 0 0); - - --border: oklch(90.9% 0.004 286.3); - --input: oklch(90.9% 0.004 286.3); - --ring: oklch(72% 0.22 115); - - --radius: 0.75rem; - --sidebar-width: 240px; - - --success: oklch(64% 0.24 115); - --success-bg: oklch(98% 0.04 115); - --success-border: oklch(85% 0.15 115); - --warning: oklch(56% 0.18 65); - --warning-bg: oklch(98% 0.04 65); - --warning-border: oklch(90% 0.12 65); - --danger: oklch(62% 0.22 25); - --danger-bg: oklch(98% 0.04 25); - --danger-border: oklch(88% 0.14 25); - --info: oklch(68% 0.18 250); - --info-bg: oklch(98% 0.04 250); - --info-border: oklch(88% 0.12 250); - - --shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.02); - --shadow-md: 0 1px 4px oklch(0% 0 0 / 0.03), 0 1px 2px oklch(0% 0 0 / 0.02); - --shadow-lg: 0 4px 16px -4px oklch(0% 0 0 / 0.04), 0 1px 6px -2px oklch(0% 0 0 / 0.03); - - --code-bg: oklch(96.3% 0.003 286.3); - --code-fg: oklch(27.4% 0.006 286.3); - --code-border: oklch(90.9% 0.004 286.3); - - /* True green/red, not brand lime, so diff rows/counts stay distinguishable. */ - --diff-add-bg: oklch(95% 0.06 150); - --diff-add-fg: oklch(48% 0.13 150); - --diff-del-bg: oklch(95% 0.05 27); - --diff-del-fg: oklch(52% 0.16 27); -} - -/* DARK MODE (.dark class on ) */ -.dark { - --background: #000000; - --foreground: oklch(98% 0.005 115); - --card: #09090b; - --card-foreground: oklch(98% 0.005 115); - --popover: #09090b; - --popover-foreground: oklch(98% 0.005 115); - - --primary: oklch(94% 0.23 115); - --primary-foreground: oklch(12% 0.04 115); - - --btn-primary-bg: #CCE800; - --btn-primary-fg: #CCE800; - --btn-primary-border: color-mix(in oklab, #CCE800 50%, transparent); - --btn-primary-surface: color-mix(in oklab, #CCE800 8%, transparent); - --btn-primary-hover: color-mix(in oklab, #CCE800 16%, transparent); - - --secondary: oklch(18% 0.018 115); - --secondary-foreground:oklch(82% 0.012 115); - --muted: oklch(18% 0.018 115); - --muted-foreground: oklch(55% 0.015 115); - - --accent: oklch(18% 0.018 115); - --accent-foreground: oklch(91% 0.010 115); - - --destructive: oklch(60% 0.220 25); - --destructive-foreground: oklch(10% 0.015 115); - - --border: oklch(22% 0.02 115); - --input: oklch(22% 0.02 115); - --ring: oklch(94% 0.23 115); - - --success: oklch(94% 0.23 115); - --success-bg: oklch(18% 0.06 115); - --success-border: oklch(28% 0.10 115); - --warning: oklch(78% 0.165 65); - --warning-bg: oklch(18% 0.080 65); - --warning-border: oklch(35% 0.14 65); - --danger: oklch(70% 0.200 25); - --danger-bg: oklch(18% 0.080 25); - --danger-border: oklch(35% 0.14 25); - --info: oklch(72% 0.160 250); - --info-bg: oklch(18% 0.075 250); - --info-border: oklch(35% 0.12 250); - - --shadow-sm: 0 1px 2px oklch(100% 0 0 / 0.05), 0 1px 2px oklch(0% 0 0 / 0.3); - --shadow-md: 0 4px 12px oklch(0% 0 0 / 0.45), 0 1px 4px oklch(0% 0 0 / 0.25); - --shadow-lg: 0 12px 24px -4px oklch(0% 0 0 / 0.5), 0 4px 12px -2px oklch(0% 0 0 / 0.3); - - --code-bg: oklch(20.5% 0.005 286.3); - --code-fg: oklch(86.5% 0.005 286.3); - --code-border: oklch(27.4% 0.006 286.3); - - --diff-add-bg: oklch(24% 0.055 150); - --diff-add-fg: oklch(82% 0.15 150); - --diff-del-bg: oklch(25% 0.075 27); - --diff-del-fg: oklch(80% 0.16 27); -} - -/* Tailwind v4 theme tokens (@theme inline = dynamic) */ -@theme inline { - --font-sans: 'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; - - /* References raw --ui-* vars so utilities flip with .dark. */ - --color-ui-base: var(--ui-base); - --color-ui-canvas: var(--ui-canvas); - --color-ui-line: var(--ui-line); - --color-ui-fill: var(--ui-fill); - --color-ui-subtle: var(--ui-subtle); - --color-ui-default: var(--ui-default); - --color-ui-strong: var(--ui-strong); - --color-ui-brand: var(--primary); - - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground:var(--destructive-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - - --color-success: var(--success); - --color-success-bg: var(--success-bg); - --color-success-border: var(--success-border); - - --color-warning: var(--warning); - --color-warning-bg: var(--warning-bg); - --color-warning-border: var(--warning-border); - - --color-danger: var(--danger); - --color-danger-bg: var(--danger-bg); - --color-danger-border: var(--danger-border); - - --color-info: var(--info); - --color-info-bg: var(--info-bg); - --color-info-border: var(--info-border); - - /* radius-lg == radius-xl intentionally: cards and .surface share one size. */ - --radius-sm: 0.3125rem; - --radius-md: 0.4375rem; - --radius-lg: 0.6875rem; - --radius-xl: 0.6875rem; - --radius-2xl: 0.875rem; - - --text-xs: 0.75rem; - --text-sm: 0.875rem; - --text-base: 1rem; - --text-lg: clamp(1.125rem, 2vw, 1.25rem); - --text-xl: clamp(1.25rem, 3vw, 1.5rem); - --text-2xl: clamp(1.5rem, 4vw, 2.25rem); - --text-3xl: clamp(2rem, 6vw, 3.5rem); - --text-4xl: clamp(2.5rem, 10vw, 6rem); - --text-display: clamp(3rem, 12vw, 9rem); - - --space-xs: clamp(0.5rem, 1vw, 0.75rem); - --space-sm: clamp(1rem, 2vw, 1.5rem); - --space-md: clamp(1.5rem, 4vw, 3rem); - --space-lg: clamp(3rem, 8vw, 6rem); - --space-xl: clamp(6rem, 12vw, 10rem); -} - -@layer base { - *, *::before, *::after { box-sizing: border-box; } - - html.theme-changing, - html.theme-changing *, - html.theme-changing *::before, - html.theme-changing *::after { - transition: none !important; - } - - html { - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - font-kerning: normal; - } - - body { - background-color: var(--background); - color: var(--foreground); - font-family: var(--font-sans); - line-height: 1.5; - min-height: 100svh; - transition: background-color 0.3s var(--ease-out-expo), - color 0.3s var(--ease-out-expo); - } - - body::before { - content: ''; - pointer-events: none; - position: fixed; - inset: 0; - z-index: -1; - background: none; - transition: opacity 0.4s; - } - - .dark body::before { - background: - radial-gradient(ellipse 60% 45% at 0% 0%, oklch(20% 0.15 115 / 0.15), transparent 60%), - radial-gradient(ellipse 50% 40% at 100% 100%, oklch(10% 0.05 115 / 0.1), transparent 60%); - } - - a { color: inherit; text-decoration: none; } - button, input, textarea, select { font: inherit; } - pre, code { font-family: var(--font-mono); } -} - -@keyframes shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } -} - -@keyframes pulse-ring { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.4; transform: scale(1.25); } -} - -@keyframes fade-up { - from { opacity: 0; transform: translateY(16px); } - to { opacity: 1; transform: translateY(0); } -} - -@keyframes fade-in { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes slide-down { - from { opacity: 0; transform: translateY(-12px); } - to { opacity: 1; transform: translateY(0); } -} - -@keyframes scale-in { - from { opacity: 0; transform: scale(0.96) translateY(8px); } - to { opacity: 1; transform: scale(1) translateY(0); } -} - -@keyframes reveal-left { - from { opacity: 0; transform: translateX(-20px); } - to { opacity: 1; transform: translateX(0); } -} - -@utility animate-fade-in { - animation: fade-in 0.5s var(--ease-out-expo) both; -} - -@utility animate-fade-up { - animation: fade-up 0.6s var(--ease-out-expo) both; -} - -@utility animate-slide-down { - animation: slide-down 0.5s var(--ease-out-expo) both; -} - -@utility animate-scale-in { - animation: scale-in 0.6s var(--ease-out-expo) both; -} - -@utility animate-reveal-left { - animation: reveal-left 0.6s var(--ease-out-expo) both; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -@utility animate-in { - animation-duration: 200ms; - animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - fill-mode: forwards; -} - -@utility animate-out { - animation-duration: 200ms; - animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - fill-mode: forwards; -} - -@utility fade-in-0 { - --tw-enter-opacity: 0; - animation-name: enter; -} - -@utility fade-out-0 { - --tw-exit-opacity: 0; - animation-name: exit; -} - -@utility zoom-in-95 { - --tw-enter-scale: 0.95; - animation-name: enter; -} - -@utility zoom-in-98 { - --tw-enter-scale: 0.98; - animation-name: enter; -} - -@utility zoom-out-95 { - --tw-exit-scale: 0.95; - animation-name: exit; -} - -@utility zoom-out-98 { - --tw-exit-scale: 0.98; - animation-name: exit; -} - -@utility slide-in-from-top-1 { - --tw-enter-translate-y: -4px; - animation-name: enter; -} - -@utility slide-in-from-top-2 { - --tw-enter-translate-y: -8px; - animation-name: enter; -} - -@utility slide-in-from-bottom-1 { - --tw-enter-translate-y: 4px; - animation-name: enter; -} - -@utility slide-in-from-bottom-2 { - --tw-enter-translate-y: 8px; - animation-name: enter; -} - -@keyframes enter { - from { - opacity: var(--tw-enter-opacity, 1); - transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0) scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), 1) rotate(var(--tw-enter-rotate, 0)); - } -} - -@keyframes exit { - to { - opacity: var(--tw-exit-opacity, 1); - transform: translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0) scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), 1) rotate(var(--tw-exit-rotate, 0)); - } -} - -@utility page-enter { - animation: fade-up 0.5s var(--ease-out-expo) both; - & > * { - animation: fade-up 0.5s var(--ease-out-expo) both; - } - & > *:nth-child(1) { animation-delay: 0ms; } - & > *:nth-child(2) { animation-delay: 60ms; } - & > *:nth-child(3) { animation-delay: 120ms; } - & > *:nth-child(4) { animation-delay: 180ms; } - & > *:nth-child(5) { animation-delay: 240ms; } - & > *:nth-child(6) { animation-delay: 300ms; } - - @media (prefers-reduced-motion: reduce) { - animation: none !important; - & > * { animation: none !important; } - } -} - -/* Toggled by JS IntersectionObserver. */ -@utility reveal-on-scroll { - opacity: 0; - transform: translateY(24px); - transition: - opacity 0.65s var(--ease-out-expo), - transform 0.65s var(--ease-out-expo); - - &.is-visible { - opacity: 1; - transform: translateY(0); - } - - @media (prefers-reduced-motion: reduce) { - opacity: 1 !important; - transform: none !important; - transition: none !important; - } -} - -@utility reveal-delay-1 { transition-delay: 80ms !important; } -@utility reveal-delay-2 { transition-delay: 160ms !important; } -@utility reveal-delay-3 { transition-delay: 240ms !important; } -@utility reveal-delay-4 { transition-delay: 320ms !important; } - -/* Scrollbars: neutral grey, auto-hidden via app-shell.tsx toggling data-scrolling. */ -* { - scrollbar-width: thin; - scrollbar-color: transparent transparent; -} - -[data-scrolling] { - scrollbar-color: oklch(0% 0 0 / 0.32) transparent; -} -.dark [data-scrolling] { - scrollbar-color: oklch(100% 0 0 / 0.3) transparent; -} - -/* Transparent border + padding-box clip insets the thumb into a slim bar. */ -::-webkit-scrollbar { - width: 10px; - height: 10px; -} -::-webkit-scrollbar-track { - background: transparent; -} -::-webkit-scrollbar-thumb { - background-color: transparent; - border: 3px solid transparent; - background-clip: padding-box; - border-radius: 999px; - transition: background-color 0.3s ease; -} -[data-scrolling]::-webkit-scrollbar-thumb { - background-color: oklch(0% 0 0 / 0.3); -} -[data-scrolling]::-webkit-scrollbar-thumb:hover { - background-color: oklch(0% 0 0 / 0.45); -} -.dark [data-scrolling]::-webkit-scrollbar-thumb { - background-color: oklch(100% 0 0 / 0.28); -} -.dark [data-scrolling]::-webkit-scrollbar-thumb:hover { - background-color: oklch(100% 0 0 / 0.45); -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } -} - -@utility surface { - @apply bg-card border border-border rounded-xl; - box-shadow: var(--shadow-md); -} - -.surface-static { - transition: none !important; -} - -.surface-static:hover { - box-shadow: var(--shadow-md) !important; - transform: none !important; -} - -.surface-static-shadow { - box-shadow: var(--shadow-md) !important; - transition: none !important; -} - -.surface-static-shadow:hover { - box-shadow: var(--shadow-md) !important; - transform: none !important; -} - -@utility glass { - @apply backdrop-blur-md bg-card/75 border border-border; - background-image: linear-gradient(to bottom right, oklch(100% 0 0 / 0.05), transparent); -} - -@utility surface-hover { - @apply transition-all duration-300; - &:hover { - @apply border-primary/30 shadow-lg shadow-primary/5 -translate-y-[1px]; - } -} - -@utility skeleton { - background: linear-gradient( - 90deg, - var(--muted) 25%, - color-mix(in oklch, var(--muted) 50%, var(--card)) 50%, - var(--muted) 75% - ); - background-size: 200% 100%; - @apply animate-[shimmer_1.8s_linear_infinite] rounded-sm; -} - -/* Unlayered, kept at this specificity so no later utility can beat it. */ -.skeleton { - background: - linear-gradient( - 90deg, - var(--muted) 25%, - color-mix(in oklch, var(--muted) 50%, var(--card)) 50%, - var(--muted) 75% - ) !important; - background-size: 200% 100% !important; - animation: shimmer 1.8s linear infinite !important; -} - -/* Model-call bars: override default lime fill with info/blue. */ -.meter-indicator-info { - background-image: none !important; - background-color: var(--info) !important; -} - -@utility code-block { - @apply m-0 whitespace-pre-wrap break-words px-[1.125rem] py-4 rounded-md font-mono text-[0.775rem] leading-[1.7] overflow-auto tracking-[0.01em]; - background-color: var(--code-bg); - color: var(--code-fg); - border: 1px solid var(--code-border); -} - -@utility severity-tag { - @apply text-[0.64rem] px-[5.5px] py-[1.5px] rounded-[3px] uppercase font-bold tracking-[0.07em] border border-transparent; - - &.P0 { @apply bg-danger-bg text-danger border-danger-border; } - &.P1 { @apply bg-warning-bg text-warning border-warning-border; } - &.P2 { - @apply bg-[oklch(95%_0.06_65)] text-[oklch(50%_0.14_65)] border-[oklch(83%_0.09_65)]; - .dark & { - @apply bg-[oklch(21%_0.07_65)] text-[oklch(72%_0.14_65)] border-[oklch(31%_0.09_65)]; - } - } - &.P3 { @apply bg-info-bg text-info border-info-border; } - &.nit { @apply bg-ui-fill/50 text-ui-subtle border-ui-line; } -} - -@utility category-tag { - @apply text-[0.72rem] text-muted-foreground inline-flex items-center gap-[5px] font-medium; - - &::before { - content: ''; - @apply w-[5px] h-[5px] rounded-full bg-current flex-shrink-0 inline-block; - } - - &.security { @apply text-danger; } - &.performance { @apply text-info; } - &.bugs { @apply text-warning; } - &.correctness { @apply text-success; } - &.quality { - @apply text-[oklch(56%_0.16_295)]; - .dark & { @apply text-[oklch(70%_0.14_295)]; } - } -} - -@utility step-dot { - @apply w-2 h-2 rounded-full flex-shrink-0; - - &.pending { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); } - &.running { - @apply bg-info; - } - &.done { @apply bg-success; } - &.failed { @apply bg-danger; } -} - -@utility pulsing-dot { - @apply w-[7px] h-[7px] rounded-full bg-info inline-block; -} - -.recharts-default-tooltip { - background: var(--card) !important; - border: 1px solid var(--border) !important; - border-radius: 10px !important; - box-shadow: 0 8px 32px oklch(5% 0.01 115 / 0.18) !important; - font-family: var(--font-sans) !important; -} -.dark .recharts-default-tooltip { - box-shadow: 0 8px 32px oklch(0% 0 0 / 0.5) !important; -} - -.app-shell-content { - --background: oklch(97.8% 0.002 286.3); - --card: oklch(100% 0 0); - --muted: oklch(90.9% 0.004 286.3); - --popover: oklch(100% 0 0); - --secondary: oklch(88.5% 0.004 286.3); - --border: oklch(90.9% 0.004 286.3); - --input: oklch(90.9% 0.004 286.3); -} - -.dark .app-shell-content { - /* Cool neutral hue 286.3, not hue 115 which gave the card a warm olive cast. */ - --background: oklch(18% 0.006 286.3); - --card: oklch(18% 0.006 286.3); - --muted: oklch(22% 0.006 286.3); - --popover: oklch(18% 0.006 286.3); - --secondary: oklch(26% 0.007 286.3); - --border: oklch(22% 0.006 286.3); - --input: oklch(22% 0.006 286.3); -} - -/* SharedLayoutBg pill is the sole hover affordance; row itself never transforms. */ -.dashboard-sidebar-action:hover, -.dashboard-sidebar-action:focus-visible, -.dashboard-sidebar-action:active { - transform: none !important; -} - -/* Light beam parked off-screen, sweeps across once on hover/focus. */ -.dashboard-sidebar-shine { - transform: skew(-13deg) translateX(-130%); - transition: transform 0ms linear; - will-change: transform; -} -.dashboard-sidebar-action:hover .dashboard-sidebar-shine, -.dashboard-sidebar-action:focus-visible .dashboard-sidebar-shine { - transform: skew(-13deg) translateX(130%); - transition-duration: 1500ms; - transition-timing-function: var(--ease-out-quart); -} - -@utility chart-card { - @apply bg-card border border-border rounded-lg overflow-hidden relative; - box-shadow: var(--shadow-md); -} - -@utility chart-card-inner { - @apply absolute inset-0 pointer-events-none z-0; - background-image: radial-gradient( - circle, - color-mix(in oklch, var(--primary) 12%, transparent) 1px, - transparent 1px - ); - background-size: 20px 20px; -} - -.chart-card > * { position: relative; z-index: 1; } - -@utility stat-number { - @apply text-2xl md:text-3xl lg:text-[2.25rem] font-bold tracking-[-0.04em] leading-none text-foreground tabular-nums; -} - -/* Geist, scoped locally since global @theme sets --font-sans/mono to app defaults. */ -.ui-font-sans { - font-family: 'Geist', ui-sans-serif, system-ui, sans-serif; -} -.ui-font-mono { - font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-feature-settings: 'tnum' 1; -} - -/* Matches dashboard stat-card chrome; .ui-well is its recessed inner panel. */ -.ui-panel { - font-family: 'Geist', ui-sans-serif, system-ui, sans-serif; - border-radius: var(--radius-lg); - border: 1px solid var(--ui-line); - background: #ffffff; -} -.dark .ui-panel { - background: #000000; - border-color: oklch(0.27 0 0); -} -.ui-well { - background: oklch(97.8% 0.002 286.3); - /* On the recessed face the neutral-500 subtle tone reads washed out in light mode, - so step it down to zinc-600. Dark mode already has enough separation. */ - --ui-subtle: oklch(44.2% 0.017 285.8); -} -.dark .ui-well { - background: oklch(19% 0 0); - --ui-subtle: oklch(70.8% 0 0); -} - -/* Syntax tokens for sugar-high (src/client/lib/highlight.tsx); it emits - color: var(--sh-) per token. */ -:root { - --sh-keyword: oklch(48% 0.19 305); - --sh-string: oklch(46% 0.12 150); - --sh-class: oklch(50% 0.13 65); - --sh-comment: oklch(58% 0.01 260); - --sh-entity: oklch(46% 0.14 260); - --sh-property: oklch(45% 0.11 200); - --sh-identifier: inherit; - --sh-sign: oklch(58% 0.01 260); - --sh-jsxliterals: inherit; - --sh-break: inherit; - --sh-space: inherit; -} -.dark { - --sh-keyword: oklch(75% 0.14 305); - --sh-string: oklch(76% 0.11 150); - --sh-class: oklch(78% 0.12 65); - --sh-comment: oklch(58% 0.01 260); - --sh-entity: oklch(76% 0.1 260); - --sh-property: oklch(78% 0.1 200); -} -.sh__token--comment { font-style: italic; } - -.diff-add { background-color: var(--diff-add-bg); } -.diff-del { background-color: var(--diff-del-bg); } -.diff-add-fg { color: var(--diff-add-fg); } -.diff-del-fg { color: var(--diff-del-fg); } - -@keyframes ui-fade-in { - from { opacity: 0; transform: translateY(2px); } - to { opacity: 1; transform: translateY(0); } -} -.ui-fade-in { - animation: ui-fade-in 0.25s ease-out both; -} - -.diff-tree ul { - list-style: none; - margin: 0; - padding: 0; -} -.diff-tree ul ul { - margin-left: 10px; - padding-left: 8px; - border-left: 1px solid var(--ui-line); -} -.diff-tree li { - position: relative; - margin-top: 2px; -} -.diff-tree ul ul li::before { - content: ""; - position: absolute; - left: -8px; - top: 14px; - width: 6px; - height: 1px; - background-color: var(--ui-line); -} -.diff-tree-children { - display: grid; - /* Implicit column would size to content (auto); pin full width so rows stretch edge-to-edge. */ - grid-template-columns: minmax(0, 1fr); - grid-template-rows: 1fr; - transition: grid-template-rows 0.25s ease-in-out; -} -.diff-tree-children[data-collapsed="true"] { - grid-template-rows: 0fr; -} -.diff-tree-children > div { - overflow: hidden; - min-width: 0; -} - -/* .thin-scroll / .auto-hide-scroll kept as no-op aliases: the treatment is - now global (Scrollbars block above); existing markup referencing them still works. */ - -.diff-tree-scroll { - overscroll-behavior: contain; - scrollbar-gutter: stable; -} - -@utility stat-label { - @apply text-[0.65rem] md:text-[0.7rem] lg:text-[0.72rem] font-semibold tracking-[0.06em] uppercase text-muted-foreground; - .dark & { color: color-mix(in oklch, var(--foreground) 72%, transparent); } -} - -@utility prose { - @apply text-[0.875rem] leading-[1.75] text-foreground; - - & h1, & h2, & h3, & h4 { - @apply font-bold leading-[1.3] mt-[1.4em] mb-[0.4em] tracking-[-0.01em]; - } - & h1 { @apply text-[1.2rem]; } - & h2 { @apply text-[1.05rem]; } - & h3 { @apply text-[0.95rem]; } - & p { @apply my-[0.6em]; } - & ul, & ol { @apply pl-[1.4em] my-[0.5em]; } - & li { @apply my-[0.2em]; } - & strong { @apply font-bold; } - & em { @apply italic; } - & code { - @apply font-mono text-[0.78em] bg-ui-fill/60 text-ui-strong px-[0.3em] py-[0.1em] rounded-[3px] border border-ui-line; - } - & pre { - @apply px-4 py-[0.85rem] rounded-md overflow-x-auto text-[0.78em]; - background-color: var(--code-bg); - color: var(--code-fg); - border: 1px solid var(--code-border); - } - & pre code { - @apply bg-transparent border-none p-0 text-inherit; - } - & blockquote { - @apply border-l-2 border-primary pl-4 text-muted-foreground my-[0.85em]; - } - & a { @apply text-primary underline underline-offset-2; } - & hr { @apply border-border my-[1.5em]; } -} - -/* Sonner toast overrides */ - -[data-sonner-toaster] { - --offset: 1.25rem !important; - --width: min(22rem, calc(100vw - 2rem)) !important; - font-family: var(--font-sans) !important; -} - -.codra-toast { - display: flex !important; - align-items: flex-start !important; - gap: 0.625rem !important; - padding: 0.75rem 0.875rem !important; - border-radius: 0.625rem !important; - border: none !important; - font-family: var(--font-sans) !important; - font-size: 0.8125rem !important; - line-height: 1.45 !important; - box-shadow: - 0 4px 16px oklch(0% 0 0 / 0.10), - 0 1px 4px oklch(0% 0 0 / 0.06), - inset 0 1px 0 oklch(100% 0 0 / 0.05) !important; - - background: oklch(99.5% 0.004 115) !important; - color: oklch(15% 0.02 115) !important; - - animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1) !important; -} - -.dark .codra-toast { - background: oklch(13% 0.018 115) !important; - color: oklch(94% 0.006 115) !important; - box-shadow: - 0 6px 24px oklch(0% 0 0 / 0.5), - 0 1px 6px oklch(0% 0 0 / 0.3), - inset 0 1px 0 oklch(100% 0 0 / 0.04) !important; -} - -.codra-toast-title { - font-size: 0.8125rem !important; - font-weight: 600 !important; - letter-spacing: 0.005em !important; - line-height: 1.35 !important; -} - -.codra-toast-description { - font-size: 0.74rem !important; - font-weight: 400 !important; - opacity: 0.72 !important; - margin-top: 0.15rem !important; - line-height: 1.5 !important; -} - -.codra-toast-icon { - margin-top: 0.05rem !important; - flex-shrink: 0 !important; -} - -.codra-toast-close { - top: 0.55rem !important; - right: 0.55rem !important; - width: 1.25rem !important; - height: 1.25rem !important; - border-radius: 0.3rem !important; - background: oklch(88% 0.006 115 / 0.6) !important; - border: 1px solid oklch(82% 0.008 115 / 0.8) !important; - color: oklch(40% 0.015 115) !important; - transition: background 150ms, opacity 150ms !important; -} - -.dark .codra-toast-close { - background: oklch(22% 0.018 115 / 0.7) !important; - border-color: oklch(30% 0.02 115 / 0.8) !important; - color: oklch(65% 0.012 115) !important; -} - -.codra-toast-close:hover { - background: oklch(82% 0.010 115) !important; - opacity: 1 !important; -} - -.dark .codra-toast-close:hover { - background: oklch(28% 0.022 115) !important; -} - -/* Status color comes from the icon; text stays the default toast color. */ -.codra-toast-loader svg { - color: var(--primary) !important; -} - -.codra-toast-warning { - color: oklch(35% 0.12 65) !important; -} - -.dark .codra-toast-warning { - color: oklch(82% 0.14 65) !important; -} - -.codra-toast-info { - color: oklch(30% 0.12 250) !important; -} - -.dark .codra-toast-info { - color: oklch(80% 0.12 250) !important; -} +@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap'); + +@import "tailwindcss"; +@import "@codraoss/ui/styles"; + +/* Tailwind v4 scans from the build root (apps/dashboard), so packages/ui would otherwise be missed and its class names silently dropped; @source adds to detection rather than replacing it. */ +@source "../../../packages/ui/src"; +@source ".."; + +:root { + --sidebar-width: 240px; +} + +@layer base { + *, *::before, *::after { box-sizing: border-box; } + + html.theme-changing, + html.theme-changing *, + html.theme-changing *::before, + html.theme-changing *::after { + transition: none !important; + } + + html { + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-kerning: normal; + } + + body { + background-color: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + line-height: 1.5; + min-height: 100svh; + transition: background-color 0.3s var(--ease-out-expo), + color 0.3s var(--ease-out-expo); + } + + body::before { + content: ''; + pointer-events: none; + position: fixed; + inset: 0; + z-index: -1; + background: none; + transition: opacity 0.4s; + } + + .dark body::before { + background: + radial-gradient(ellipse 60% 45% at 0% 0%, oklch(20% 0.15 115 / 0.15), transparent 60%), + radial-gradient(ellipse 50% 40% at 100% 100%, oklch(10% 0.05 115 / 0.1), transparent 60%); + } + + a { color: inherit; text-decoration: none; } + button, input, textarea, select { font: inherit; } + pre, code { font-family: var(--font-mono); } +} + + +@keyframes pulse-ring { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(1.25); } +} + +@keyframes fade-up { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slide-down { + from { opacity: 0; transform: translateY(-12px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes scale-in { + from { opacity: 0; transform: scale(0.96) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +@keyframes reveal-left { + from { opacity: 0; transform: translateX(-20px); } + to { opacity: 1; transform: translateX(0); } +} + +@utility animate-fade-in { + animation: fade-in 0.5s var(--ease-out-expo) both; +} + +@utility animate-fade-up { + animation: fade-up 0.6s var(--ease-out-expo) both; +} + +@utility animate-slide-down { + animation: slide-down 0.5s var(--ease-out-expo) both; +} + +@utility animate-scale-in { + animation: scale-in 0.6s var(--ease-out-expo) both; +} + +@utility animate-reveal-left { + animation: reveal-left 0.6s var(--ease-out-expo) both; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@utility animate-in { + animation-duration: 200ms; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + fill-mode: forwards; +} + +@utility animate-out { + animation-duration: 200ms; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + fill-mode: forwards; +} + +@utility fade-in-0 { + --tw-enter-opacity: 0; + animation-name: enter; +} + +@utility fade-out-0 { + --tw-exit-opacity: 0; + animation-name: exit; +} + +@utility zoom-in-95 { + --tw-enter-scale: 0.95; + animation-name: enter; +} + +@utility zoom-in-98 { + --tw-enter-scale: 0.98; + animation-name: enter; +} + +@utility zoom-out-95 { + --tw-exit-scale: 0.95; + animation-name: exit; +} + +@utility zoom-out-98 { + --tw-exit-scale: 0.98; + animation-name: exit; +} + +@utility slide-in-from-top-1 { + --tw-enter-translate-y: -4px; + animation-name: enter; +} + +@utility slide-in-from-top-2 { + --tw-enter-translate-y: -8px; + animation-name: enter; +} + +@utility slide-in-from-bottom-1 { + --tw-enter-translate-y: 4px; + animation-name: enter; +} + +@utility slide-in-from-bottom-2 { + --tw-enter-translate-y: 8px; + animation-name: enter; +} + +@keyframes enter { + from { + opacity: var(--tw-enter-opacity, 1); + transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0) scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), 1) rotate(var(--tw-enter-rotate, 0)); + } +} + +@keyframes exit { + to { + opacity: var(--tw-exit-opacity, 1); + transform: translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0) scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), 1) rotate(var(--tw-exit-rotate, 0)); + } +} + +@utility page-enter { + animation: fade-up 0.5s var(--ease-out-expo) both; + & > * { + animation: fade-up 0.5s var(--ease-out-expo) both; + } + & > *:nth-child(1) { animation-delay: 0ms; } + & > *:nth-child(2) { animation-delay: 60ms; } + & > *:nth-child(3) { animation-delay: 120ms; } + & > *:nth-child(4) { animation-delay: 180ms; } + & > *:nth-child(5) { animation-delay: 240ms; } + & > *:nth-child(6) { animation-delay: 300ms; } + + @media (prefers-reduced-motion: reduce) { + animation: none !important; + & > * { animation: none !important; } + } +} + +@utility reveal-on-scroll { + opacity: 0; + transform: translateY(24px); + transition: + opacity 0.65s var(--ease-out-expo), + transform 0.65s var(--ease-out-expo); + + &.is-visible { + opacity: 1; + transform: translateY(0); + } + + @media (prefers-reduced-motion: reduce) { + opacity: 1 !important; + transform: none !important; + transition: none !important; + } +} + +@utility reveal-delay-1 { transition-delay: 80ms !important; } +@utility reveal-delay-2 { transition-delay: 160ms !important; } +@utility reveal-delay-3 { transition-delay: 240ms !important; } +@utility reveal-delay-4 { transition-delay: 320ms !important; } + +* { + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} + +[data-scrolling] { + scrollbar-color: oklch(0% 0 0 / 0.32) transparent; +} +.dark [data-scrolling] { + scrollbar-color: oklch(100% 0 0 / 0.3) transparent; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background-color: transparent; + border: 3px solid transparent; + background-clip: padding-box; + border-radius: 999px; + transition: background-color 0.3s ease; +} +[data-scrolling]::-webkit-scrollbar-thumb { + background-color: oklch(0% 0 0 / 0.3); +} +[data-scrolling]::-webkit-scrollbar-thumb:hover { + background-color: oklch(0% 0 0 / 0.45); +} +.dark [data-scrolling]::-webkit-scrollbar-thumb { + background-color: oklch(100% 0 0 / 0.28); +} +.dark [data-scrolling]::-webkit-scrollbar-thumb:hover { + background-color: oklch(100% 0 0 / 0.45); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + + +.meter-indicator-info { + background-image: none !important; + background-color: var(--info) !important; +} + +@utility code-block { + @apply m-0 whitespace-pre-wrap break-words px-[1.125rem] py-4 rounded-md font-mono text-[0.775rem] leading-[1.7] overflow-auto tracking-[0.01em]; + background-color: var(--code-bg); + color: var(--code-fg); + border: 1px solid var(--code-border); +} + +@utility severity-tag { + @apply text-[0.64rem] px-[5.5px] py-[1.5px] rounded-[3px] uppercase font-bold tracking-[0.07em] border border-transparent; + + &.P0 { @apply bg-danger-bg text-danger border-danger-border; } + &.P1 { @apply bg-warning-bg text-warning border-warning-border; } + &.P2 { + @apply bg-[oklch(95%_0.06_65)] text-[oklch(50%_0.14_65)] border-[oklch(83%_0.09_65)]; + .dark & { + @apply bg-[oklch(21%_0.07_65)] text-[oklch(72%_0.14_65)] border-[oklch(31%_0.09_65)]; + } + } + &.P3 { @apply bg-info-bg text-info border-info-border; } + &.nit { @apply bg-ui-fill/50 text-ui-subtle border-ui-line; } +} + +@utility category-tag { + @apply text-[0.72rem] text-muted-foreground inline-flex items-center gap-[5px] font-medium; + + &::before { + content: ''; + @apply w-[5px] h-[5px] rounded-full bg-current flex-shrink-0 inline-block; + } + + &.security { @apply text-danger; } + &.performance { @apply text-info; } + &.bugs { @apply text-warning; } + &.correctness { @apply text-success; } + &.quality { + @apply text-[oklch(56%_0.16_295)]; + .dark & { @apply text-[oklch(70%_0.14_295)]; } + } +} + +@utility step-dot { + @apply w-2 h-2 rounded-full flex-shrink-0; + + &.pending { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); } + &.running { + @apply bg-info; + } + &.done { @apply bg-success; } + &.failed { @apply bg-danger; } +} + +@utility pulsing-dot { + @apply w-[7px] h-[7px] rounded-full bg-info inline-block; +} + +.recharts-default-tooltip { + background: var(--card) !important; + border: 1px solid var(--border) !important; + border-radius: 10px !important; + box-shadow: 0 8px 32px oklch(5% 0.01 115 / 0.18) !important; + font-family: var(--font-sans) !important; +} +.dark .recharts-default-tooltip { + box-shadow: 0 8px 32px oklch(0% 0 0 / 0.5) !important; +} + +.app-shell-content { + --background: oklch(97.8% 0.002 286.3); + --card: oklch(100% 0 0); + --muted: oklch(90.9% 0.004 286.3); + --popover: oklch(100% 0 0); + --secondary: oklch(88.5% 0.004 286.3); + --border: oklch(90.9% 0.004 286.3); + --input: oklch(90.9% 0.004 286.3); +} + +.dark .app-shell-content { + --background: oklch(18% 0.006 286.3); + --card: oklch(18% 0.006 286.3); + --muted: oklch(22% 0.006 286.3); + --popover: oklch(18% 0.006 286.3); + --secondary: oklch(26% 0.007 286.3); + --border: oklch(22% 0.006 286.3); + --input: oklch(22% 0.006 286.3); +} + +.dashboard-sidebar-action:hover, +.dashboard-sidebar-action:focus-visible, +.dashboard-sidebar-action:active { + transform: none !important; +} + +.dashboard-sidebar-shine { + transform: skew(-13deg) translateX(-130%); + transition: transform 0ms linear; + will-change: transform; +} +.dashboard-sidebar-action:hover .dashboard-sidebar-shine, +.dashboard-sidebar-action:focus-visible .dashboard-sidebar-shine { + transform: skew(-13deg) translateX(130%); + transition-duration: 1500ms; + transition-timing-function: var(--ease-out-quart); +} + +@utility chart-card { + @apply bg-card border border-border rounded-lg overflow-hidden relative; + box-shadow: var(--shadow-md); +} + +@utility chart-card-inner { + @apply absolute inset-0 pointer-events-none z-0; + background-image: radial-gradient( + circle, + color-mix(in oklch, var(--primary) 12%, transparent) 1px, + transparent 1px + ); + background-size: 20px 20px; +} + +.chart-card > * { position: relative; z-index: 1; } + +@utility stat-number { + @apply text-2xl md:text-3xl lg:text-[2.25rem] font-bold tracking-[-0.04em] leading-none text-foreground tabular-nums; +} + + +.sh__token--comment { font-style: italic; } + +.diff-add { background-color: var(--diff-add-bg); } +.diff-del { background-color: var(--diff-del-bg); } +.diff-add-fg { color: var(--diff-add-fg); } +.diff-del-fg { color: var(--diff-del-fg); } + +@keyframes ui-fade-in { + from { opacity: 0; transform: translateY(2px); } + to { opacity: 1; transform: translateY(0); } +} +.ui-fade-in { + animation: ui-fade-in 0.25s ease-out both; +} + +.diff-tree ul { + list-style: none; + margin: 0; + padding: 0; +} +.diff-tree ul ul { + margin-left: 10px; + padding-left: 8px; + border-left: 1px solid var(--ui-line); +} +.diff-tree li { + position: relative; + margin-top: 2px; +} +.diff-tree ul ul li::before { + content: ""; + position: absolute; + left: -8px; + top: 14px; + width: 6px; + height: 1px; + background-color: var(--ui-line); +} +.diff-tree-children { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: 1fr; + transition: grid-template-rows 0.25s ease-in-out; +} +.diff-tree-children[data-collapsed="true"] { + grid-template-rows: 0fr; +} +.diff-tree-children > div { + overflow: hidden; + min-width: 0; +} + +/* .thin-scroll and .auto-hide-scroll are kept as no-op aliases: the treatment is global now, and existing markup referencing them still works. */ + +.diff-tree-scroll { + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +@utility stat-label { + @apply text-[0.65rem] md:text-[0.7rem] lg:text-[0.72rem] font-semibold tracking-[0.06em] uppercase text-muted-foreground; + .dark & { color: color-mix(in oklch, var(--foreground) 72%, transparent); } +} + +@utility prose { + @apply text-[0.875rem] leading-[1.75] text-foreground; + + & h1, & h2, & h3, & h4 { + @apply font-bold leading-[1.3] mt-[1.4em] mb-[0.4em] tracking-[-0.01em]; + } + & h1 { @apply text-[1.2rem]; } + & h2 { @apply text-[1.05rem]; } + & h3 { @apply text-[0.95rem]; } + & p { @apply my-[0.6em]; } + & ul, & ol { @apply pl-[1.4em] my-[0.5em]; } + & li { @apply my-[0.2em]; } + & strong { @apply font-bold; } + & em { @apply italic; } + & code { + @apply font-mono text-[0.78em] bg-ui-fill/60 text-ui-strong px-[0.3em] py-[0.1em] rounded-[3px] border border-ui-line; + } + & pre { + @apply px-4 py-[0.85rem] rounded-md overflow-x-auto text-[0.78em]; + background-color: var(--code-bg); + color: var(--code-fg); + border: 1px solid var(--code-border); + } + & pre code { + @apply bg-transparent border-none p-0 text-inherit; + } + & blockquote { + @apply border-l-2 border-primary pl-4 text-muted-foreground my-[0.85em]; + } + & a { @apply text-primary underline underline-offset-2; } + & hr { @apply border-border my-[1.5em]; } +} + + +[data-sonner-toaster] { + --offset: 1.25rem !important; + --width: min(22rem, calc(100vw - 2rem)) !important; + font-family: var(--font-sans) !important; +} + +.codra-toast { + display: flex !important; + align-items: flex-start !important; + gap: 0.625rem !important; + padding: 0.75rem 0.875rem !important; + border-radius: 0.625rem !important; + border: none !important; + font-family: var(--font-sans) !important; + font-size: 0.8125rem !important; + line-height: 1.45 !important; + box-shadow: + 0 4px 16px oklch(0% 0 0 / 0.10), + 0 1px 4px oklch(0% 0 0 / 0.06), + inset 0 1px 0 oklch(100% 0 0 / 0.05) !important; + + background: oklch(99.5% 0.004 115) !important; + color: oklch(15% 0.02 115) !important; + + animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1) !important; +} + +.dark .codra-toast { + background: oklch(13% 0.018 115) !important; + color: oklch(94% 0.006 115) !important; + box-shadow: + 0 6px 24px oklch(0% 0 0 / 0.5), + 0 1px 6px oklch(0% 0 0 / 0.3), + inset 0 1px 0 oklch(100% 0 0 / 0.04) !important; +} + +.codra-toast-title { + font-size: 0.8125rem !important; + font-weight: 600 !important; + letter-spacing: 0.005em !important; + line-height: 1.35 !important; +} + +.codra-toast-description { + font-size: 0.74rem !important; + font-weight: 400 !important; + opacity: 0.72 !important; + margin-top: 0.15rem !important; + line-height: 1.5 !important; +} + +.codra-toast-icon { + margin-top: 0.05rem !important; + flex-shrink: 0 !important; +} + +.codra-toast-close { + top: 0.55rem !important; + right: 0.55rem !important; + width: 1.25rem !important; + height: 1.25rem !important; + border-radius: 0.3rem !important; + background: oklch(88% 0.006 115 / 0.6) !important; + border: 1px solid oklch(82% 0.008 115 / 0.8) !important; + color: oklch(40% 0.015 115) !important; + transition: background 150ms, opacity 150ms !important; +} + +.dark .codra-toast-close { + background: oklch(22% 0.018 115 / 0.7) !important; + border-color: oklch(30% 0.02 115 / 0.8) !important; + color: oklch(65% 0.012 115) !important; +} + +.codra-toast-close:hover { + background: oklch(82% 0.010 115) !important; + opacity: 1 !important; +} + +.dark .codra-toast-close:hover { + background: oklch(28% 0.022 115) !important; +} + +.codra-toast-loader svg { + color: var(--primary) !important; +} + +.codra-toast-warning { + color: oklch(35% 0.12 65) !important; +} + +.dark .codra-toast-warning { + color: oklch(82% 0.14 65) !important; +} + +.codra-toast-info { + color: oklch(30% 0.12 250) !important; +} + +.dark .codra-toast-info { + color: oklch(80% 0.12 250) !important; +} diff --git a/src/client/assets/codra-fullicon-dark.svg b/apps/dashboard/src/assets/codra-fullicon-dark.svg similarity index 100% rename from src/client/assets/codra-fullicon-dark.svg rename to apps/dashboard/src/assets/codra-fullicon-dark.svg diff --git a/src/client/assets/codra-fullicon-light.svg b/apps/dashboard/src/assets/codra-fullicon-light.svg similarity index 100% rename from src/client/assets/codra-fullicon-light.svg rename to apps/dashboard/src/assets/codra-fullicon-light.svg diff --git a/src/client/assets/icons/nit-icon.svg b/apps/dashboard/src/assets/icons/nit-icon.svg similarity index 100% rename from src/client/assets/icons/nit-icon.svg rename to apps/dashboard/src/assets/icons/nit-icon.svg diff --git a/src/client/assets/icons/p0-icon.svg b/apps/dashboard/src/assets/icons/p0-icon.svg similarity index 100% rename from src/client/assets/icons/p0-icon.svg rename to apps/dashboard/src/assets/icons/p0-icon.svg diff --git a/src/client/assets/icons/p1-icon.svg b/apps/dashboard/src/assets/icons/p1-icon.svg similarity index 100% rename from src/client/assets/icons/p1-icon.svg rename to apps/dashboard/src/assets/icons/p1-icon.svg diff --git a/src/client/assets/icons/p2-icon.svg b/apps/dashboard/src/assets/icons/p2-icon.svg similarity index 100% rename from src/client/assets/icons/p2-icon.svg rename to apps/dashboard/src/assets/icons/p2-icon.svg diff --git a/src/client/assets/icons/p3-icon.svg b/apps/dashboard/src/assets/icons/p3-icon.svg similarity index 100% rename from src/client/assets/icons/p3-icon.svg rename to apps/dashboard/src/assets/icons/p3-icon.svg diff --git a/src/client/components/features/account/detail-rows.tsx b/apps/dashboard/src/components/features/account/detail-rows.tsx similarity index 100% rename from src/client/components/features/account/detail-rows.tsx rename to apps/dashboard/src/components/features/account/detail-rows.tsx diff --git a/src/client/components/features/account/details-section.tsx b/apps/dashboard/src/components/features/account/details-section.tsx similarity index 100% rename from src/client/components/features/account/details-section.tsx rename to apps/dashboard/src/components/features/account/details-section.tsx diff --git a/src/client/components/features/account/profile-card.tsx b/apps/dashboard/src/components/features/account/profile-card.tsx similarity index 100% rename from src/client/components/features/account/profile-card.tsx rename to apps/dashboard/src/components/features/account/profile-card.tsx diff --git a/src/client/components/features/dashboard/updates-email-prompt.tsx b/apps/dashboard/src/components/features/dashboard/updates-email-prompt.tsx similarity index 100% rename from src/client/components/features/dashboard/updates-email-prompt.tsx rename to apps/dashboard/src/components/features/dashboard/updates-email-prompt.tsx diff --git a/src/client/components/features/job-detail/comment-card.tsx b/apps/dashboard/src/components/features/job-detail/comment-card.tsx similarity index 100% rename from src/client/components/features/job-detail/comment-card.tsx rename to apps/dashboard/src/components/features/job-detail/comment-card.tsx diff --git a/src/client/components/features/job-detail/constants.ts b/apps/dashboard/src/components/features/job-detail/constants.ts similarity index 100% rename from src/client/components/features/job-detail/constants.ts rename to apps/dashboard/src/components/features/job-detail/constants.ts diff --git a/src/client/components/features/job-detail/context-snippet.tsx b/apps/dashboard/src/components/features/job-detail/context-snippet.tsx similarity index 100% rename from src/client/components/features/job-detail/context-snippet.tsx rename to apps/dashboard/src/components/features/job-detail/context-snippet.tsx diff --git a/src/client/components/features/job-detail/diff-file-panel-utils.ts b/apps/dashboard/src/components/features/job-detail/diff-file-panel-utils.ts similarity index 100% rename from src/client/components/features/job-detail/diff-file-panel-utils.ts rename to apps/dashboard/src/components/features/job-detail/diff-file-panel-utils.ts diff --git a/src/client/components/features/job-detail/diff-file-panel.tsx b/apps/dashboard/src/components/features/job-detail/diff-file-panel.tsx similarity index 100% rename from src/client/components/features/job-detail/diff-file-panel.tsx rename to apps/dashboard/src/components/features/job-detail/diff-file-panel.tsx diff --git a/src/client/components/features/job-detail/diff-file-tree.tsx b/apps/dashboard/src/components/features/job-detail/diff-file-tree.tsx similarity index 100% rename from src/client/components/features/job-detail/diff-file-tree.tsx rename to apps/dashboard/src/components/features/job-detail/diff-file-tree.tsx diff --git a/src/client/components/features/job-detail/file-finding.tsx b/apps/dashboard/src/components/features/job-detail/file-finding.tsx similarity index 100% rename from src/client/components/features/job-detail/file-finding.tsx rename to apps/dashboard/src/components/features/job-detail/file-finding.tsx diff --git a/src/client/components/features/job-detail/job-chip-utils.ts b/apps/dashboard/src/components/features/job-detail/job-chip-utils.ts similarity index 100% rename from src/client/components/features/job-detail/job-chip-utils.ts rename to apps/dashboard/src/components/features/job-detail/job-chip-utils.ts diff --git a/src/client/components/features/job-detail/job-chips.tsx b/apps/dashboard/src/components/features/job-detail/job-chips.tsx similarity index 96% rename from src/client/components/features/job-detail/job-chips.tsx rename to apps/dashboard/src/components/features/job-detail/job-chips.tsx index 6dcf87ba..cfa273b9 100644 --- a/src/client/components/features/job-detail/job-chips.tsx +++ b/apps/dashboard/src/components/features/job-detail/job-chips.tsx @@ -1,171 +1,171 @@ -// Row vocabulary shared with the job detail page, mirroring the jobs table. -import { useState, type ReactNode } from 'react'; -import { CheckCircle2, MessageSquare, type LucideIcon } from 'lucide-react'; -import { cn } from '@codraoss/ui/utils'; -import { STATUS_DOT, jobDuration, statusLabel } from '@client/lib/job-format'; - -import type { JobDetail, JobSummary } from '@codraoss/schema'; - - -export function StatusDot({ status, className }: { status: string; className?: string }) { - return ( - - ); -} - -// Mirrors the jobs table's status cell. -export function StatusLine({ - status, - duration, - className, -}: { - status: string; - duration?: string | null; - className?: string; -}) { - return ( - - - {statusLabel(status)} - {duration && ( - - {duration} - - )} - - ); -} - -export function JobStatusLine({ job, className }: { job: JobDetail; className?: string }) { - return ; -} - -// Border stays neutral; only the icon carries colour. -export function VerdictPill({ verdict }: { verdict: NonNullable }) { - const approved = verdict === 'approve'; - const Icon = approved ? CheckCircle2 : MessageSquare; - - return ( - - - {verdict} - - ); -} - -export function OutlinePill({ - icon: Icon, - tone, - children, -}: { - icon?: LucideIcon; - tone?: string; - children: ReactNode; -}) { - return ( - - {Icon && } - {children} - - ); -} - -// Mirrors the table's MetaCell. -export function MetaChip({ - icon: Icon, - children, - mono = false, - title, - className, -}: { - icon: LucideIcon; - children: ReactNode; - mono?: boolean; - title?: string; - className?: string; -}) { - return ( - - - - {children} - - - ); -} - -// Hits avatars.githubusercontent.com directly: the github.com/.png redirect can fail. -// No loading="lazy": intersection detection is unreliable in this app's scroll containers. -export function AuthorAvatar({ login, size = 20 }: { login: string | null; size?: number }) { - const [failed, setFailed] = useState(false); - const box = { width: size, height: size }; - - if (!login || failed) { - return ( - - {login?.charAt(0) ?? ''} - - ); - } - - return ( - setFailed(true)} - className="shrink-0 rounded-full bg-ui-fill object-cover ring-1 ring-ui-line" - /> - ); -} - -export function AuthorChip({ login }: { login: string | null }) { - if (!login) return null; - return ( - - - - {login} - - - ); -} - -export function EmptyValue() { - return -; -} - -export function MonoPath({ path, className }: { path: string; className?: string }) { - const slash = path.lastIndexOf('/'); - const dir = slash === -1 ? '' : path.slice(0, slash + 1); - const base = slash === -1 ? path : path.slice(slash + 1); - - return ( - - {dir && {dir}} - {base} - - ); -} +// Row vocabulary shared with the job detail page, mirroring the jobs table. +import { useState, type ReactNode } from 'react'; +import { CheckCircle2, MessageSquare, type LucideIcon } from 'lucide-react'; +import { cn } from '@codraoss/ui/utils'; +import { STATUS_DOT, jobDuration, statusLabel } from '@client/lib/job-format'; + +import type { JobDetail, JobSummary } from '@codraoss/schema'; + + +export function StatusDot({ status, className }: { status: string; className?: string }) { + return ( + + ); +} + +// Mirrors the jobs table's status cell. +export function StatusLine({ + status, + duration, + className, +}: { + status: string; + duration?: string | null; + className?: string; +}) { + return ( + + + {statusLabel(status)} + {duration && ( + + {duration} + + )} + + ); +} + +export function JobStatusLine({ job, className }: { job: JobDetail; className?: string }) { + return ; +} + +// Border stays neutral; only the icon carries colour. +export function VerdictPill({ verdict }: { verdict: NonNullable }) { + const approved = verdict === 'approve'; + const Icon = approved ? CheckCircle2 : MessageSquare; + + return ( + + + {verdict} + + ); +} + +export function OutlinePill({ + icon: Icon, + tone, + children, +}: { + icon?: LucideIcon; + tone?: string; + children: ReactNode; +}) { + return ( + + {Icon && } + {children} + + ); +} + +// Mirrors the table's MetaCell. +export function MetaChip({ + icon: Icon, + children, + mono = false, + title, + className, +}: { + icon: LucideIcon; + children: ReactNode; + mono?: boolean; + title?: string; + className?: string; +}) { + return ( + + + + {children} + + + ); +} + +// Hits avatars.githubusercontent.com directly: the github.com/.png redirect can fail. +// No loading="lazy": intersection detection is unreliable in this app's scroll containers. +export function AuthorAvatar({ login, size = 20 }: { login: string | null; size?: number }) { + const [failed, setFailed] = useState(false); + const box = { width: size, height: size }; + + if (!login || failed) { + return ( + + {login?.charAt(0) ?? ''} + + ); + } + + return ( + setFailed(true)} + className="shrink-0 rounded-full bg-ui-fill object-cover ring-1 ring-ui-line" + /> + ); +} + +export function AuthorChip({ login }: { login: string | null }) { + if (!login) return null; + return ( + + + + {login} + + + ); +} + +export function EmptyValue() { + return -; +} + +export function MonoPath({ path, className }: { path: string; className?: string }) { + const slash = path.lastIndexOf('/'); + const dir = slash === -1 ? '' : path.slice(0, slash + 1); + const base = slash === -1 ? path : path.slice(slash + 1); + + return ( + + {dir && {dir}} + {base} + + ); +} diff --git a/src/client/components/features/job-detail/job-diffs.tsx b/apps/dashboard/src/components/features/job-detail/job-diffs.tsx similarity index 100% rename from src/client/components/features/job-detail/job-diffs.tsx rename to apps/dashboard/src/components/features/job-detail/job-diffs.tsx diff --git a/src/client/components/features/job-detail/job-findings-list.tsx b/apps/dashboard/src/components/features/job-detail/job-findings-list.tsx similarity index 100% rename from src/client/components/features/job-detail/job-findings-list.tsx rename to apps/dashboard/src/components/features/job-detail/job-findings-list.tsx diff --git a/src/client/components/features/job-detail/job-header.tsx b/apps/dashboard/src/components/features/job-detail/job-header.tsx similarity index 97% rename from src/client/components/features/job-detail/job-header.tsx rename to apps/dashboard/src/components/features/job-detail/job-header.tsx index 8bc3947c..8eff1283 100644 --- a/src/client/components/features/job-detail/job-header.tsx +++ b/apps/dashboard/src/components/features/job-detail/job-header.tsx @@ -1,237 +1,237 @@ -import { Button, ConfirmDialog } from '@codraoss/ui'; -import { useState } from 'react'; -import type { ComponentType } from 'react'; -import { Link } from 'react-router-dom'; -import { ChevronRight, ExternalLink, Loader2, RotateCcw, Terminal, Trash2 } from 'lucide-react'; -import type { ButtonProps } from '@codraoss/ui'; -import { UpdatesEmailPrompt } from '@client/components/features/dashboard/updates-email-prompt'; -import { AuthorChip, VerdictPill } from './job-chips'; -import { formatAbsoluteDate, formatRelativeDate } from './job-chip-utils'; -import type { JobDetail } from '@codraoss/schema'; - -// Lucide's CircleStop strokes the inner square too, which reads as a blob at 14px; filling it -// instead keeps the stop symbol legible. -function StopIcon({ size = 14 }: { size?: number }) { - return ( - - ); -} - -interface JobActionButtonProps { - icon: ComponentType<{ size?: number }>; - label: string; - /** In-flight: swaps the icon for a spinner. Also disables unless `disabled` says otherwise. */ - busy: boolean; - disabled?: boolean; - variant?: ButtonProps['variant']; - className?: string; - onClick: () => void; -} - -// Every header action is the same icon-only button whose only state is "in flight", so the busy flag -// lives here rather than branching the header itself. -function JobActionButton({ - icon: Icon, - label, - busy, - disabled, - variant = 'secondary', - className = 'rounded-[7px]', - onClick, -}: JobActionButtonProps) { - return ( - - ); -} - -/** `·` inside a group of related facts, `|` between groups. */ -function Dot() { - return ·; -} - -function Pipe() { - return |; -} - -interface JobHeaderProps { - job: JobDetail; - isRerunning: boolean; - isStopping: boolean; - isDeleting: boolean; - onRerun: () => void; - onStop: () => void; - onDelete: () => void; -} - -export function JobHeader({ - job, - isRerunning, - isStopping, - isDeleting, - onRerun, - onStop, - onDelete, -}: JobHeaderProps) { - const [stopOpen, setStopOpen] = useState(false); - const [deleteOpen, setDeleteOpen] = useState(false); - - const canStop = job.status === 'running' || job.status === 'queued'; - - return ( - <> - {/* Full-bleed header: a hairline rule under the breadcrumb bar, then the title and the PR's - coordinates. No card - the panels below are the cards, and framing this too would nest a - surface inside a surface. Status, token counts and the step list live in those panels. */} -
-
-
- - Jobs - - - - {job.id.slice(0, 8)} - -
- -
- - - setStopOpen(true)} - /> - - {/* Always restarts the review from the beginning (every file), regardless of the job's current status. */} - - - setDeleteOpen(true)} - /> -
-
- -
-

- - {job.prTitle ?? 'Untitled pull request'} - - - {job.verdict && } -

- - {/* Coordinates, in one readable line rather than a row of chips. */} -
- - {job.owner}/{job.repo} - - - #{job.prNumber} - {job.commitSha && ( - <> - - - {job.commitSha.slice(0, 7)} - - - )} - - {job.baseRef && job.headRef && ( - <> - - - {job.baseRef} ← {job.headRef} - - - )} - - - - - - {formatRelativeDate(job.createdAt)} - -
-
-
- - - - - - - - ); -} +import { Button, ConfirmDialog } from '@codraoss/ui'; +import { useState } from 'react'; +import type { ComponentType } from 'react'; +import { Link } from 'react-router-dom'; +import { ChevronRight, ExternalLink, Loader2, RotateCcw, Terminal, Trash2 } from 'lucide-react'; +import type { ButtonProps } from '@codraoss/ui'; +import { UpdatesEmailPrompt } from '@client/components/features/dashboard/updates-email-prompt'; +import { AuthorChip, VerdictPill } from './job-chips'; +import { formatAbsoluteDate, formatRelativeDate } from './job-chip-utils'; +import type { JobDetail } from '@codraoss/schema'; + +// Lucide's CircleStop strokes the inner square too, which reads as a blob at 14px; filling it +// instead keeps the stop symbol legible. +function StopIcon({ size = 14 }: { size?: number }) { + return ( + + ); +} + +interface JobActionButtonProps { + icon: ComponentType<{ size?: number }>; + label: string; + /** In-flight: swaps the icon for a spinner. Also disables unless `disabled` says otherwise. */ + busy: boolean; + disabled?: boolean; + variant?: ButtonProps['variant']; + className?: string; + onClick: () => void; +} + +// Every header action is the same icon-only button whose only state is "in flight", so the busy flag +// lives here rather than branching the header itself. +function JobActionButton({ + icon: Icon, + label, + busy, + disabled, + variant = 'secondary', + className = 'rounded-[7px]', + onClick, +}: JobActionButtonProps) { + return ( + + ); +} + +/** `·` inside a group of related facts, `|` between groups. */ +function Dot() { + return ·; +} + +function Pipe() { + return |; +} + +interface JobHeaderProps { + job: JobDetail; + isRerunning: boolean; + isStopping: boolean; + isDeleting: boolean; + onRerun: () => void; + onStop: () => void; + onDelete: () => void; +} + +export function JobHeader({ + job, + isRerunning, + isStopping, + isDeleting, + onRerun, + onStop, + onDelete, +}: JobHeaderProps) { + const [stopOpen, setStopOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const canStop = job.status === 'running' || job.status === 'queued'; + + return ( + <> + {/* Full-bleed header: a hairline rule under the breadcrumb bar, then the title and the PR's + coordinates. No card - the panels below are the cards, and framing this too would nest a + surface inside a surface. Status, token counts and the step list live in those panels. */} +
+
+
+ + Jobs + + + + {job.id.slice(0, 8)} + +
+ +
+ + + setStopOpen(true)} + /> + + {/* Always restarts the review from the beginning (every file), regardless of the job's current status. */} + + + setDeleteOpen(true)} + /> +
+
+ +
+

+ + {job.prTitle ?? 'Untitled pull request'} + + + {job.verdict && } +

+ + {/* Coordinates, in one readable line rather than a row of chips. */} +
+ + {job.owner}/{job.repo} + + + #{job.prNumber} + {job.commitSha && ( + <> + + + {job.commitSha.slice(0, 7)} + + + )} + + {job.baseRef && job.headRef && ( + <> + + + {job.baseRef} ← {job.headRef} + + + )} + + + + + + {formatRelativeDate(job.createdAt)} + +
+
+
+ + + + + + + + ); +} diff --git a/src/client/components/features/job-detail/job-meta-cards.tsx b/apps/dashboard/src/components/features/job-detail/job-meta-cards.tsx similarity index 96% rename from src/client/components/features/job-detail/job-meta-cards.tsx rename to apps/dashboard/src/components/features/job-detail/job-meta-cards.tsx index 52a5c08a..e7f67ec5 100644 --- a/src/client/components/features/job-detail/job-meta-cards.tsx +++ b/apps/dashboard/src/components/features/job-detail/job-meta-cards.tsx @@ -1,161 +1,161 @@ -import type { ReactNode } from 'react'; -import { AtSign, Info, ListChecks, RotateCcw, Zap } from 'lucide-react'; -import { Link } from 'react-router-dom'; -import { cn, formatPreciseDuration } from '@codraoss/ui/utils'; -import type { JobDetail, JobStep } from '@codraoss/schema'; -import { - EmptyValue, - JobStatusLine, - MetaChip, - StatusDot, - VerdictPill, -} from './job-chips'; -import { DETAIL_LABEL, DETAIL_ROW, formatAbsoluteDate, formatRelativeDate } from './job-chip-utils'; - -interface JobMetaCardsProps { - job: JobDetail; -} - -const TRIGGER_ICON = { - auto: Zap, - mention: AtSign, - retry: RotateCcw, -} as const; - -function elapsedSec(step: JobStep): string | null { - if (step.finishedAt && step.startedAt) { - const start = new Date(step.startedAt).getTime(); - const end = new Date(step.finishedAt).getTime(); - if (!Number.isFinite(start) || !Number.isFinite(end)) return null; - return formatPreciseDuration(end - start); - } - return null; -} - -function MetaPanel({ - icon: Icon, - title, - children, -}: { - icon: typeof Info; - title: string; - children: ReactNode; -}) { - return ( -
-
- -

{title}

-
- {/* Recessed inner panel, same as the dashboard stat cards. */} -
{children}
-
- ); -} - -function DetailRow({ label, children }: { label: string; children: ReactNode }) { - return ( -
-
{label}
-
{children}
-
- ); -} - -function StepRow({ step }: { step: JobStep }) { - const isRunning = step.status === 'running'; - const isPending = step.status === 'pending'; - const elapsed = elapsedSec(step); - - return ( -
-
- - - {step.name} - -
- -
- {isRunning ? ( - Running - ) : elapsed ? ( - - {elapsed} - - ) : ( - - )} -
-
- ); -} - -export function JobMetaCards({ job }: JobMetaCardsProps) { - const steps = job.steps ?? []; - const TriggerIcon = TRIGGER_ICON[job.trigger] ?? Zap; - - return ( -
- -
- - - - - - {job.verdict ? : } - - - - - {job.trigger} - - - - - - {(job.totalInputTokens + job.totalOutputTokens).toLocaleString()} - - - - - - {formatRelativeDate(job.createdAt)} - - - - {job.retryOfJobId && ( - - - {job.retryOfJobId.slice(0, 8)} - - - )} -
- -
- - - {steps.length === 0 ? ( -

No steps recorded yet.

- ) : ( - steps.map((step) => ) - )} -
-
- ); -} +import type { ReactNode } from 'react'; +import { AtSign, Info, ListChecks, RotateCcw, Zap } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { cn, formatPreciseDuration } from '@codraoss/ui/utils'; +import type { JobDetail, JobStep } from '@codraoss/schema'; +import { + EmptyValue, + JobStatusLine, + MetaChip, + StatusDot, + VerdictPill, +} from './job-chips'; +import { DETAIL_LABEL, DETAIL_ROW, formatAbsoluteDate, formatRelativeDate } from './job-chip-utils'; + +interface JobMetaCardsProps { + job: JobDetail; +} + +const TRIGGER_ICON = { + auto: Zap, + mention: AtSign, + retry: RotateCcw, +} as const; + +function elapsedSec(step: JobStep): string | null { + if (step.finishedAt && step.startedAt) { + const start = new Date(step.startedAt).getTime(); + const end = new Date(step.finishedAt).getTime(); + if (!Number.isFinite(start) || !Number.isFinite(end)) return null; + return formatPreciseDuration(end - start); + } + return null; +} + +function MetaPanel({ + icon: Icon, + title, + children, +}: { + icon: typeof Info; + title: string; + children: ReactNode; +}) { + return ( +
+
+ +

{title}

+
+ {/* Recessed inner panel, same as the dashboard stat cards. */} +
{children}
+
+ ); +} + +function DetailRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function StepRow({ step }: { step: JobStep }) { + const isRunning = step.status === 'running'; + const isPending = step.status === 'pending'; + const elapsed = elapsedSec(step); + + return ( +
+
+ + + {step.name} + +
+ +
+ {isRunning ? ( + Running + ) : elapsed ? ( + + {elapsed} + + ) : ( + + )} +
+
+ ); +} + +export function JobMetaCards({ job }: JobMetaCardsProps) { + const steps = job.steps ?? []; + const TriggerIcon = TRIGGER_ICON[job.trigger] ?? Zap; + + return ( +
+ +
+ + + + + + {job.verdict ? : } + + + + + {job.trigger} + + + + + + {(job.totalInputTokens + job.totalOutputTokens).toLocaleString()} + + + + + + {formatRelativeDate(job.createdAt)} + + + + {job.retryOfJobId && ( + + + {job.retryOfJobId.slice(0, 8)} + + + )} +
+ +
+ + + {steps.length === 0 ? ( +

No steps recorded yet.

+ ) : ( + steps.map((step) => ) + )} +
+
+ ); +} diff --git a/src/client/components/features/job-detail/job-progress.tsx b/apps/dashboard/src/components/features/job-detail/job-progress.tsx similarity index 97% rename from src/client/components/features/job-detail/job-progress.tsx rename to apps/dashboard/src/components/features/job-detail/job-progress.tsx index d5dd095c..da8f1f24 100644 --- a/src/client/components/features/job-detail/job-progress.tsx +++ b/apps/dashboard/src/components/features/job-detail/job-progress.tsx @@ -1,76 +1,76 @@ -import { FileCode2, Hourglass } from 'lucide-react'; -import type { JobDetail } from '@codraoss/schema'; - -interface JobProgressProps { - job: JobDetail; -} - -export function JobProgress({ job }: JobProgressProps) { - if (job.status !== 'running' && job.status !== 'queued') return null; - - const finishedCount = job.files.filter(f => f.fileStatus === 'done' || f.fileStatus === 'skipped').length; - const total = job.fileCount || 0; - const pct = total > 0 ? Math.round((finishedCount / total) * 100) : 0; - const isQueued = job.status === 'queued'; - - const activeFile = job.files.find(f => f.fileStatus === 'pending'); - const activeFilePath = activeFile?.filePath ?? null; - - const displayPath = activeFilePath - ? activeFilePath.split('/').slice(-2).join('/') - : null; - const prefixPath = activeFilePath && activeFilePath.includes('/') - ? activeFilePath.split('/').slice(0, -2).join('/') + '/' - : null; - - return ( -
-
-
- {isQueued - ? - : - } - - {isQueued ? 'Waiting in queue' : 'Reviewing files'} - -
- - {isQueued ? '-' : `${finishedCount} / ${total}`} - -
- - {/* Recessed inner panel, same as the dashboard stat cards. */} -
-
-
-
- - {!isQueued && ( -
-
- {prefixPath && ( - {prefixPath} - )} - {displayPath - ? {displayPath} - : {Math.max(total - finishedCount, 0)} {total - finishedCount === 1 ? 'file' : 'files'} remaining - } -
- {pct}% -
- )} -
-
- ); -} +import { FileCode2, Hourglass } from 'lucide-react'; +import type { JobDetail } from '@codraoss/schema'; + +interface JobProgressProps { + job: JobDetail; +} + +export function JobProgress({ job }: JobProgressProps) { + if (job.status !== 'running' && job.status !== 'queued') return null; + + const finishedCount = job.files.filter(f => f.fileStatus === 'done' || f.fileStatus === 'skipped').length; + const total = job.fileCount || 0; + const pct = total > 0 ? Math.round((finishedCount / total) * 100) : 0; + const isQueued = job.status === 'queued'; + + const activeFile = job.files.find(f => f.fileStatus === 'pending'); + const activeFilePath = activeFile?.filePath ?? null; + + const displayPath = activeFilePath + ? activeFilePath.split('/').slice(-2).join('/') + : null; + const prefixPath = activeFilePath && activeFilePath.includes('/') + ? activeFilePath.split('/').slice(0, -2).join('/') + '/' + : null; + + return ( +
+
+
+ {isQueued + ? + : + } + + {isQueued ? 'Waiting in queue' : 'Reviewing files'} + +
+ + {isQueued ? '-' : `${finishedCount} / ${total}`} + +
+ + {/* Recessed inner panel, same as the dashboard stat cards. */} +
+
+
+
+ + {!isQueued && ( +
+
+ {prefixPath && ( + {prefixPath} + )} + {displayPath + ? {displayPath} + : {Math.max(total - finishedCount, 0)} {total - finishedCount === 1 ? 'file' : 'files'} remaining + } +
+ {pct}% +
+ )} +
+
+ ); +} diff --git a/src/client/components/features/job-detail/job-review-overview.tsx b/apps/dashboard/src/components/features/job-detail/job-review-overview.tsx similarity index 98% rename from src/client/components/features/job-detail/job-review-overview.tsx rename to apps/dashboard/src/components/features/job-detail/job-review-overview.tsx index 1acebd31..ce475570 100644 --- a/src/client/components/features/job-detail/job-review-overview.tsx +++ b/apps/dashboard/src/components/features/job-detail/job-review-overview.tsx @@ -1,103 +1,103 @@ -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import { CheckCircle2, ClipboardList, TriangleAlert } from 'lucide-react'; -import type { JobDetail } from '@codraoss/schema'; -import { reviewSeverities } from '@codraoss/schema/review-limits'; -import { OutlinePill } from './job-chips'; - -import { safeRehypePlugins } from '@codraoss/ui/markdown-plugins'; -interface JobReviewOverviewProps { - job: JobDetail; -} - -export function JobReviewOverview({ job }: JobReviewOverviewProps) { - const hasOverview = !!(job.summaryMarkdown || job.overallCorrectness || (job.overallConfidenceScore !== undefined && job.overallConfidenceScore !== null)); - if (!hasOverview) return null; - - const allComments = job.files.flatMap((f) => f.parsedComments); - const sevCounts = Object.fromEntries( - reviewSeverities.map((s) => [s, allComments.filter((c) => c.severity === s).length]), - ); - - const renderSummary = () => { - if (!job.summaryMarkdown) return ''; - const content = job.summaryMarkdown.replace(/^(✅ \*\*Approved\*\*|💬 \*\*Comments posted\*\*)\n\n/, '').trim(); - - // Strip only the "### ... Codra Review" heading, keep the intro sentence - const stripHeader = (md: string) => md - .replace(/^###\s*([\s\S]*?<\/picture>|💡)\s*Codra Review\s*\n+/, '') - .trim(); - - if (content.startsWith('### 💡 Codra Review') || content.includes('Codra Review')) { - return stripHeader(content); - } - - const shortSha = job.commitSha.slice(0, 10); - const baseUrl = typeof window !== 'undefined' ? window.location.origin : ''; - - return `Here are some automated review suggestions for this pull request.\n\n**Reviewed commit:** \`${shortSha}\`\n\n
\nℹ️ About Codra\n\n
\n\n[Your team has set up Codra to review pull requests in this repo](${baseUrl}/repos). Reviews are triggered when you:\n\n- **Open** a pull request for review\n- **Mark** a draft as ready\n- **Comment** "@codra-app review"\n\nIf Codra has suggestions, it will comment; otherwise it will react with 👍.\n\nCodra can also answer questions or update the PR. Try commenting "@codra-app address that feedback".\n\n
\n\n---\n\n${content}`; - }; - - return ( -
-
-
- -

Review overview

-
- {/* Correctness and confidence read as chips: neutral border, colour only in the leading icon. */} -
- {job.overallCorrectness && (() => { - const incorrect = job.overallCorrectness.toLowerCase().includes('incorrect'); - return ( - - {job.overallCorrectness} - - ); - })()} - {(job.overallConfidenceScore !== undefined && job.overallConfidenceScore !== null) && ( - - Confidence - - {(Number(job.overallConfidenceScore) * 100).toFixed(0)}% - - - )} -
-
- - {/* Recessed inner panel (same as the dashboard stat cards); markdown's own leading/trailing - block margins are zeroed so the well padding alone controls the gap. */} -
-
- - {renderSummary()} - -
-
- - {/* Footer sits on the card face, mirroring the stat cards' delta row. */} -
-
-

Priority triage

- {reviewSeverities.map((sev) => { - const count = sevCounts[sev] || 0; - if (count === 0 && sev !== 'nit') return null; - - return ( -
- {sev} - - {count} - -
- ); - })} -
-
-
- ); -} +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { CheckCircle2, ClipboardList, TriangleAlert } from 'lucide-react'; +import type { JobDetail } from '@codraoss/schema'; +import { reviewSeverities } from '@codraoss/schema/review-limits'; +import { OutlinePill } from './job-chips'; + +import { safeRehypePlugins } from '@codraoss/ui/markdown-plugins'; +interface JobReviewOverviewProps { + job: JobDetail; +} + +export function JobReviewOverview({ job }: JobReviewOverviewProps) { + const hasOverview = !!(job.summaryMarkdown || job.overallCorrectness || (job.overallConfidenceScore !== undefined && job.overallConfidenceScore !== null)); + if (!hasOverview) return null; + + const allComments = job.files.flatMap((f) => f.parsedComments); + const sevCounts = Object.fromEntries( + reviewSeverities.map((s) => [s, allComments.filter((c) => c.severity === s).length]), + ); + + const renderSummary = () => { + if (!job.summaryMarkdown) return ''; + const content = job.summaryMarkdown.replace(/^(✅ \*\*Approved\*\*|💬 \*\*Comments posted\*\*)\n\n/, '').trim(); + + // Strip only the "### ... Codra Review" heading, keep the intro sentence + const stripHeader = (md: string) => md + .replace(/^###\s*([\s\S]*?<\/picture>|💡)\s*Codra Review\s*\n+/, '') + .trim(); + + if (content.startsWith('### 💡 Codra Review') || content.includes('Codra Review')) { + return stripHeader(content); + } + + const shortSha = job.commitSha.slice(0, 10); + const baseUrl = typeof window !== 'undefined' ? window.location.origin : ''; + + return `Here are some automated review suggestions for this pull request.\n\n**Reviewed commit:** \`${shortSha}\`\n\n
\nℹ️ About Codra\n\n
\n\n[Your team has set up Codra to review pull requests in this repo](${baseUrl}/repos). Reviews are triggered when you:\n\n- **Open** a pull request for review\n- **Mark** a draft as ready\n- **Comment** "@codra-app review"\n\nIf Codra has suggestions, it will comment; otherwise it will react with 👍.\n\nCodra can also answer questions or update the PR. Try commenting "@codra-app address that feedback".\n\n
\n\n---\n\n${content}`; + }; + + return ( +
+
+
+ +

Review overview

+
+ {/* Correctness and confidence read as chips: neutral border, colour only in the leading icon. */} +
+ {job.overallCorrectness && (() => { + const incorrect = job.overallCorrectness.toLowerCase().includes('incorrect'); + return ( + + {job.overallCorrectness} + + ); + })()} + {(job.overallConfidenceScore !== undefined && job.overallConfidenceScore !== null) && ( + + Confidence + + {(Number(job.overallConfidenceScore) * 100).toFixed(0)}% + + + )} +
+
+ + {/* Recessed inner panel (same as the dashboard stat cards); markdown's own leading/trailing + block margins are zeroed so the well padding alone controls the gap. */} +
+
+ + {renderSummary()} + +
+
+ + {/* Footer sits on the card face, mirroring the stat cards' delta row. */} +
+
+

Priority triage

+ {reviewSeverities.map((sev) => { + const count = sevCounts[sev] || 0; + if (count === 0 && sev !== 'nit') return null; + + return ( +
+ {sev} + + {count} + +
+ ); + })} +
+
+
+ ); +} diff --git a/src/client/components/features/job-detail/job-skeleton.tsx b/apps/dashboard/src/components/features/job-detail/job-skeleton.tsx similarity index 100% rename from src/client/components/features/job-detail/job-skeleton.tsx rename to apps/dashboard/src/components/features/job-detail/job-skeleton.tsx diff --git a/src/client/components/features/job-detail/job-status-notice.tsx b/apps/dashboard/src/components/features/job-detail/job-status-notice.tsx similarity index 100% rename from src/client/components/features/job-detail/job-status-notice.tsx rename to apps/dashboard/src/components/features/job-detail/job-status-notice.tsx diff --git a/src/client/components/features/job-detail/status-badge.tsx b/apps/dashboard/src/components/features/job-detail/status-badge.tsx similarity index 100% rename from src/client/components/features/job-detail/status-badge.tsx rename to apps/dashboard/src/components/features/job-detail/status-badge.tsx diff --git a/src/client/components/features/models/model-chain.tsx b/apps/dashboard/src/components/features/models/model-chain.tsx similarity index 100% rename from src/client/components/features/models/model-chain.tsx rename to apps/dashboard/src/components/features/models/model-chain.tsx diff --git a/src/client/components/features/models/model-route.ts b/apps/dashboard/src/components/features/models/model-route.ts similarity index 100% rename from src/client/components/features/models/model-route.ts rename to apps/dashboard/src/components/features/models/model-route.ts diff --git a/src/client/components/features/repos/repo-model-modal.tsx b/apps/dashboard/src/components/features/repos/repo-model-modal.tsx similarity index 100% rename from src/client/components/features/repos/repo-model-modal.tsx rename to apps/dashboard/src/components/features/repos/repo-model-modal.tsx diff --git a/src/client/components/features/repos/repo-route.ts b/apps/dashboard/src/components/features/repos/repo-route.ts similarity index 100% rename from src/client/components/features/repos/repo-route.ts rename to apps/dashboard/src/components/features/repos/repo-route.ts diff --git a/src/client/components/features/repos/repo-row.tsx b/apps/dashboard/src/components/features/repos/repo-row.tsx similarity index 100% rename from src/client/components/features/repos/repo-row.tsx rename to apps/dashboard/src/components/features/repos/repo-row.tsx diff --git a/src/client/components/features/reviews/live-review-stepper.tsx b/apps/dashboard/src/components/features/reviews/live-review-stepper.tsx similarity index 100% rename from src/client/components/features/reviews/live-review-stepper.tsx rename to apps/dashboard/src/components/features/reviews/live-review-stepper.tsx diff --git a/src/client/components/features/settings/about-section.tsx b/apps/dashboard/src/components/features/settings/about-section.tsx similarity index 97% rename from src/client/components/features/settings/about-section.tsx rename to apps/dashboard/src/components/features/settings/about-section.tsx index 9e9e32d0..65b15b61 100644 --- a/src/client/components/features/settings/about-section.tsx +++ b/apps/dashboard/src/components/features/settings/about-section.tsx @@ -1,5 +1,5 @@ import { Badge, LayerCard, SectionCard, Text } from '@codraoss/ui'; -import pkg from '../../../../../package.json'; +import pkg from '../../../../../../package.json'; import { ExternalLink } from 'lucide-react'; // No props and no state, which is why this is a component rather than inlined JSX: it keeps 50 lines of markup out of SettingsPage. diff --git a/src/client/components/features/settings/default-models-section.tsx b/apps/dashboard/src/components/features/settings/default-models-section.tsx similarity index 100% rename from src/client/components/features/settings/default-models-section.tsx rename to apps/dashboard/src/components/features/settings/default-models-section.tsx diff --git a/src/client/components/features/settings/field-label.tsx b/apps/dashboard/src/components/features/settings/field-label.tsx similarity index 100% rename from src/client/components/features/settings/field-label.tsx rename to apps/dashboard/src/components/features/settings/field-label.tsx diff --git a/src/client/components/features/settings/new-provider-form.tsx b/apps/dashboard/src/components/features/settings/new-provider-form.tsx similarity index 100% rename from src/client/components/features/settings/new-provider-form.tsx rename to apps/dashboard/src/components/features/settings/new-provider-form.tsx diff --git a/src/client/components/features/settings/provider-list.tsx b/apps/dashboard/src/components/features/settings/provider-list.tsx similarity index 100% rename from src/client/components/features/settings/provider-list.tsx rename to apps/dashboard/src/components/features/settings/provider-list.tsx diff --git a/src/client/components/features/settings/provider-row.tsx b/apps/dashboard/src/components/features/settings/provider-row.tsx similarity index 100% rename from src/client/components/features/settings/provider-row.tsx rename to apps/dashboard/src/components/features/settings/provider-row.tsx diff --git a/src/client/components/features/settings/review-section.tsx b/apps/dashboard/src/components/features/settings/review-section.tsx similarity index 100% rename from src/client/components/features/settings/review-section.tsx rename to apps/dashboard/src/components/features/settings/review-section.tsx diff --git a/src/client/components/features/settings/settings-support.ts b/apps/dashboard/src/components/features/settings/settings-support.ts similarity index 100% rename from src/client/components/features/settings/settings-support.ts rename to apps/dashboard/src/components/features/settings/settings-support.ts diff --git a/src/client/components/features/stats/chart-primitives.tsx b/apps/dashboard/src/components/features/stats/chart-primitives.tsx similarity index 97% rename from src/client/components/features/stats/chart-primitives.tsx rename to apps/dashboard/src/components/features/stats/chart-primitives.tsx index a8524d1c..1264b3ee 100644 --- a/src/client/components/features/stats/chart-primitives.tsx +++ b/apps/dashboard/src/components/features/stats/chart-primitives.tsx @@ -1,88 +1,88 @@ -import { Skeleton, GraphShell, SeriesMarker, type SeriesMarkerProps } from '@codraoss/ui'; -import type { ReactNode } from 'react'; -import { Activity, Boxes, Coins, FolderGit2, ShieldCheck } from 'lucide-react'; -import { formatCompact, formatDayRange } from './chart-support'; - -/** Per-`dataKey` marker description, so the tooltip can draw exactly what the legend drew. */ -export type SeriesMarkers = Record; - -/** - * Recharts reports a series' raw `fill`, so gradient- and pattern-backed bars arrive as `url(#id)`, - * which is not a CSS colour - assigning it to `background-color` renders nothing at all. The - * caller's `markers` map is the source of truth; this only covers series it doesn't describe. - */ -function fallbackMarker(color: string | undefined): SeriesMarkerProps { - if (!color || color.startsWith('url(')) return { color: 'currentColor' }; - return { color }; -} - -export function ChartTooltip({ active, payload, label, markers }: any) { - if (!active || !payload?.length) return null; - - const endDay: string | undefined = payload[0]?.payload?.endDay; - const heading = - typeof label === 'string' && label.includes('-') ? formatDayRange(label, endDay) : label; - - return ( -
- {label &&

{heading}

} -
- {payload.map((item: any) => ( -
- - {item.name} - - {typeof item.value === 'number' ? formatCompact(item.value) : item.value} - -
- ))} -
-
- ); -} - -function GraphCardSkeleton({ title, icon, className = '' }: { title: string; icon?: ReactNode; className?: string }) { - return ( - -
- -
-
- ); -} - -function GraphBarCardSkeleton({ title, icon, rows = 5, className = '' }: { title: string; icon?: ReactNode; rows?: number; className?: string }) { - return ( - -
- {Array.from({ length: rows }).map((_, i) => ( -
- - - -
- ))} -
-
- ); -} - -/** Rows only - `MetricsGrid` owns the outer wrapper so the skeleton/chart handoff isn't animated. */ -export function MetricsGridSkeleton() { - return ( - <> -
- } /> - } /> -
-
- } /> - } rows={4} /> - } rows={5} /> -
- - ); -} +import { Skeleton, GraphShell, SeriesMarker, type SeriesMarkerProps } from '@codraoss/ui'; +import type { ReactNode } from 'react'; +import { Activity, Boxes, Coins, FolderGit2, ShieldCheck } from 'lucide-react'; +import { formatCompact, formatDayRange } from './chart-support'; + +/** Per-`dataKey` marker description, so the tooltip can draw exactly what the legend drew. */ +export type SeriesMarkers = Record; + +/** + * Recharts reports a series' raw `fill`, so gradient- and pattern-backed bars arrive as `url(#id)`, + * which is not a CSS colour - assigning it to `background-color` renders nothing at all. The + * caller's `markers` map is the source of truth; this only covers series it doesn't describe. + */ +function fallbackMarker(color: string | undefined): SeriesMarkerProps { + if (!color || color.startsWith('url(')) return { color: 'currentColor' }; + return { color }; +} + +export function ChartTooltip({ active, payload, label, markers }: any) { + if (!active || !payload?.length) return null; + + const endDay: string | undefined = payload[0]?.payload?.endDay; + const heading = + typeof label === 'string' && label.includes('-') ? formatDayRange(label, endDay) : label; + + return ( +
+ {label &&

{heading}

} +
+ {payload.map((item: any) => ( +
+ + {item.name} + + {typeof item.value === 'number' ? formatCompact(item.value) : item.value} + +
+ ))} +
+
+ ); +} + +function GraphCardSkeleton({ title, icon, className = '' }: { title: string; icon?: ReactNode; className?: string }) { + return ( + +
+ +
+
+ ); +} + +function GraphBarCardSkeleton({ title, icon, rows = 5, className = '' }: { title: string; icon?: ReactNode; rows?: number; className?: string }) { + return ( + +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ ); +} + +/** Rows only - `MetricsGrid` owns the outer wrapper so the skeleton/chart handoff isn't animated. */ +export function MetricsGridSkeleton() { + return ( + <> +
+ } /> + } /> +
+
+ } /> + } rows={4} /> + } rows={5} /> +
+ + ); +} diff --git a/src/client/components/features/stats/chart-support.ts b/apps/dashboard/src/components/features/stats/chart-support.ts similarity index 100% rename from src/client/components/features/stats/chart-support.ts rename to apps/dashboard/src/components/features/stats/chart-support.ts diff --git a/src/client/components/features/stats/metrics-grid-charts.tsx b/apps/dashboard/src/components/features/stats/metrics-grid-charts.tsx similarity index 97% rename from src/client/components/features/stats/metrics-grid-charts.tsx rename to apps/dashboard/src/components/features/stats/metrics-grid-charts.tsx index ceadc9f0..a8c8d40c 100644 --- a/src/client/components/features/stats/metrics-grid-charts.tsx +++ b/apps/dashboard/src/components/features/stats/metrics-grid-charts.tsx @@ -1,260 +1,260 @@ -import { - Area, - AreaChart, - Bar, - BarChart, - CartesianGrid, - Cell, - Pie, - PieChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; -import { Activity, Boxes, Coins, FolderGit2, ShieldCheck } from 'lucide-react'; -import type { StatsPayload } from '@codraoss/schema'; -import { - ChartTooltip, - type SeriesMarkers -} from './chart-primitives'; -import { - GraphShell, - LegendChip, - ChartDefs, - MeterList, - TickMeter -} from '@codraoss/ui'; -import { - CHART, - MONO_STACK, - TICK_COLORS_DARK, - TICK_COLORS_LIGHT, - formatCompact, - formatDay, - modelName, -} from './chart-support'; - -// `equidistantPreserveStart` drops labels on a fixed stride (every 2nd, every 3rd, ...) sized to the -// available width, so the dates stay evenly spaced instead of jumping by uneven gaps. -const X_AXIS_PROPS = { - dataKey: 'day', - tickFormatter: formatDay, - interval: 'equidistantPreserveStart' as const, - minTickGap: 12, -}; - -export function MetricsGridCharts({ - stats, - isDark, -}: { - stats: StatsPayload; - isDark: boolean; -}) { - const lime = isDark ? CHART.primaryDark : CHART.primary; - const amber = isDark ? CHART.amberDark : CHART.amber; - const dangerColor = isDark ? CHART.dangerDark : CHART.danger; - const infoColor = isDark ? CHART.infoDark : CHART.info; - const quietColor = isDark ? CHART.quietDark : CHART.quiet; - const dashColor = isDark ? 'rgba(228,228,231,0.75)' : 'rgba(63,63,70,0.65)'; - const tickColors = isDark ? TICK_COLORS_DARK : TICK_COLORS_LIGHT; - // Long ranges arrive pre-combined into multi-day buckets; say so, since each point is a sum, not a day. - const bucketDays = stats.trendBucketDays ?? 1; - const bucketNote = bucketDays > 1 ? {bucketDays}-day totals : null; - const repoMax = Math.max(...stats.topRepos.map((repo) => repo.jobs), 1); - const modelMax = Math.max(...stats.models.map((model) => model.calls), 1); - - // CSS variables don't reliably resolve inside Recharts SVG text, so colors are keyed off the active theme explicitly. - const axisColor = isDark ? 'rgba(228,228,231,0.55)' : 'rgba(63,63,70,0.7)'; - const gridColor = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'; - const cursorColor = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'; - const axisProps = { - fontSize: 10, - tickLine: false, - tickMargin: 8, - axisLine: false, - tick: { fontFamily: MONO_STACK, fill: axisColor }, - } as const; - - const STATUS_COLOR: Record = { - done: lime, - running: infoColor, - queued: quietColor, - failed: dangerColor, - superseded: quietColor, - cancelled: quietColor, - }; - const statusTotal = Math.max(stats.statuses.reduce((sum, s) => sum + s.count, 0), 1); - - // One description per series, feeding both the legend chip and the tooltip swatch, so the two - // can't drift apart. Keyed by `dataKey`, which is what Recharts reports back on hover. - const flowMarkers: SeriesMarkers = { - jobs: { color: amber }, - comments: { color: dashColor, dashed: true }, - }; - const tokenMarkers: SeriesMarkers = { - outputTokens: { color: CHART.blue }, - inputTokens: { hatched: true }, - }; - - return ( - // Rows only: `MetricsGrid` owns the outer wrapper. See the note there. - <> -
- } - legend={ - <> - - - {bucketNote} - - } - > -
- - - - - - - } cursor={{ stroke: amber, strokeDasharray: '4 4' }} /> - - - - -
-
- - } - legend={ - <> - - - {bucketNote} - - } - > -
- - - - - - - } cursor={{ fill: cursorColor }} /> - {/* Capped so a short range (or a heavily bucketed one) doesn't render a handful of slab-wide bars. */} - - - - -
-
-
- -
- }> -
-
- - - - {stats.statuses.map((s) => ( - - ))} - - - -
- - {formatCompact(statusTotal)} - - Jobs -
-
- -
- {stats.statuses.map((s) => ( -
- - - {s.status} - - - {s.count} - ({Math.round((s.count / statusTotal) * 100)}%) - -
- ))} -
-
-
- - }> - - {stats.topRepos.map((repo, i) => ( - - ))} - - - - }> - - {stats.models.map((model, i) => ( - - ))} - - -
- - ); -} +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Cell, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { Activity, Boxes, Coins, FolderGit2, ShieldCheck } from 'lucide-react'; +import type { StatsPayload } from '@codraoss/schema'; +import { + ChartTooltip, + type SeriesMarkers +} from './chart-primitives'; +import { + GraphShell, + LegendChip, + ChartDefs, + MeterList, + TickMeter +} from '@codraoss/ui'; +import { + CHART, + MONO_STACK, + TICK_COLORS_DARK, + TICK_COLORS_LIGHT, + formatCompact, + formatDay, + modelName, +} from './chart-support'; + +// `equidistantPreserveStart` drops labels on a fixed stride (every 2nd, every 3rd, ...) sized to the +// available width, so the dates stay evenly spaced instead of jumping by uneven gaps. +const X_AXIS_PROPS = { + dataKey: 'day', + tickFormatter: formatDay, + interval: 'equidistantPreserveStart' as const, + minTickGap: 12, +}; + +export function MetricsGridCharts({ + stats, + isDark, +}: { + stats: StatsPayload; + isDark: boolean; +}) { + const lime = isDark ? CHART.primaryDark : CHART.primary; + const amber = isDark ? CHART.amberDark : CHART.amber; + const dangerColor = isDark ? CHART.dangerDark : CHART.danger; + const infoColor = isDark ? CHART.infoDark : CHART.info; + const quietColor = isDark ? CHART.quietDark : CHART.quiet; + const dashColor = isDark ? 'rgba(228,228,231,0.75)' : 'rgba(63,63,70,0.65)'; + const tickColors = isDark ? TICK_COLORS_DARK : TICK_COLORS_LIGHT; + // Long ranges arrive pre-combined into multi-day buckets; say so, since each point is a sum, not a day. + const bucketDays = stats.trendBucketDays ?? 1; + const bucketNote = bucketDays > 1 ? {bucketDays}-day totals : null; + const repoMax = Math.max(...stats.topRepos.map((repo) => repo.jobs), 1); + const modelMax = Math.max(...stats.models.map((model) => model.calls), 1); + + // CSS variables don't reliably resolve inside Recharts SVG text, so colors are keyed off the active theme explicitly. + const axisColor = isDark ? 'rgba(228,228,231,0.55)' : 'rgba(63,63,70,0.7)'; + const gridColor = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'; + const cursorColor = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'; + const axisProps = { + fontSize: 10, + tickLine: false, + tickMargin: 8, + axisLine: false, + tick: { fontFamily: MONO_STACK, fill: axisColor }, + } as const; + + const STATUS_COLOR: Record = { + done: lime, + running: infoColor, + queued: quietColor, + failed: dangerColor, + superseded: quietColor, + cancelled: quietColor, + }; + const statusTotal = Math.max(stats.statuses.reduce((sum, s) => sum + s.count, 0), 1); + + // One description per series, feeding both the legend chip and the tooltip swatch, so the two + // can't drift apart. Keyed by `dataKey`, which is what Recharts reports back on hover. + const flowMarkers: SeriesMarkers = { + jobs: { color: amber }, + comments: { color: dashColor, dashed: true }, + }; + const tokenMarkers: SeriesMarkers = { + outputTokens: { color: CHART.blue }, + inputTokens: { hatched: true }, + }; + + return ( + // Rows only: `MetricsGrid` owns the outer wrapper. See the note there. + <> +
+ } + legend={ + <> + + + {bucketNote} + + } + > +
+ + + + + + + } cursor={{ stroke: amber, strokeDasharray: '4 4' }} /> + + + + +
+
+ + } + legend={ + <> + + + {bucketNote} + + } + > +
+ + + + + + + } cursor={{ fill: cursorColor }} /> + {/* Capped so a short range (or a heavily bucketed one) doesn't render a handful of slab-wide bars. */} + + + + +
+
+
+ +
+ }> +
+
+ + + + {stats.statuses.map((s) => ( + + ))} + + + +
+ + {formatCompact(statusTotal)} + + Jobs +
+
+ +
+ {stats.statuses.map((s) => ( +
+ + + {s.status} + + + {s.count} + ({Math.round((s.count / statusTotal) * 100)}%) + +
+ ))} +
+
+
+ + }> + + {stats.topRepos.map((repo, i) => ( + + ))} + + + + }> + + {stats.models.map((model, i) => ( + + ))} + + +
+ + ); +} diff --git a/src/client/components/features/stats/metrics-grid-prefetch.ts b/apps/dashboard/src/components/features/stats/metrics-grid-prefetch.ts similarity index 100% rename from src/client/components/features/stats/metrics-grid-prefetch.ts rename to apps/dashboard/src/components/features/stats/metrics-grid-prefetch.ts diff --git a/src/client/components/features/stats/metrics-grid.tsx b/apps/dashboard/src/components/features/stats/metrics-grid.tsx similarity index 97% rename from src/client/components/features/stats/metrics-grid.tsx rename to apps/dashboard/src/components/features/stats/metrics-grid.tsx index e349a498..e9e2cc46 100644 --- a/src/client/components/features/stats/metrics-grid.tsx +++ b/apps/dashboard/src/components/features/stats/metrics-grid.tsx @@ -1,48 +1,48 @@ -import { useEffect, useState } from 'react'; -import type { StatsPayload } from '@codraoss/schema'; -import { MetricsGridSkeleton } from './chart-primitives'; -import { loadMetricsCharts, metricsChartsIfLoaded } from './metrics-grid-prefetch'; - -/** - * Owns the whole loading state - the chart chunk *and* the data - so the skeleton is one element in - * one tree position for the entire wait. - * - * This deliberately avoids `lazy` + `Suspense`: with a fallback, the skeleton renders from a second - * position, so the handoff between "no data yet" and "chunk still downloading" unmounts one - * skeleton and mounts another. Identical markup, but React sees a new element - restarting the - * shimmer and replaying the parent's `page-enter` fade-up, which reads as the cards refreshing - * twice before any content arrives. - */ -export function MetricsGrid({ - stats, - isDark, -}: { - stats: StatsPayload | null; - isDark: boolean; -}) { - const [Charts, setCharts] = useState>( - metricsChartsIfLoaded, - ); - - useEffect(() => { - if (Charts) return; - let active = true; - // Component values are functions, so the updater has to return one rather than be one. - void loadMetricsCharts().then((loaded) => { - if (active) setCharts(() => loaded); - }); - return () => { - active = false; - }; - }, [Charts]); - - // The wrapper is what `page-enter` animates (it's the section's direct child), so it stays - // mounted across the handoff: the skeleton fades up once, then the real cards simply replace it - // in place. Returning the skeleton and the charts as siblings-of-different-shape would mount a - // new direct child and replay the fade-up, which read as the page animating twice. - return ( -
- {!Charts || !stats ? : } -
- ); -} +import { useEffect, useState } from 'react'; +import type { StatsPayload } from '@codraoss/schema'; +import { MetricsGridSkeleton } from './chart-primitives'; +import { loadMetricsCharts, metricsChartsIfLoaded } from './metrics-grid-prefetch'; + +/** + * Owns the whole loading state - the chart chunk *and* the data - so the skeleton is one element in + * one tree position for the entire wait. + * + * This deliberately avoids `lazy` + `Suspense`: with a fallback, the skeleton renders from a second + * position, so the handoff between "no data yet" and "chunk still downloading" unmounts one + * skeleton and mounts another. Identical markup, but React sees a new element - restarting the + * shimmer and replaying the parent's `page-enter` fade-up, which reads as the cards refreshing + * twice before any content arrives. + */ +export function MetricsGrid({ + stats, + isDark, +}: { + stats: StatsPayload | null; + isDark: boolean; +}) { + const [Charts, setCharts] = useState>( + metricsChartsIfLoaded, + ); + + useEffect(() => { + if (Charts) return; + let active = true; + // Component values are functions, so the updater has to return one rather than be one. + void loadMetricsCharts().then((loaded) => { + if (active) setCharts(() => loaded); + }); + return () => { + active = false; + }; + }, [Charts]); + + // The wrapper is what `page-enter` animates (it's the section's direct child), so it stays + // mounted across the handoff: the skeleton fades up once, then the real cards simply replace it + // in place. Returning the skeleton and the charts as siblings-of-different-shape would mount a + // new direct child and replay the fade-up, which read as the page animating twice. + return ( +
+ {!Charts || !stats ? : } +
+ ); +} diff --git a/src/client/components/features/stats/overview-stats.tsx b/apps/dashboard/src/components/features/stats/overview-stats.tsx similarity index 100% rename from src/client/components/features/stats/overview-stats.tsx rename to apps/dashboard/src/components/features/stats/overview-stats.tsx diff --git a/src/client/components/features/stats/stats-grid.tsx b/apps/dashboard/src/components/features/stats/stats-grid.tsx similarity index 97% rename from src/client/components/features/stats/stats-grid.tsx rename to apps/dashboard/src/components/features/stats/stats-grid.tsx index 55c6a16c..cb73a0e2 100644 --- a/src/client/components/features/stats/stats-grid.tsx +++ b/apps/dashboard/src/components/features/stats/stats-grid.tsx @@ -1,135 +1,135 @@ -import { BarSparkline, Skeleton } from '@codraoss/ui'; -import * as React from 'react'; -import { cn } from '@codraoss/ui/utils'; -import type { LucideIcon } from 'lucide-react'; - -export interface StatDelta { - /** Signed percentage change vs. the previous period. */ - pct: number; - direction: 'up' | 'down' | 'flat'; -} - -export interface StatsItem { - label: string; - /** Numeric part of the value (already formatted); null while loading. */ - value: string | null; - /** Unit suffix rendered smaller next to the value (e.g. "k", "M"). */ - unit?: string; - icon: LucideIcon; - /** Accent color (hex) for the sparkline bars. */ - color: string; - /** Short noun for the footer, e.g. "Reviews Increased by …". */ - noun?: string; - trend?: number[]; - delta?: StatDelta | null; -} - -interface StatsGridProps extends React.HTMLAttributes { - items: StatsItem[]; -} - -/** - * KPI cards: ui-* surface/text tokens, system font stack, mono numerals, and a - * nested value panel with a bar sparkline. - */ -export function StatsGrid({ items, className, ...props }: StatsGridProps) { - return ( -
- {items.map((item) => ( - - ))} -
- ); -} - -function StatCard({ label, value, unit, icon: Icon, color, noun, trend, delta }: StatsItem) { - const loading = value === null; - - return ( -
-
- - {label} -
- -
- {loading ? ( - - ) : ( -

- - {value} - - {unit && {unit}} -

- )} - - {loading ? ( - - ) : ( - trend && - )} -
- - {/* Footer: "Reviews Increased by" ..... ▲ +15% vs prev. period */} - -
- ); -} - -function StatFooter({ - delta, - loading, - noun, -}: { - delta?: StatDelta | null; - loading: boolean; - noun?: string; -}) { - if (loading) { - return ( - // h-7 == pt-3 + the loaded row's 16px text-xs line box. Without it the card is 4px shorter - // while loading, shifting everything below it (and the dashboard's row-fitting measurement). -
- - -
- ); - } - - const flat = !delta || delta.direction === 'flat'; - const up = delta?.direction === 'up'; - const toneClass = flat - ? 'text-ui-subtle' - : up - ? 'text-emerald-600 dark:text-emerald-400' - : 'text-red-600 dark:text-red-400'; - const prefix = noun ?? 'Value'; - const label = flat ? `${prefix} unchanged` : `${prefix} ${up ? 'Increased' : 'Decreased'} by`; - - return ( -
- {label} - - {!flat && ( - <> - - {up ? '▲' : '▼'} - - - {up ? '+' : '-'} - {Math.abs(delta!.pct)}% - - - )} - vs prev. period - -
- ); -} +import { BarSparkline, Skeleton } from '@codraoss/ui'; +import * as React from 'react'; +import { cn } from '@codraoss/ui/utils'; +import type { LucideIcon } from 'lucide-react'; + +export interface StatDelta { + /** Signed percentage change vs. the previous period. */ + pct: number; + direction: 'up' | 'down' | 'flat'; +} + +export interface StatsItem { + label: string; + /** Numeric part of the value (already formatted); null while loading. */ + value: string | null; + /** Unit suffix rendered smaller next to the value (e.g. "k", "M"). */ + unit?: string; + icon: LucideIcon; + /** Accent color (hex) for the sparkline bars. */ + color: string; + /** Short noun for the footer, e.g. "Reviews Increased by …". */ + noun?: string; + trend?: number[]; + delta?: StatDelta | null; +} + +interface StatsGridProps extends React.HTMLAttributes { + items: StatsItem[]; +} + +/** + * KPI cards: ui-* surface/text tokens, system font stack, mono numerals, and a + * nested value panel with a bar sparkline. + */ +export function StatsGrid({ items, className, ...props }: StatsGridProps) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +function StatCard({ label, value, unit, icon: Icon, color, noun, trend, delta }: StatsItem) { + const loading = value === null; + + return ( +
+
+ + {label} +
+ +
+ {loading ? ( + + ) : ( +

+ + {value} + + {unit && {unit}} +

+ )} + + {loading ? ( + + ) : ( + trend && + )} +
+ + {/* Footer: "Reviews Increased by" ..... ▲ +15% vs prev. period */} + +
+ ); +} + +function StatFooter({ + delta, + loading, + noun, +}: { + delta?: StatDelta | null; + loading: boolean; + noun?: string; +}) { + if (loading) { + return ( + // h-7 == pt-3 + the loaded row's 16px text-xs line box. Without it the card is 4px shorter + // while loading, shifting everything below it (and the dashboard's row-fitting measurement). +
+ + +
+ ); + } + + const flat = !delta || delta.direction === 'flat'; + const up = delta?.direction === 'up'; + const toneClass = flat + ? 'text-ui-subtle' + : up + ? 'text-emerald-600 dark:text-emerald-400' + : 'text-red-600 dark:text-red-400'; + const prefix = noun ?? 'Value'; + const label = flat ? `${prefix} unchanged` : `${prefix} ${up ? 'Increased' : 'Decreased'} by`; + + return ( +
+ {label} + + {!flat && ( + <> + + {up ? '▲' : '▼'} + + + {up ? '+' : '-'} + {Math.abs(delta!.pct)}% + + + )} + vs prev. period + +
+ ); +} diff --git a/src/client/components/features/stats/time-range-select.tsx b/apps/dashboard/src/components/features/stats/time-range-select.tsx similarity index 100% rename from src/client/components/features/stats/time-range-select.tsx rename to apps/dashboard/src/components/features/stats/time-range-select.tsx diff --git a/src/client/components/layout/account-menu.tsx b/apps/dashboard/src/components/layout/account-menu.tsx similarity index 97% rename from src/client/components/layout/account-menu.tsx rename to apps/dashboard/src/components/layout/account-menu.tsx index 3fd9b5fc..df91406c 100644 --- a/src/client/components/layout/account-menu.tsx +++ b/apps/dashboard/src/components/layout/account-menu.tsx @@ -1,214 +1,214 @@ -import { GithubMark } from '@codraoss/ui'; -import { Link } from 'react-router-dom'; -import { useEffect, useRef, useState } from 'react'; -import { api } from '@client/lib/api'; -import { ArrowUpRight, LogOut, ChevronsUpDown, UserRound } from 'lucide-react'; -import { cn } from '@codraoss/ui/utils'; -import type { AuthSessionUser } from '@codraoss/schema/api'; - -/** Shared by the pill trigger and the menu's identity header. */ -function Avatar({ - user, - initial, - size, -}: { - user: AuthSessionUser; - initial: string; - size: number; -}) { - const box = { width: size, height: size }; - - if (user.avatarUrl) { - return ( - - ); - } - - return ( - - {initial} - - ); -} - -/** One row in the menu: icon, label, and an optional trailing affordance. */ -const ITEM = cn( - 'group/item flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-[13px] font-medium', - 'text-ui-default outline-none transition-colors duration-150', -); - -const ITEM_ICON = 'shrink-0 text-ui-subtle transition-colors group-hover/item:text-ui-default'; - -/** - * Built from scratch (no shared dropdown primitive): a local popover anchored - * to the account row via `absolute bottom-full`, so it opens directly above - * the row and moves with the sidebar. - */ -export function AccountMenu({ user }: { user: AuthSessionUser }) { - const [open, setOpen] = useState(false); - const rootRef = useRef(null); - const triggerRef = useRef(null); - - const name = user.name?.trim() || user.login; - const initial = name.charAt(0).toUpperCase(); - - useEffect(() => { - if (!open) return; - const onPointer = (e: PointerEvent) => { - if (!rootRef.current?.contains(e.target as Node)) setOpen(false); - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - setOpen(false); - triggerRef.current?.focus(); - } - }; - window.addEventListener('pointerdown', onPointer); - window.addEventListener('keydown', onKey); - return () => { - window.removeEventListener('pointerdown', onPointer); - window.removeEventListener('keydown', onKey); - }; - }, [open]); - - return ( -
- - {/* Repeats the identity as the panel's header so the menu has a subject of its own. Stays - mounted and animates via CSS, and is `invisible` + `pointer-events-none` when closed so it - can't sit on top of rows behind it and swallow clicks. */} -
-
- - - - {name} - - - @{user.login} - - -
- -
- - setOpen(false)} - > - - Account - - - setOpen(false)} - > - - GitHub profile - {/* Marks the one item that leaves the app. */} - - - -
- - -
- - {/* Avatar, name over handle, and the double chevron. Geometry (full width, radius, spacing) - matches the sidebar rows above it. */} - -
- ); -} +import { GithubMark } from '@codraoss/ui'; +import { Link } from 'react-router-dom'; +import { useEffect, useRef, useState } from 'react'; +import { api } from '@client/lib/api'; +import { ArrowUpRight, LogOut, ChevronsUpDown, UserRound } from 'lucide-react'; +import { cn } from '@codraoss/ui/utils'; +import type { AuthSessionUser } from '@codraoss/schema/api'; + +/** Shared by the pill trigger and the menu's identity header. */ +function Avatar({ + user, + initial, + size, +}: { + user: AuthSessionUser; + initial: string; + size: number; +}) { + const box = { width: size, height: size }; + + if (user.avatarUrl) { + return ( + + ); + } + + return ( + + {initial} + + ); +} + +/** One row in the menu: icon, label, and an optional trailing affordance. */ +const ITEM = cn( + 'group/item flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-[13px] font-medium', + 'text-ui-default outline-none transition-colors duration-150', +); + +const ITEM_ICON = 'shrink-0 text-ui-subtle transition-colors group-hover/item:text-ui-default'; + +/** + * Built from scratch (no shared dropdown primitive): a local popover anchored + * to the account row via `absolute bottom-full`, so it opens directly above + * the row and moves with the sidebar. + */ +export function AccountMenu({ user }: { user: AuthSessionUser }) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + + const name = user.name?.trim() || user.login; + const initial = name.charAt(0).toUpperCase(); + + useEffect(() => { + if (!open) return; + const onPointer = (e: PointerEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setOpen(false); + triggerRef.current?.focus(); + } + }; + window.addEventListener('pointerdown', onPointer); + window.addEventListener('keydown', onKey); + return () => { + window.removeEventListener('pointerdown', onPointer); + window.removeEventListener('keydown', onKey); + }; + }, [open]); + + return ( +
+ + {/* Repeats the identity as the panel's header so the menu has a subject of its own. Stays + mounted and animates via CSS, and is `invisible` + `pointer-events-none` when closed so it + can't sit on top of rows behind it and swallow clicks. */} +
+
+ + + + {name} + + + @{user.login} + + +
+ +
+ + setOpen(false)} + > + + Account + + + setOpen(false)} + > + + GitHub profile + {/* Marks the one item that leaves the app. */} + + + +
+ + +
+ + {/* Avatar, name over handle, and the double chevron. Geometry (full width, radius, spacing) + matches the sidebar rows above it. */} + +
+ ); +} diff --git a/src/client/components/layout/app-shell.tsx b/apps/dashboard/src/components/layout/app-shell.tsx similarity index 73% rename from src/client/components/layout/app-shell.tsx rename to apps/dashboard/src/components/layout/app-shell.tsx index e451056c..fd88e91b 100644 --- a/src/client/components/layout/app-shell.tsx +++ b/apps/dashboard/src/components/layout/app-shell.tsx @@ -1,215 +1,219 @@ -import { Outlet, Link } from 'react-router-dom'; -import { useEffect, useState } from 'react'; -import { SharedLayoutBg } from '@codraoss/ui/motion'; -import { api } from '@client/lib/api'; -import { LayoutDashboard, AlignLeft, GitBranch, BarChart2, Sun, Moon, Activity, Settings, Star, X, ArrowUpRight } from 'lucide-react'; -import { cn } from '@codraoss/ui/utils'; -import { useTheme } from '@codraoss/ui/theme'; -import codraDark from '@/assets/codra-fullicon-dark.svg'; -import codraLight from '@/assets/codra-fullicon-light.svg'; -import type { AuthSessionUser } from '@codraoss/schema/api'; - -import { SidebarNavItem } from '@client/components/layout/sidebar-nav-item'; -import { AccountMenu } from '@client/components/layout/account-menu'; -const links = [ - { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, end: true }, - { to: '/jobs', label: 'Jobs', icon: Activity, end: false }, - { to: '/repos', label: 'Repos', icon: GitBranch, end: false }, - { to: '/stats', label: 'Stats', icon: BarChart2, end: false }, - { to: '/settings', label: 'Settings', icon: Settings, end: false }, -]; - - -export function AppShell() { - const { theme, toggleTheme } = useTheme(); - const [sessionUser, setSessionUser] = useState(null); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - - useEffect(() => { - let cancelled = false; - api.getSession() - .then(r => { if (!cancelled) setSessionUser(r.user); }) - .catch(() => { if (!cancelled) setSessionUser(null); }); - return () => { cancelled = true; }; - }, []); - - // Scroll doesn't bubble: listen in capture phase, flag scrolled el with data-scrolling for CSS, clear after 700ms idle. - useEffect(() => { - const timers = new WeakMap(); - const onScroll = (e: Event) => { - let el = e.target as Element | Document | null; - if (el === document) el = document.scrollingElement; - if (!(el instanceof Element)) return; - const node = el; - node.setAttribute('data-scrolling', 'true'); - const prev = timers.get(node); - if (prev !== undefined) window.clearTimeout(prev); - timers.set(node, window.setTimeout(() => node.removeAttribute('data-scrolling'), 700)); - }; - document.addEventListener('scroll', onScroll, true); - return () => document.removeEventListener('scroll', onScroll, true); - }, []); - - return ( -
- - {mobileMenuOpen && ( - /* Hidden from a11y tree: drawer's X is the real focusable close; scrim as a tab stop would double-announce. */ - - -
-
- - - -
- -
- - - - Star on GitHub - - - - {sessionUser && } -
- - - {/* Shell never scrolls; card fills viewport, pages scroll their own body inside it. */} -
- -
- - -
- - {/* Full-width so scrollbar sits at card's inner edge; short pages scroll here, always inside - the card, never the window. `scrollbar-gutter: stable` keeps the gutter reserved whether - or not the bar is showing: otherwise gaining a scrollbar narrows the content, which can - rewrap text and shift every measurement taken against this box. */} -
-
- -
-
-
-
- ); -} +import { Outlet, Link } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { SharedLayoutBg } from '@codraoss/ui/motion'; +import { AlignLeft, Sun, Moon, Star, X, ArrowUpRight } from 'lucide-react'; +import { cn } from '@codraoss/ui/utils'; +import { useTheme } from '@codraoss/ui/theme'; +import codraDark from '@/assets/codra-fullicon-dark.svg'; +import codraLight from '@/assets/codra-fullicon-light.svg'; +import { SidebarNavItem } from '@client/components/layout/sidebar-nav-item'; +import { AccountMenu } from '@client/components/layout/account-menu'; +import { navItems as defaultNavItems } from '@client/nav'; +import type { NavItem } from '@client/nav'; +import { SessionProvider, useSession } from '@client/hooks/use-session'; +import { useCan } from '@client/hooks/use-can'; + +export function AppShell({ navItems = defaultNavItems }: { navItems?: NavItem[] } = {}) { + return ( + + + + ); +} + +function SidebarNav({ navItems, onNavigate }: { navItems: NavItem[]; onNavigate: () => void }) { + return ( + <> + {navItems.map(({ to, label, end, icon, requiresAction }) => ( + + ))} + + ); +} + +function NavEntry({ to, label, end, icon, requiresAction, onNavigate }: NavItem & { onNavigate: () => void }) { + const allowed = useCan(requiresAction ?? '*'); + if (requiresAction && !allowed) return null; + + return ( + /* SharedLayoutBg clones this div to inject pill + z-10 wrapper. */ +
+ +
+ ); +} + +function AppShellInner({ navItems }: { navItems: NavItem[] }) { + const { theme, toggleTheme } = useTheme(); + const { user: sessionUser } = useSession(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + useEffect(() => { + const timers = new WeakMap(); + const onScroll = (e: Event) => { + let el = e.target as Element | Document | null; + if (el === document) el = document.scrollingElement; + if (!(el instanceof Element)) return; + const node = el; + node.setAttribute('data-scrolling', 'true'); + const prev = timers.get(node); + if (prev !== undefined) window.clearTimeout(prev); + timers.set(node, window.setTimeout(() => node.removeAttribute('data-scrolling'), 700)); + }; + document.addEventListener('scroll', onScroll, true); + return () => document.removeEventListener('scroll', onScroll, true); + }, []); + + return ( +
+ + {mobileMenuOpen && ( + + +
+
+ + + +
+ +
+ + + + Star on GitHub + + + + {sessionUser && } +
+ + +
+ +
+ + +
+ + {/* `scrollbar-gutter: stable` keeps the gutter reserved either way; otherwise gaining a scrollbar narrows the content, rewrapping text and shifting every measurement taken against this box. */} +
+
+ +
+
+
+
+ ); +} diff --git a/src/client/components/layout/page-header.tsx b/apps/dashboard/src/components/layout/page-header.tsx similarity index 100% rename from src/client/components/layout/page-header.tsx rename to apps/dashboard/src/components/layout/page-header.tsx diff --git a/src/client/components/layout/sidebar-nav-item.tsx b/apps/dashboard/src/components/layout/sidebar-nav-item.tsx similarity index 100% rename from src/client/components/layout/sidebar-nav-item.tsx rename to apps/dashboard/src/components/layout/sidebar-nav-item.tsx diff --git a/src/client/components/shared/jobs-table.tsx b/apps/dashboard/src/components/shared/jobs-table.tsx similarity index 100% rename from src/client/components/shared/jobs-table.tsx rename to apps/dashboard/src/components/shared/jobs-table.tsx diff --git a/src/client/components/shared/page-header-actions.tsx b/apps/dashboard/src/components/shared/page-header-actions.tsx similarity index 100% rename from src/client/components/shared/page-header-actions.tsx rename to apps/dashboard/src/components/shared/page-header-actions.tsx diff --git a/src/client/components/shared/route-error-boundary.tsx b/apps/dashboard/src/components/shared/route-error-boundary.tsx similarity index 100% rename from src/client/components/shared/route-error-boundary.tsx rename to apps/dashboard/src/components/shared/route-error-boundary.tsx diff --git a/apps/dashboard/src/hooks/use-can.ts b/apps/dashboard/src/hooks/use-can.ts new file mode 100644 index 00000000..af8bf631 --- /dev/null +++ b/apps/dashboard/src/hooks/use-can.ts @@ -0,0 +1,9 @@ +import type { ApiAction } from '@codraoss/schema/api'; +import { useSession } from '@client/hooks/use-session'; + +// UI-side gate only; the server authorizes every request independently, and absent permissions mean nothing is restricted, so everything is allowed. +export function useCan(action: ApiAction): boolean { + const { permissions } = useSession(); + if (permissions === undefined) return true; + return permissions.includes('*') || permissions.includes(action); +} diff --git a/src/client/hooks/use-fit-rows.ts b/apps/dashboard/src/hooks/use-fit-rows.ts similarity index 97% rename from src/client/hooks/use-fit-rows.ts rename to apps/dashboard/src/hooks/use-fit-rows.ts index f63d84de..099d2187 100644 --- a/src/client/hooks/use-fit-rows.ts +++ b/apps/dashboard/src/hooks/use-fit-rows.ts @@ -1,140 +1,140 @@ -import { useCallback, useLayoutEffect, useRef, useState } from 'react'; - -interface FitRowsOptions { - /** Height of one desktop table row, in px. Matches the `h-12` cell in JobsTable. */ - rowHeight?: number; - /** Height of one stacked mobile card, in px. */ - mobileRowHeight?: number; - /** Never ask for fewer than this many rows. */ - min?: number; - /** Never ask for more than this many rows (the API caps `limit` at 100). */ - max?: number; - /** - * Space left below the last row: the page wrapper's bottom padding (`py-8` = 32px) plus the - * panel border, so the table stops short of the scroll container instead of overflowing it. - */ - reserve?: number; -} - -/** Nearest scrollable ancestor, so the measurement is taken against the box the table lives in. */ -function scrollParent(el: HTMLElement): HTMLElement { - let node = el.parentElement; - while (node) { - const { overflowY } = getComputedStyle(node); - if (overflowY === 'auto' || overflowY === 'scroll') return node; - node = node.parentElement; - } - return document.documentElement; -} - -/** - * Every element laid out above `el` inside the scroller: its previous siblings, then its ancestors' - * previous siblings. Deliberately excludes `el`, its ancestors and its descendants - those contain - * the table, whose height is this hook's output, and observing them fed the row count back into - * itself. What is above `el` moves its top edge, so it genuinely needs a re-measure. - */ -function elementsAbove(el: HTMLElement, scroller: HTMLElement): Element[] { - const found: Element[] = []; - let node: HTMLElement | null = el; - - while (node && node !== scroller) { - for (let sib = node.previousElementSibling; sib; sib = sib.previousElementSibling) { - found.push(sib); - } - node = node.parentElement; - } - - return found; -} - -/** - * Fraction of a row a measurement has to clear before the count changes. A few px of layout jitter - * (a scrollbar appearing, a label rewrapping) would otherwise flip `rows` back and forth, and every - * flip refetches at a new `limit`. - */ -const DEADBAND = 0.35; - -/** - * Measures how many rows fit between the returned ref's top edge and the bottom of the scroll - * container, so a list can request exactly as many items as the viewport can show. - * - * `rows` is `null` until the first measurement lands - callers should hold off fetching until then - * so they don't fire one request at a guessed size and a second at the real one. - */ -export function useFitRows({ - rowHeight = 48, - mobileRowHeight = 101, - min = 3, - max = 30, - reserve = 36, -}: FitRowsOptions = {}) { - const ref = useRef(null); - const [rows, setRows] = useState(null); - - const measure = useCallback(() => { - const el = ref.current; - if (!el) return; - - const scroller = scrollParent(el); - // Offset from the scroll container's content top, not the viewport: stays put while the user - // scrolls, so growing the table can't feed back into the row count. - const top = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop; - const available = scroller.clientHeight - top - reserve; - - const wide = typeof window.matchMedia === 'function' - ? window.matchMedia('(min-width: 640px)').matches - : true; - const unit = wide ? rowHeight : mobileRowHeight; - const fits = Math.max(min, Math.min(max, Math.floor(available / unit))); - - setRows((current) => { - if (current === null || fits === current) return fits; - - // A one-row change has to be decisive; anything larger is a real resize, so take it as-is. - if (Math.abs(fits - current) === 1) { - const margin = unit * DEADBAND; - const growing = fits > current; - if (growing && available < (current + 1) * unit + margin) return current; - if (!growing && available > current * unit - margin) return current; - } - - return fits; - }); - }, [rowHeight, mobileRowHeight, min, max, reserve]); - - useLayoutEffect(() => { - measure(); - - const el = ref.current; - if (!el) return; - - // Guarded for jsdom, which has no ResizeObserver; window resize alone is enough there. - // - // Callbacks are coalesced into a frame so a burst of resize notifications measures once, after - // layout has settled. - let frame = 0; - const schedule = () => { - if (frame) return; - frame = requestAnimationFrame(() => { - frame = 0; - measure(); - }); - }; - - const scroller = scrollParent(el); - const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(schedule) : null; - observer?.observe(scroller); - // Content above shifts the table's top edge: the stat cards settling, or a banner that only - // appears once its own request resolves. - for (const node of elementsAbove(el, scroller)) observer?.observe(node); - - window.addEventListener('resize', schedule); - return () => { - if (frame) cancelAnimationFrame(frame); - observer?.disconnect(); - window.removeEventListener('resize', schedule); - }; - }, [measure]); - - return { ref, rows }; -} +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; + +interface FitRowsOptions { + /** Height of one desktop table row, in px. Matches the `h-12` cell in JobsTable. */ + rowHeight?: number; + /** Height of one stacked mobile card, in px. */ + mobileRowHeight?: number; + /** Never ask for fewer than this many rows. */ + min?: number; + /** Never ask for more than this many rows (the API caps `limit` at 100). */ + max?: number; + /** + * Space left below the last row: the page wrapper's bottom padding (`py-8` = 32px) plus the + * panel border, so the table stops short of the scroll container instead of overflowing it. + */ + reserve?: number; +} + +/** Nearest scrollable ancestor, so the measurement is taken against the box the table lives in. */ +function scrollParent(el: HTMLElement): HTMLElement { + let node = el.parentElement; + while (node) { + const { overflowY } = getComputedStyle(node); + if (overflowY === 'auto' || overflowY === 'scroll') return node; + node = node.parentElement; + } + return document.documentElement; +} + +/** + * Every element laid out above `el` inside the scroller: its previous siblings, then its ancestors' + * previous siblings. Deliberately excludes `el`, its ancestors and its descendants - those contain + * the table, whose height is this hook's output, and observing them fed the row count back into + * itself. What is above `el` moves its top edge, so it genuinely needs a re-measure. + */ +function elementsAbove(el: HTMLElement, scroller: HTMLElement): Element[] { + const found: Element[] = []; + let node: HTMLElement | null = el; + + while (node && node !== scroller) { + for (let sib = node.previousElementSibling; sib; sib = sib.previousElementSibling) { + found.push(sib); + } + node = node.parentElement; + } + + return found; +} + +/** + * Fraction of a row a measurement has to clear before the count changes. A few px of layout jitter + * (a scrollbar appearing, a label rewrapping) would otherwise flip `rows` back and forth, and every + * flip refetches at a new `limit`. + */ +const DEADBAND = 0.35; + +/** + * Measures how many rows fit between the returned ref's top edge and the bottom of the scroll + * container, so a list can request exactly as many items as the viewport can show. + * + * `rows` is `null` until the first measurement lands - callers should hold off fetching until then + * so they don't fire one request at a guessed size and a second at the real one. + */ +export function useFitRows({ + rowHeight = 48, + mobileRowHeight = 101, + min = 3, + max = 30, + reserve = 36, +}: FitRowsOptions = {}) { + const ref = useRef(null); + const [rows, setRows] = useState(null); + + const measure = useCallback(() => { + const el = ref.current; + if (!el) return; + + const scroller = scrollParent(el); + // Offset from the scroll container's content top, not the viewport: stays put while the user + // scrolls, so growing the table can't feed back into the row count. + const top = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop; + const available = scroller.clientHeight - top - reserve; + + const wide = typeof window.matchMedia === 'function' + ? window.matchMedia('(min-width: 640px)').matches + : true; + const unit = wide ? rowHeight : mobileRowHeight; + const fits = Math.max(min, Math.min(max, Math.floor(available / unit))); + + setRows((current) => { + if (current === null || fits === current) return fits; + + // A one-row change has to be decisive; anything larger is a real resize, so take it as-is. + if (Math.abs(fits - current) === 1) { + const margin = unit * DEADBAND; + const growing = fits > current; + if (growing && available < (current + 1) * unit + margin) return current; + if (!growing && available > current * unit - margin) return current; + } + + return fits; + }); + }, [rowHeight, mobileRowHeight, min, max, reserve]); + + useLayoutEffect(() => { + measure(); + + const el = ref.current; + if (!el) return; + + // Guarded for jsdom, which has no ResizeObserver; window resize alone is enough there. + // + // Callbacks are coalesced into a frame so a burst of resize notifications measures once, after + // layout has settled. + let frame = 0; + const schedule = () => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + measure(); + }); + }; + + const scroller = scrollParent(el); + const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(schedule) : null; + observer?.observe(scroller); + // Content above shifts the table's top edge: the stat cards settling, or a banner that only + // appears once its own request resolves. + for (const node of elementsAbove(el, scroller)) observer?.observe(node); + + window.addEventListener('resize', schedule); + return () => { + if (frame) cancelAnimationFrame(frame); + observer?.disconnect(); + window.removeEventListener('resize', schedule); + }; + }, [measure]); + + return { ref, rows }; +} diff --git a/src/client/hooks/use-job-detail.ts b/apps/dashboard/src/hooks/use-job-detail.ts similarity index 100% rename from src/client/hooks/use-job-detail.ts rename to apps/dashboard/src/hooks/use-job-detail.ts diff --git a/src/client/hooks/use-polling.ts b/apps/dashboard/src/hooks/use-polling.ts similarity index 100% rename from src/client/hooks/use-polling.ts rename to apps/dashboard/src/hooks/use-polling.ts diff --git a/src/client/hooks/use-provider-settings.ts b/apps/dashboard/src/hooks/use-provider-settings.ts similarity index 100% rename from src/client/hooks/use-provider-settings.ts rename to apps/dashboard/src/hooks/use-provider-settings.ts diff --git a/src/client/hooks/use-review-settings.ts b/apps/dashboard/src/hooks/use-review-settings.ts similarity index 100% rename from src/client/hooks/use-review-settings.ts rename to apps/dashboard/src/hooks/use-review-settings.ts diff --git a/apps/dashboard/src/hooks/use-session.tsx b/apps/dashboard/src/hooks/use-session.tsx new file mode 100644 index 00000000..2a3bb794 --- /dev/null +++ b/apps/dashboard/src/hooks/use-session.tsx @@ -0,0 +1,35 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import { api } from '@client/lib/api'; +import type { AuthSessionUser } from '@codraoss/schema/api'; + +export interface SessionState { + user: AuthSessionUser | null; + permissions: string[] | undefined; + loading: boolean; +} + +const SessionContext = createContext({ user: null, permissions: undefined, loading: true }); + +export function SessionProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ user: null, permissions: undefined, loading: true }); + + useEffect(() => { + let cancelled = false; + api.getSession() + .then((r) => { + if (!cancelled) setState({ user: r.user, permissions: r.permissions, loading: false }); + }) + .catch(() => { + if (!cancelled) setState({ user: null, permissions: undefined, loading: false }); + }); + return () => { cancelled = true; }; + }, []); + + const value = useMemo(() => state, [state]); + return {children}; +} + +export function useSession() { + return useContext(SessionContext); +} diff --git a/src/client/hooks/use-stats-range.ts b/apps/dashboard/src/hooks/use-stats-range.ts similarity index 100% rename from src/client/hooks/use-stats-range.ts rename to apps/dashboard/src/hooks/use-stats-range.ts diff --git a/src/client/lib/api.ts b/apps/dashboard/src/lib/api.ts similarity index 100% rename from src/client/lib/api.ts rename to apps/dashboard/src/lib/api.ts diff --git a/src/client/lib/batch-groups.ts b/apps/dashboard/src/lib/batch-groups.ts similarity index 100% rename from src/client/lib/batch-groups.ts rename to apps/dashboard/src/lib/batch-groups.ts diff --git a/src/client/lib/diffs-cache.ts b/apps/dashboard/src/lib/diffs-cache.ts similarity index 100% rename from src/client/lib/diffs-cache.ts rename to apps/dashboard/src/lib/diffs-cache.ts diff --git a/src/client/lib/job-format.ts b/apps/dashboard/src/lib/job-format.ts similarity index 100% rename from src/client/lib/job-format.ts rename to apps/dashboard/src/lib/job-format.ts diff --git a/src/client/lib/timezone.ts b/apps/dashboard/src/lib/timezone.ts similarity index 100% rename from src/client/lib/timezone.ts rename to apps/dashboard/src/lib/timezone.ts diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx new file mode 100644 index 00000000..d180197e --- /dev/null +++ b/apps/dashboard/src/main.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { RouterProvider } from 'react-router-dom'; +import { Toaster } from 'sonner'; +import { buildRouter } from './routes'; + +import './app.css'; + +import { ThemeProvider } from '@codraoss/ui/theme'; +import { useIsDarkMode } from '@codraoss/ui/hooks'; +import { SmoothScroll } from '@codraoss/ui/motion'; + +function ToasterWrapper() { + const isDark = useIsDarkMode(); + return ( + + ); +} + +const router = buildRouter(); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + , +); diff --git a/apps/dashboard/src/nav.ts b/apps/dashboard/src/nav.ts new file mode 100644 index 00000000..18e7d4d9 --- /dev/null +++ b/apps/dashboard/src/nav.ts @@ -0,0 +1,20 @@ +import { LayoutDashboard, GitBranch, BarChart2, Activity, Settings } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import type { ApiAction } from '@codraoss/schema/api'; + +export interface NavItem { + to: string; + label: string; + icon: LucideIcon; + end?: boolean; + requiresAction?: ApiAction; +} + +// The sidebar's contents, kept here so navigation can be extended by composing this list; /account is intentionally absent because it lives in the account menu. +export const navItems: NavItem[] = [ + { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, end: true }, + { to: '/jobs', label: 'Jobs', icon: Activity, end: false }, + { to: '/repos', label: 'Repos', icon: GitBranch, end: false }, + { to: '/stats', label: 'Stats', icon: BarChart2, end: false }, + { to: '/settings', label: 'Settings', icon: Settings, end: false }, +]; diff --git a/src/client/pages/account.tsx b/apps/dashboard/src/pages/account.tsx similarity index 100% rename from src/client/pages/account.tsx rename to apps/dashboard/src/pages/account.tsx diff --git a/src/client/pages/dashboard.tsx b/apps/dashboard/src/pages/dashboard.tsx similarity index 97% rename from src/client/pages/dashboard.tsx rename to apps/dashboard/src/pages/dashboard.tsx index 76de9dea..418fd2ec 100644 --- a/src/client/pages/dashboard.tsx +++ b/apps/dashboard/src/pages/dashboard.tsx @@ -1,131 +1,131 @@ -import { Button, EmptyState, LoadError } from '@codraoss/ui'; -import { useState } from 'react'; -import { api } from '@client/lib/api'; -import type { StatsPayload, JobSummary } from '@codraoss/schema'; -import { ArrowRight, GitPullRequest, Activity } from 'lucide-react'; -import { JobsTable } from '@client/components/shared/jobs-table'; -import { PageHeaderActions } from '@client/components/shared/page-header-actions'; -import { Link } from 'react-router-dom'; - -import { PageHeader } from '@client/components/layout/page-header'; -import { OverviewStats } from '@client/components/features/stats/overview-stats'; -import { useFitRows } from '@client/hooks/use-fit-rows'; -import { usePolling } from '@client/hooks/use-polling'; -import { useStatsRange } from '@client/hooks/use-stats-range'; - -export function DashboardPage() { - const [stats, setStats] = useState(null); - const [recentJobs, setRecentJobs] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); - - const [days, setDays] = useStatsRange(); - - // Ask for exactly as many recent jobs as fit under the stats cards, so the panel fills the - // viewport without spilling into a page scroll. `null` until the first measurement lands. - const { ref: tableRef, rows } = useFitRows({ min: 4, max: 30 }); - - // Clears stats to show skeletons while the new range loads; recent-jobs is range-independent and keeps its data. - const changeDays = (next: number) => { - setStats(null); - setDays(next); - }; - - const load = async (manual = false) => { - if (rows === null) return; - if (manual) setRefreshing(true); - try { - const [statsRes, jobsRes] = await Promise.all([ - api.getStats(days), - api.getJobs({ limit: rows }), - ]); - setStats(statsRes.stats); - setRecentJobs(jobsRes.jobs); - setError(null); - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to refresh dashboard.'); - } finally { - setLoading(false); - setRefreshing(false); - } - }; - - usePolling(load, 15_000, [days, rows]); - - - return ( -
- - load(true)} - refreshing={refreshing} - /> - } - /> - - {error && ( - load(true)} - retrying={refreshing} - /> - )} - - - -
-
-
- -

Recent reviews

-
- - - -
- -
- {/* Nothing renders until the measurement lands. JobsTable falls back to 8 skeleton rows - when `skeletonRows` is undefined, so rendering it early painted a too-long table that - then shrank to the fitted count. `useFitRows` measures in a layout effect, so `rows` - is set before the first paint - this costs no visible delay. */} - {rows !== null && (loading || recentJobs.length > 0) && ( - - )} - - {!loading && recentJobs.length === 0 && ( - } - title="No jobs yet" - description="Your pull request reviews will appear here" - hints={[ - 'Once you open a PR in any of the connected repos, analysis triggers automatically', - 'To trigger manually, comment @codra on any PR', - ]} - linkAction={{ - label: 'See how to interact with Codra', - href: 'https://github.com/devarshishimpi/codra#readme', - }} - className="rounded-none border-0" - /> - )} -
-
-
- ); -} - +import { Button, EmptyState, LoadError } from '@codraoss/ui'; +import { useState } from 'react'; +import { api } from '@client/lib/api'; +import type { StatsPayload, JobSummary } from '@codraoss/schema'; +import { ArrowRight, GitPullRequest, Activity } from 'lucide-react'; +import { JobsTable } from '@client/components/shared/jobs-table'; +import { PageHeaderActions } from '@client/components/shared/page-header-actions'; +import { Link } from 'react-router-dom'; + +import { PageHeader } from '@client/components/layout/page-header'; +import { OverviewStats } from '@client/components/features/stats/overview-stats'; +import { useFitRows } from '@client/hooks/use-fit-rows'; +import { usePolling } from '@client/hooks/use-polling'; +import { useStatsRange } from '@client/hooks/use-stats-range'; + +export function DashboardPage() { + const [stats, setStats] = useState(null); + const [recentJobs, setRecentJobs] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + const [days, setDays] = useStatsRange(); + + // Ask for exactly as many recent jobs as fit under the stats cards, so the panel fills the + // viewport without spilling into a page scroll. `null` until the first measurement lands. + const { ref: tableRef, rows } = useFitRows({ min: 4, max: 30 }); + + // Clears stats to show skeletons while the new range loads; recent-jobs is range-independent and keeps its data. + const changeDays = (next: number) => { + setStats(null); + setDays(next); + }; + + const load = async (manual = false) => { + if (rows === null) return; + if (manual) setRefreshing(true); + try { + const [statsRes, jobsRes] = await Promise.all([ + api.getStats(days), + api.getJobs({ limit: rows }), + ]); + setStats(statsRes.stats); + setRecentJobs(jobsRes.jobs); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to refresh dashboard.'); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + usePolling(load, 15_000, [days, rows]); + + + return ( +
+ + load(true)} + refreshing={refreshing} + /> + } + /> + + {error && ( + load(true)} + retrying={refreshing} + /> + )} + + + +
+
+
+ +

Recent reviews

+
+ + + +
+ +
+ {/* Nothing renders until the measurement lands. JobsTable falls back to 8 skeleton rows + when `skeletonRows` is undefined, so rendering it early painted a too-long table that + then shrank to the fitted count. `useFitRows` measures in a layout effect, so `rows` + is set before the first paint - this costs no visible delay. */} + {rows !== null && (loading || recentJobs.length > 0) && ( + + )} + + {!loading && recentJobs.length === 0 && ( + } + title="No jobs yet" + description="Your pull request reviews will appear here" + hints={[ + 'Once you open a PR in any of the connected repos, analysis triggers automatically', + 'To trigger manually, comment @codra on any PR', + ]} + linkAction={{ + label: 'See how to interact with Codra', + href: 'https://github.com/devarshishimpi/codra#readme', + }} + className="rounded-none border-0" + /> + )} +
+
+
+ ); +} + diff --git a/src/client/pages/job-detail.tsx b/apps/dashboard/src/pages/job-detail.tsx similarity index 97% rename from src/client/pages/job-detail.tsx rename to apps/dashboard/src/pages/job-detail.tsx index 97d4e6d9..a05ac237 100644 --- a/src/client/pages/job-detail.tsx +++ b/apps/dashboard/src/pages/job-detail.tsx @@ -1,110 +1,110 @@ -import { LoadError } from '@codraoss/ui'; -import { useState } from 'react'; -import { useParams } from 'react-router-dom'; -import { LazyMotion, m, domMax } from 'motion/react'; -import { ClipboardList, FileDiff } from 'lucide-react'; -import { useJobDetail } from '@client/hooks/use-job-detail'; -import { JobHeader } from '@client/components/features/job-detail/job-header'; -import { JobProgress } from '@client/components/features/job-detail/job-progress'; -import { JobStatusNotice } from '@client/components/features/job-detail/job-status-notice'; -import { JobMetaCards } from '@client/components/features/job-detail/job-meta-cards'; -import { JobReviewOverview } from '@client/components/features/job-detail/job-review-overview'; -import { JobFindingsList } from '@client/components/features/job-detail/job-findings-list'; -import { JobDiffs } from '@client/components/features/job-detail/job-diffs'; -import { JobDetailSkeleton } from '@client/components/features/job-detail/job-skeleton'; -import { cn } from '@codraoss/ui/utils'; - -type DetailTab = 'overview' | 'files'; - -const TABS: Array<{ id: DetailTab; label: string; icon: typeof ClipboardList }> = [ - { id: 'overview', label: 'Overview', icon: ClipboardList }, - { id: 'files', label: 'Files changed', icon: FileDiff }, -]; - -export function JobDetailPage() { - const { id = '' } = useParams(); - const [tab, setTab] = useState('overview'); - const { - job, - error, - isRerunning, - isStopping, - isDeleting, - handleRerun, - handleStop, - handleDelete, - } = useJobDetail(id); - - if (!job) { - return ; - } - - return ( -
- - - {error && } - - - - {/* Terminal outcome (failed / superseded / stopped / partial) gets its own banner above the tabs. */} - - - {/* domMax, not domAnimation: the underline uses `layoutId`, which needs the layout feature. */} - - - - - {tab === 'overview' ? ( -
- - - -
- ) : ( - - )} -
- ); -} +import { LoadError } from '@codraoss/ui'; +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { LazyMotion, m, domMax } from 'motion/react'; +import { ClipboardList, FileDiff } from 'lucide-react'; +import { useJobDetail } from '@client/hooks/use-job-detail'; +import { JobHeader } from '@client/components/features/job-detail/job-header'; +import { JobProgress } from '@client/components/features/job-detail/job-progress'; +import { JobStatusNotice } from '@client/components/features/job-detail/job-status-notice'; +import { JobMetaCards } from '@client/components/features/job-detail/job-meta-cards'; +import { JobReviewOverview } from '@client/components/features/job-detail/job-review-overview'; +import { JobFindingsList } from '@client/components/features/job-detail/job-findings-list'; +import { JobDiffs } from '@client/components/features/job-detail/job-diffs'; +import { JobDetailSkeleton } from '@client/components/features/job-detail/job-skeleton'; +import { cn } from '@codraoss/ui/utils'; + +type DetailTab = 'overview' | 'files'; + +const TABS: Array<{ id: DetailTab; label: string; icon: typeof ClipboardList }> = [ + { id: 'overview', label: 'Overview', icon: ClipboardList }, + { id: 'files', label: 'Files changed', icon: FileDiff }, +]; + +export function JobDetailPage() { + const { id = '' } = useParams(); + const [tab, setTab] = useState('overview'); + const { + job, + error, + isRerunning, + isStopping, + isDeleting, + handleRerun, + handleStop, + handleDelete, + } = useJobDetail(id); + + if (!job) { + return ; + } + + return ( +
+ + + {error && } + + + + {/* Terminal outcome (failed / superseded / stopped / partial) gets its own banner above the tabs. */} + + + {/* domMax, not domAnimation: the underline uses `layoutId`, which needs the layout feature. */} + + + + + {tab === 'overview' ? ( +
+ + + +
+ ) : ( + + )} +
+ ); +} diff --git a/src/client/pages/job-logs.tsx b/apps/dashboard/src/pages/job-logs.tsx similarity index 97% rename from src/client/pages/job-logs.tsx rename to apps/dashboard/src/pages/job-logs.tsx index 85034cc0..96a7c22a 100644 --- a/src/client/pages/job-logs.tsx +++ b/apps/dashboard/src/pages/job-logs.tsx @@ -1,384 +1,384 @@ -import { Badge, CopyButton, LoadError } from '@codraoss/ui'; -import { useEffect, useMemo, useState } from 'react'; -import { useParams, Link } from 'react-router-dom'; - - -import { preventToggleOnTextSelection } from '@codraoss/ui/selection'; -import { readDiffsCache, writeDiffsCache } from '@client/lib/diffs-cache'; -import { groupBatches } from '@client/lib/batch-groups'; -import type { BatchGroup } from '@client/lib/batch-groups'; -import { - ChevronLeft, FileCode2, Clock, Cpu, Hash, Layers, MessageSquare, - AlertCircle, CheckCircle2, SkipForward, Hourglass, - ChevronDown, -} from 'lucide-react'; -import { useJobDetail } from '@client/hooks/use-job-detail'; -import { JobDetailSkeleton } from '@client/components/features/job-detail/job-skeleton'; - -import { api } from '@client/lib/api'; -import type { FileReviewRecord } from '@codraoss/schema'; - -import { formatPreciseDuration } from '@codraoss/ui/utils'; - -function fmtK(n: number | null) { - if (n === null) return null; - return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); -} - -type FileStatus = FileReviewRecord['fileStatus']; - -const STATUS_META: Record = { - done: { Icon: CheckCircle2, iconCls: 'text-success', badge: 'success', label: 'Done' }, - skipped: { Icon: SkipForward, iconCls: 'text-ui-subtle', badge: 'neutral', label: 'Skipped' }, - failed: { Icon: AlertCircle, iconCls: 'text-danger', badge: 'danger', label: 'Failed' }, - pending: { Icon: Hourglass, iconCls: 'text-ui-subtle', badge: 'neutral', label: 'Pending' }, -}; - -function withheldTotal(file: FileReviewRecord): number { - const counts = file.withheldCounts; - if (!counts) return 0; - return (counts.evidence ?? 0) + (counts.claimDenied ?? 0) + (counts.contextOnly ?? 0) + (counts.absenceRefuted ?? 0); -} - -// A review that answered but not cleanly. Worth a badge rather than a log line: an unconstrained or -// truncated response can look exactly like a clean one from the outside. -const DEGRADED_LABEL: Record = { - 'schema-dropped': 'The model refused the response format and answered without it.', - 'schema-dropped-catchall': 'The response format may have been dropped (the provider error was ambiguous).', - truncated: 'The model ran out of output room; some findings may be missing.', -}; - -function FileRow({ file, diffsLoading, batch }: { file: FileReviewRecord; diffsLoading: boolean; batch?: BatchGroup }) { - const meta = STATUS_META[file.fileStatus] ?? STATUS_META.pending; - const { Icon } = meta; - const duration = formatPreciseDuration(file.durationMs); - const inTok = fmtK(file.inputTokens); - const outTok = fmtK(file.outputTokens); - const modelShort = file.modelUsed?.split('/').pop() ?? null; - // Only >1 is worth surfacing: 1 means reviewed alone and null means the row predates batching, - // and neither tells the reader anything they can act on. - const batchSize = (file.batchSize ?? 1) > 1 ? file.batchSize! : null; - const batchTitle = batchSize - ? `Reviewed in one model call shared with ${batchSize - 1} other ${batchSize === 2 ? 'file' : 'files'}. ` - + 'Token counts are this file\'s share of that call; the duration is the whole call, and the raw output below is the shared response.' - : undefined; - const siblings = batch?.paths.filter((path) => path !== file.filePath) ?? []; - const withheld = withheldTotal(file); - const kept = file.parsedComments.length; - - return ( -
- {/* Selection allowed here (was `select-none`) so log content can be copied; the click guard stops drag-select from collapsing the row. */} - - - - - {/* Fixed-width whether or not it is filled, so paths stay aligned down the whole list. */} - - {batch ? `B${batch.index}` : '·'} - - - - {file.filePath} - - -
- {modelShort && ( - - {modelShort} - - )} - {duration && ( - - {duration} - - )} - {(inTok || outTok) && ( - - {inTok ?? '-'}↑ {outTok ?? '-'}↓ - - )} - {file.degraded && ( - - degraded - - )} - {batchSize !== null && ( - - ×{batchSize} - - )} - {file.fileStatus === 'done' && ( - 0 - ? `${kept} finding${kept === 1 ? '' : 's'} kept, ${withheld} withheld by the evidence and claim gates` - : `${kept} finding${kept === 1 ? '' : 's'} kept` - } - className="ui-font-mono flex items-center gap-1 text-[10px] tabular-nums text-ui-subtle" - > - {kept} - {/* The number that distinguishes "found nothing" from "found things and dropped them all". */} - {withheld > 0 && -{withheld}} - - )} -
- - - {meta.label} - - - -
- -
- -
- {modelShort && {modelShort}} - {duration && {duration}} - {inTok && {inTok}↑ {outTok ?? '-'}↓} - {batchSize !== null && batch {batch?.index ?? '?'} · ×{batchSize}} - {file.fileStatus === 'done' && ( - {kept} kept{withheld > 0 ? `, ${withheld} withheld` : ''} - )} -
- - {/* Which files actually shared this call. batch_size alone says "6" without saying with whom, - which is the first question anyone debugging a batched review asks. */} - {siblings.length > 0 && ( -
-

- Shared one model call with -

-
    - {siblings.map((path) => ( -
  • - - {path} -
  • - ))} -
-
- )} - - {file.fileStatus === 'failed' && file.errorMessage && ( -
-

- Review error -

-

- {file.errorMessage} -

-
- )} - -
-
-
-

- Prompt / diff -

- {file.diffInput && } -
-
-              {/* No leading dash: this pre holds a unified diff, where `- ` is the deletion marker. */}
-              {file.diffInput ?? (diffsLoading ? 'Loading…' : 'Prompt unavailable')}
-            
-
-
-
- {/* Flagged as shared, or the entries for the other N-1 files read as this file's output. */} -

- Raw model output{batchSize ? ` · shared by ${batchSize} files` : ''} -

- {file.rawAiOutput && } -
-
-              {file.rawAiOutput ?? 'No output saved'}
-            
-
-
-
-
- ); -} - -export function JobLogsPage() { - const { id = '' } = useParams(); - const { job, error } = useJobDetail(id); - - const [diffsByPath, setDiffsByPath] = useState | null>(() => readDiffsCache(id)); - const [diffsLoading, setDiffsLoading] = useState(diffsByPath === null); - - useEffect(() => { - let cancelled = false; - setDiffsLoading(true); - api.getJobDiffs(id) - .then((res) => { - if (cancelled) return; - setDiffsByPath(res.diffs); - writeDiffsCache(id, res.diffs); - }) - .catch(() => { - if (!cancelled) setDiffsByPath((current) => current ?? {}); - }) - .finally(() => { - if (!cancelled) setDiffsLoading(false); - }); - return () => { - cancelled = true; - }; - }, [id]); - - const files = useMemo( - () => (job ? job.files.map((f) => (diffsByPath?.[f.filePath] ? { ...f, diffInput: diffsByPath[f.filePath] } : f)) : []), - [job, diffsByPath], - ); - - // Above the early return: hooks must run in the same order on every render. - const batches = useMemo(() => groupBatches(files), [files]); - - if (!job) return ; - - const counts = { - done: files.filter(f => f.fileStatus === 'done').length, - skipped: files.filter(f => f.fileStatus === 'skipped').length, - failed: files.filter(f => f.fileStatus === 'failed').length, - total: files.length, - }; - - // Derived from the reconstructed bins, so it matches what the rows show rather than being a - // second, independently-computed number that can disagree with them. - const binCount = new Set([...batches.values()].map((group) => group.index)).size; - const batchedFiles = batches.size; - const callsSaved = batchedFiles - binCount; - const withheld = files.reduce((sum, file) => sum + withheldTotal(file), 0); - const kept = files.reduce((sum, file) => sum + file.parsedComments.length, 0); - - return ( -
- - - - Back to Job Details - - -
-
-

- Review logs -

-

- {job.owner}/{job.repo} · #{job.prNumber} · {job.commitSha.slice(0, 7)} -

-
- - {counts.total > 0 && ( -
- {[ - { label: 'Files', val: counts.total, cls: 'text-ui-strong' }, - { label: 'Reviewed', val: counts.done, cls: 'text-success' }, - { label: 'Skipped', val: counts.skipped, cls: 'text-ui-subtle' }, - { label: 'Failed', val: counts.failed, cls: counts.failed > 0 ? 'text-danger' : 'text-ui-subtle' }, - ].map(({ label, val, cls }) => ( -
- {val} - {label} -
- ))} -
- )} -
- - {error && } - - {/* Second strip rather than more columns in the first: the one above counts files, these - describe the review itself, and mixing the two units read as one confusing row. */} - {counts.total > 0 && ( -
- - - {kept} findings kept - {withheld > 0 && ( - <> - · - {withheld} withheld by the gates - - )} - - - {binCount > 0 && ( - - - {batchedFiles} files in - {binCount} - {binCount === 1 ? 'batch' : 'batches'} - · - {callsSaved} model calls saved - - )} -
- )} - - {files.length === 0 ? ( -
- -
-

No files processed yet

- {(job.status === 'running' || job.status === 'queued') && ( -

- Logs appear here once files are reviewed -

- )} -
-
- ) : ( -
-
- -

File reviews

- - {counts.total} {counts.total === 1 ? 'file' : 'files'} - -
-
- {files.map(file => ( - - ))} -
-
- )} -
- ); -} +import { Badge, CopyButton, LoadError } from '@codraoss/ui'; +import { useEffect, useMemo, useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; + + +import { preventToggleOnTextSelection } from '@codraoss/ui/selection'; +import { readDiffsCache, writeDiffsCache } from '@client/lib/diffs-cache'; +import { groupBatches } from '@client/lib/batch-groups'; +import type { BatchGroup } from '@client/lib/batch-groups'; +import { + ChevronLeft, FileCode2, Clock, Cpu, Hash, Layers, MessageSquare, + AlertCircle, CheckCircle2, SkipForward, Hourglass, + ChevronDown, +} from 'lucide-react'; +import { useJobDetail } from '@client/hooks/use-job-detail'; +import { JobDetailSkeleton } from '@client/components/features/job-detail/job-skeleton'; + +import { api } from '@client/lib/api'; +import type { FileReviewRecord } from '@codraoss/schema'; + +import { formatPreciseDuration } from '@codraoss/ui/utils'; + +function fmtK(n: number | null) { + if (n === null) return null; + return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); +} + +type FileStatus = FileReviewRecord['fileStatus']; + +const STATUS_META: Record = { + done: { Icon: CheckCircle2, iconCls: 'text-success', badge: 'success', label: 'Done' }, + skipped: { Icon: SkipForward, iconCls: 'text-ui-subtle', badge: 'neutral', label: 'Skipped' }, + failed: { Icon: AlertCircle, iconCls: 'text-danger', badge: 'danger', label: 'Failed' }, + pending: { Icon: Hourglass, iconCls: 'text-ui-subtle', badge: 'neutral', label: 'Pending' }, +}; + +function withheldTotal(file: FileReviewRecord): number { + const counts = file.withheldCounts; + if (!counts) return 0; + return (counts.evidence ?? 0) + (counts.claimDenied ?? 0) + (counts.contextOnly ?? 0) + (counts.absenceRefuted ?? 0); +} + +// A review that answered but not cleanly. Worth a badge rather than a log line: an unconstrained or +// truncated response can look exactly like a clean one from the outside. +const DEGRADED_LABEL: Record = { + 'schema-dropped': 'The model refused the response format and answered without it.', + 'schema-dropped-catchall': 'The response format may have been dropped (the provider error was ambiguous).', + truncated: 'The model ran out of output room; some findings may be missing.', +}; + +function FileRow({ file, diffsLoading, batch }: { file: FileReviewRecord; diffsLoading: boolean; batch?: BatchGroup }) { + const meta = STATUS_META[file.fileStatus] ?? STATUS_META.pending; + const { Icon } = meta; + const duration = formatPreciseDuration(file.durationMs); + const inTok = fmtK(file.inputTokens); + const outTok = fmtK(file.outputTokens); + const modelShort = file.modelUsed?.split('/').pop() ?? null; + // Only >1 is worth surfacing: 1 means reviewed alone and null means the row predates batching, + // and neither tells the reader anything they can act on. + const batchSize = (file.batchSize ?? 1) > 1 ? file.batchSize! : null; + const batchTitle = batchSize + ? `Reviewed in one model call shared with ${batchSize - 1} other ${batchSize === 2 ? 'file' : 'files'}. ` + + 'Token counts are this file\'s share of that call; the duration is the whole call, and the raw output below is the shared response.' + : undefined; + const siblings = batch?.paths.filter((path) => path !== file.filePath) ?? []; + const withheld = withheldTotal(file); + const kept = file.parsedComments.length; + + return ( +
+ {/* Selection allowed here (was `select-none`) so log content can be copied; the click guard stops drag-select from collapsing the row. */} + + + + + {/* Fixed-width whether or not it is filled, so paths stay aligned down the whole list. */} + + {batch ? `B${batch.index}` : '·'} + + + + {file.filePath} + + +
+ {modelShort && ( + + {modelShort} + + )} + {duration && ( + + {duration} + + )} + {(inTok || outTok) && ( + + {inTok ?? '-'}↑ {outTok ?? '-'}↓ + + )} + {file.degraded && ( + + degraded + + )} + {batchSize !== null && ( + + ×{batchSize} + + )} + {file.fileStatus === 'done' && ( + 0 + ? `${kept} finding${kept === 1 ? '' : 's'} kept, ${withheld} withheld by the evidence and claim gates` + : `${kept} finding${kept === 1 ? '' : 's'} kept` + } + className="ui-font-mono flex items-center gap-1 text-[10px] tabular-nums text-ui-subtle" + > + {kept} + {/* The number that distinguishes "found nothing" from "found things and dropped them all". */} + {withheld > 0 && -{withheld}} + + )} +
+ + + {meta.label} + + + +
+ +
+ +
+ {modelShort && {modelShort}} + {duration && {duration}} + {inTok && {inTok}↑ {outTok ?? '-'}↓} + {batchSize !== null && batch {batch?.index ?? '?'} · ×{batchSize}} + {file.fileStatus === 'done' && ( + {kept} kept{withheld > 0 ? `, ${withheld} withheld` : ''} + )} +
+ + {/* Which files actually shared this call. batch_size alone says "6" without saying with whom, + which is the first question anyone debugging a batched review asks. */} + {siblings.length > 0 && ( +
+

+ Shared one model call with +

+
    + {siblings.map((path) => ( +
  • + + {path} +
  • + ))} +
+
+ )} + + {file.fileStatus === 'failed' && file.errorMessage && ( +
+

+ Review error +

+

+ {file.errorMessage} +

+
+ )} + +
+
+
+

+ Prompt / diff +

+ {file.diffInput && } +
+
+              {/* No leading dash: this pre holds a unified diff, where `- ` is the deletion marker. */}
+              {file.diffInput ?? (diffsLoading ? 'Loading…' : 'Prompt unavailable')}
+            
+
+
+
+ {/* Flagged as shared, or the entries for the other N-1 files read as this file's output. */} +

+ Raw model output{batchSize ? ` · shared by ${batchSize} files` : ''} +

+ {file.rawAiOutput && } +
+
+              {file.rawAiOutput ?? 'No output saved'}
+            
+
+
+
+
+ ); +} + +export function JobLogsPage() { + const { id = '' } = useParams(); + const { job, error } = useJobDetail(id); + + const [diffsByPath, setDiffsByPath] = useState | null>(() => readDiffsCache(id)); + const [diffsLoading, setDiffsLoading] = useState(diffsByPath === null); + + useEffect(() => { + let cancelled = false; + setDiffsLoading(true); + api.getJobDiffs(id) + .then((res) => { + if (cancelled) return; + setDiffsByPath(res.diffs); + writeDiffsCache(id, res.diffs); + }) + .catch(() => { + if (!cancelled) setDiffsByPath((current) => current ?? {}); + }) + .finally(() => { + if (!cancelled) setDiffsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [id]); + + const files = useMemo( + () => (job ? job.files.map((f) => (diffsByPath?.[f.filePath] ? { ...f, diffInput: diffsByPath[f.filePath] } : f)) : []), + [job, diffsByPath], + ); + + // Above the early return: hooks must run in the same order on every render. + const batches = useMemo(() => groupBatches(files), [files]); + + if (!job) return ; + + const counts = { + done: files.filter(f => f.fileStatus === 'done').length, + skipped: files.filter(f => f.fileStatus === 'skipped').length, + failed: files.filter(f => f.fileStatus === 'failed').length, + total: files.length, + }; + + // Derived from the reconstructed bins, so it matches what the rows show rather than being a + // second, independently-computed number that can disagree with them. + const binCount = new Set([...batches.values()].map((group) => group.index)).size; + const batchedFiles = batches.size; + const callsSaved = batchedFiles - binCount; + const withheld = files.reduce((sum, file) => sum + withheldTotal(file), 0); + const kept = files.reduce((sum, file) => sum + file.parsedComments.length, 0); + + return ( +
+ + + + Back to Job Details + + +
+
+

+ Review logs +

+

+ {job.owner}/{job.repo} · #{job.prNumber} · {job.commitSha.slice(0, 7)} +

+
+ + {counts.total > 0 && ( +
+ {[ + { label: 'Files', val: counts.total, cls: 'text-ui-strong' }, + { label: 'Reviewed', val: counts.done, cls: 'text-success' }, + { label: 'Skipped', val: counts.skipped, cls: 'text-ui-subtle' }, + { label: 'Failed', val: counts.failed, cls: counts.failed > 0 ? 'text-danger' : 'text-ui-subtle' }, + ].map(({ label, val, cls }) => ( +
+ {val} + {label} +
+ ))} +
+ )} +
+ + {error && } + + {/* Second strip rather than more columns in the first: the one above counts files, these + describe the review itself, and mixing the two units read as one confusing row. */} + {counts.total > 0 && ( +
+ + + {kept} findings kept + {withheld > 0 && ( + <> + · + {withheld} withheld by the gates + + )} + + + {binCount > 0 && ( + + + {batchedFiles} files in + {binCount} + {binCount === 1 ? 'batch' : 'batches'} + · + {callsSaved} model calls saved + + )} +
+ )} + + {files.length === 0 ? ( +
+ +
+

No files processed yet

+ {(job.status === 'running' || job.status === 'queued') && ( +

+ Logs appear here once files are reviewed +

+ )} +
+
+ ) : ( +
+
+ +

File reviews

+ + {counts.total} {counts.total === 1 ? 'file' : 'files'} + +
+
+ {files.map(file => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/client/pages/jobs.tsx b/apps/dashboard/src/pages/jobs.tsx similarity index 100% rename from src/client/pages/jobs.tsx rename to apps/dashboard/src/pages/jobs.tsx diff --git a/src/client/pages/landing.tsx b/apps/dashboard/src/pages/landing.tsx similarity index 100% rename from src/client/pages/landing.tsx rename to apps/dashboard/src/pages/landing.tsx diff --git a/src/client/pages/login.tsx b/apps/dashboard/src/pages/login.tsx similarity index 100% rename from src/client/pages/login.tsx rename to apps/dashboard/src/pages/login.tsx diff --git a/src/client/pages/not-found.tsx b/apps/dashboard/src/pages/not-found.tsx similarity index 100% rename from src/client/pages/not-found.tsx rename to apps/dashboard/src/pages/not-found.tsx diff --git a/src/client/pages/repos.tsx b/apps/dashboard/src/pages/repos.tsx similarity index 100% rename from src/client/pages/repos.tsx rename to apps/dashboard/src/pages/repos.tsx diff --git a/src/client/pages/settings.tsx b/apps/dashboard/src/pages/settings.tsx similarity index 100% rename from src/client/pages/settings.tsx rename to apps/dashboard/src/pages/settings.tsx diff --git a/src/client/pages/stats.tsx b/apps/dashboard/src/pages/stats.tsx similarity index 97% rename from src/client/pages/stats.tsx rename to apps/dashboard/src/pages/stats.tsx index 3825bf62..bbeb695e 100644 --- a/src/client/pages/stats.tsx +++ b/apps/dashboard/src/pages/stats.tsx @@ -1,75 +1,75 @@ -import { LoadError } from '@codraoss/ui'; -import { useEffect, useState } from 'react'; -import { PageHeaderActions } from '@client/components/shared/page-header-actions'; -import { PageHeader } from '@client/components/layout/page-header'; -import { useIsDarkMode } from '@codraoss/ui/hooks'; -import { usePolling } from '@client/hooks/use-polling'; -import { useStatsRange } from '@client/hooks/use-stats-range'; -import { api } from '@client/lib/api'; -import type { StatsPayload } from '@codraoss/schema'; - - -import { MetricsGrid } from '@client/components/features/stats/metrics-grid'; -import { prefetchMetricsCharts } from '@client/components/features/stats/metrics-grid-prefetch'; - - -export function StatsPage() { - const [stats, setStats] = useState(null); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); - const [days, setDays] = useStatsRange(); - const isDark = useIsDarkMode(); - - // Downloads the lazy chart chunk in parallel with the first stats fetch rather than after it. - useEffect(prefetchMetricsCharts, []); - - // Switching the range reloads every metric; clear current data first so skeletons show while it loads. - const changeDays = (next: number) => { - setStats(null); - setDays(next); - }; - - const load = async (manual = false) => { - if (manual) setRefreshing(true); - try { - const res = await api.getStats(days); - setStats(res.stats); - setError(null); - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to load stats.'); - } finally { - setRefreshing(false); - } - }; - - usePolling(load, 30_000, [days]); - - return ( -
- load(true)} - refreshing={refreshing} - /> - } - /> - - {error && ( - load(true)} - retrying={refreshing} - /> - )} - - {/* MetricsGrid owns the skeleton too: branching here as well would mount a second one. */} - -
- ); -} +import { LoadError } from '@codraoss/ui'; +import { useEffect, useState } from 'react'; +import { PageHeaderActions } from '@client/components/shared/page-header-actions'; +import { PageHeader } from '@client/components/layout/page-header'; +import { useIsDarkMode } from '@codraoss/ui/hooks'; +import { usePolling } from '@client/hooks/use-polling'; +import { useStatsRange } from '@client/hooks/use-stats-range'; +import { api } from '@client/lib/api'; +import type { StatsPayload } from '@codraoss/schema'; + + +import { MetricsGrid } from '@client/components/features/stats/metrics-grid'; +import { prefetchMetricsCharts } from '@client/components/features/stats/metrics-grid-prefetch'; + + +export function StatsPage() { + const [stats, setStats] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [days, setDays] = useStatsRange(); + const isDark = useIsDarkMode(); + + // Downloads the lazy chart chunk in parallel with the first stats fetch rather than after it. + useEffect(prefetchMetricsCharts, []); + + // Switching the range reloads every metric; clear current data first so skeletons show while it loads. + const changeDays = (next: number) => { + setStats(null); + setDays(next); + }; + + const load = async (manual = false) => { + if (manual) setRefreshing(true); + try { + const res = await api.getStats(days); + setStats(res.stats); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load stats.'); + } finally { + setRefreshing(false); + } + }; + + usePolling(load, 30_000, [days]); + + return ( +
+ load(true)} + refreshing={refreshing} + /> + } + /> + + {error && ( + load(true)} + retrying={refreshing} + /> + )} + + {/* MetricsGrid owns the skeleton too: branching here as well would mount a second one. */} + +
+ ); +} diff --git a/apps/dashboard/src/routes.tsx b/apps/dashboard/src/routes.tsx new file mode 100644 index 00000000..5718d53c --- /dev/null +++ b/apps/dashboard/src/routes.tsx @@ -0,0 +1,75 @@ +import React, { Suspense } from 'react'; +import { createBrowserRouter } from 'react-router-dom'; +import type { RouteObject } from 'react-router-dom'; +import { AppShell } from '@client/components/layout/app-shell'; +import { RouteErrorBoundary } from '@client/components/shared/route-error-boundary'; +import { navItems as defaultNavItems } from '@client/nav'; +import type { NavItem } from '@client/nav'; + +const LandingPage = React.lazy(() => import('./pages/landing').then(m => ({ default: m.LandingPage }))); +const DashboardPage = React.lazy(() => import('./pages/dashboard').then(m => ({ default: m.DashboardPage }))); +const LoginPage = React.lazy(() => import('./pages/login').then(m => ({ default: m.LoginPage }))); +const JobsPage = React.lazy(() => import('./pages/jobs').then(m => ({ default: m.JobsPage }))); +const JobDetailPage = React.lazy(() => import('./pages/job-detail').then(m => ({ default: m.JobDetailPage }))); +const JobLogsPage = React.lazy(() => import('./pages/job-logs').then(m => ({ default: m.JobLogsPage }))); +const ReposPage = React.lazy(() => import('./pages/repos').then(m => ({ default: m.ReposPage }))); +const StatsPage = React.lazy(() => import('./pages/stats').then(m => ({ default: m.StatsPage }))); +const SettingsPage = React.lazy(() => import('./pages/settings').then(m => ({ default: m.SettingsPage }))); +const AccountPage = React.lazy(() => import('./pages/account').then(m => ({ default: m.AccountPage }))); +const NotFoundPage = React.lazy(() => import('./pages/not-found').then(m => ({ default: m.NotFoundPage }))); + +// Render failures, including a failed lazy chunk, bubble to the branch's `errorElement` so there is one styled fallback instead of two. +export const withSuspense = (Component: React.ComponentType, isFullPage = false) => ( + }> + + +); + +export const publicRoutes: RouteObject[] = [ + { + path: '/', + element: withSuspense(LandingPage, true), + errorElement: , + }, + { + path: '/login', + element: withSuspense(LoginPage, true), + errorElement: , + }, +]; + +// A boundary per child too, not just on the layout: React Router replaces the whole matched branch, so a branch-only boundary would take the sidebar and header down with a single page. +export const shellRoutes: RouteObject[] = [ + { path: 'dashboard', element: withSuspense(DashboardPage), errorElement: }, + { path: 'jobs', element: withSuspense(JobsPage), errorElement: }, + { path: 'jobs/:id', element: withSuspense(JobDetailPage), errorElement: }, + { path: 'jobs/:id/logs', element: withSuspense(JobLogsPage), errorElement: }, + { path: 'repos', element: withSuspense(ReposPage), errorElement: }, + { path: 'stats', element: withSuspense(StatsPage), errorElement: }, + { path: 'settings', element: withSuspense(SettingsPage), errorElement: }, + { path: 'account', element: withSuspense(AccountPage), errorElement: }, +]; + +export interface RouterExtensions { + publicRoutes?: RouteObject[]; + shellRoutes?: RouteObject[]; + navItems?: NavItem[]; +} + +// buildRouter appends the catch-all last so injected routes stay reachable; a route registered after a '*' route would never match. +export function buildRouter(extra: RouterExtensions = {}) { + return createBrowserRouter([ + ...publicRoutes, + ...(extra.publicRoutes ?? []), + { + element: , + errorElement: , + children: [...shellRoutes, ...(extra.shellRoutes ?? [])], + }, + { + path: '*', + element: withSuspense(NotFoundPage, true), + errorElement: , + }, + ]); +} diff --git a/src/server/adapters/file-review-store.ts b/apps/worker/src/adapters/file-review-store.ts similarity index 89% rename from src/server/adapters/file-review-store.ts rename to apps/worker/src/adapters/file-review-store.ts index a480b7f3..06e489e3 100644 --- a/src/server/adapters/file-review-store.ts +++ b/apps/worker/src/adapters/file-review-store.ts @@ -1,4 +1,4 @@ -import type { AppBindings } from '@server/env'; +import type { AppBindings } from '../env'; import type { FileReviewStore } from '@codraoss/core/ports'; import { makeFileReviewStore as makeDbFileReviewStore } from '@codraoss/db/repositories'; import type { DbEnv } from '@codraoss/db/env'; diff --git a/src/server/adapters/index.ts b/apps/worker/src/adapters/index.ts similarity index 96% rename from src/server/adapters/index.ts rename to apps/worker/src/adapters/index.ts index cf020ef4..b0138937 100644 --- a/src/server/adapters/index.ts +++ b/apps/worker/src/adapters/index.ts @@ -1,10 +1,10 @@ import type { ReviewRuntime } from '@codraoss/core/ports'; import { TokenTracker } from '@codraoss/core/token-tracker'; -import type { AppBindings } from '@server/env'; +import type { AppBindings } from '../env'; // Imported for its side effect as well as never: this installs the AsyncLocalStorage-backed logger as // the sink @codraoss/core's logger facade delegates to. Explicit here so engine log lines carry request // context by construction, rather than because some other module happened to be loaded first. -import '@server/core/logger'; +import '../core/logger'; import { cryptoIds, makeKvStore, makeTelemetrySink, systemClock } from './platform'; import { makeJobStore } from './jobs-store'; import { makeFileReviewStore } from './file-review-store'; diff --git a/src/server/adapters/jobs-store.ts b/apps/worker/src/adapters/jobs-store.ts similarity index 88% rename from src/server/adapters/jobs-store.ts rename to apps/worker/src/adapters/jobs-store.ts index 8cfa90e7..ee94e1da 100644 --- a/src/server/adapters/jobs-store.ts +++ b/apps/worker/src/adapters/jobs-store.ts @@ -1,4 +1,4 @@ -import type { AppBindings } from '@server/env'; +import type { AppBindings } from '../env'; import type { JobStore } from '@codraoss/core/ports'; import { makeJobStore as makeDbJobStore } from '@codraoss/db/repositories'; import type { DbEnv } from '@codraoss/db/env'; diff --git a/src/server/adapters/platform.ts b/apps/worker/src/adapters/platform.ts similarity index 90% rename from src/server/adapters/platform.ts rename to apps/worker/src/adapters/platform.ts index 709419b1..837c821c 100644 --- a/src/server/adapters/platform.ts +++ b/apps/worker/src/adapters/platform.ts @@ -1,6 +1,6 @@ import type { Clock, IdGenerator, KvStore, TelemetrySink } from '@codraoss/core/ports'; -import type { AppBindings } from '@server/env'; -import { sendTelemetryEvent } from '@server/core/telemetry'; +import type { AppBindings } from '../env'; +import { sendTelemetryEvent } from '../core/telemetry'; // env.APP_KV already satisfies KvStore structurally; the wrapper narrows it to the two methods the // engine may use, so a future reach for `list` or `delete` fails here rather than in the engine. diff --git a/src/server/adapters/services.ts b/apps/worker/src/adapters/services.ts similarity index 94% rename from src/server/adapters/services.ts rename to apps/worker/src/adapters/services.ts index fdc99dae..1d22c7f5 100644 --- a/src/server/adapters/services.ts +++ b/apps/worker/src/adapters/services.ts @@ -1,9 +1,9 @@ import type { GitProviderFactory, ModelErrorClassifier, ReviewFormatter, ReviewGitProvider, ReviewModel } from '@codraoss/core/ports'; import type { TokenTracker } from '@codraoss/core/token-tracker'; -import type { AppBindings } from '@server/env'; +import type { AppBindings } from '../env'; import { GitHubService } from '@codraoss/provider-github'; import { isRetryableModelError, ModelRunner, nextChainIndexOf } from '@codraoss/models'; -import { FormatterService } from '@server/services/formatter'; +import { FormatterService } from '../services/formatter'; import { getResolvedModelConfig } from '@codraoss/db/model-configs'; // The only place the four job-scoped collaborators are constructed. Every specifier above is the diff --git a/src/server/adapters/settings-store.ts b/apps/worker/src/adapters/settings-store.ts similarity index 92% rename from src/server/adapters/settings-store.ts rename to apps/worker/src/adapters/settings-store.ts index 84efe8c2..fef2d90e 100644 --- a/src/server/adapters/settings-store.ts +++ b/apps/worker/src/adapters/settings-store.ts @@ -1,8 +1,8 @@ -import type { AppBindings } from '@server/env'; +import type { AppBindings } from '../env'; import type { LearningStore, ModelConfigReader, RepoConfigLoader, ReviewSettingsReader, WebhookDeliveryReader } from '@codraoss/core/ports'; import { makeLearningStore as makeDbLearningStore, makeModelConfigReader as makeDbModelConfigReader, makeReviewSettingsReader as makeDbReviewSettingsReader, makeWebhookDeliveryReader as makeDbWebhookDeliveryReader } from '@codraoss/db/repositories'; import type { DbEnv } from '@codraoss/db/env'; -import { loadRepoConfig } from '@server/core/config'; +import { loadRepoConfig } from '../core/config'; function toDbEnv(env: AppBindings): DbEnv { return { diff --git a/apps/worker/src/api-deps.ts b/apps/worker/src/api-deps.ts index be76b9e9..f0e27b32 100644 --- a/apps/worker/src/api-deps.ts +++ b/apps/worker/src/api-deps.ts @@ -13,24 +13,24 @@ import * as dbWebhookDeliveries from '@codraoss/db/webhook-deliveries'; import { GitHubClient, normalizeGitHubWebhook } from '@codraoss/provider-github'; import { GitHubIdentityProvider } from '@codraoss/provider-github/oauth'; -import { getGlobalConfig, updateGlobalConfig, loadRepoConfig, invalidateRepoConfigCache } from '../../../src/server/core/config'; +import { getGlobalConfig, updateGlobalConfig, loadRepoConfig, invalidateRepoConfigCache } from './core/config'; -import { getUpdatesEmailPreference, syncUpdatesEmail } from '../../../src/server/core/updates-email'; +import { getUpdatesEmailPreference, syncUpdatesEmail } from './core/updates-email'; -import { getOrFetchRawDiffForCompletedJob, extractReviewRequest } from '../../../src/server/core/review'; +import { getOrFetchRawDiffForCompletedJob, extractReviewRequest } from './core/review'; -import { createOAuthState, consumeOAuthState } from '../../../src/server/core/oauth'; +import { createOAuthState, consumeOAuthState } from './core/oauth'; -import { verifyGitHubWebhookSignature } from '../../../src/server/core/verify'; +import { verifyGitHubWebhookSignature } from '@codraoss/core/verify'; import { CloudflareSessionStore } from './sessions'; -import { makeKvStore } from '../../../src/server/adapters/platform'; -import { logger } from '../../../src/server/core/logger'; +import { makeKvStore } from './adapters/platform'; +import { logger } from './core/logger'; // model sync dependencies import { listLlmProviderSecrets, upsertDiscoveredModelConfigs, createLlmProvider, updateLlmProvider, getResolvedModelConfig, getLlmProvider } from '@codraoss/db/model-configs'; import { encryptLlmApiKey, decryptLlmApiKey, listProviderModels, reviewWithCloudflare, reviewWithGoogle, reviewWithVertex, reviewWithOpenAI, reviewWithAnthropic, ProviderRequestError } from '@codraoss/models'; -import { buildReviewResponseSchema } from '../../../src/server/prompts/file-review'; +import { buildReviewResponseSchema } from '@codraoss/core/prompts/file-review'; function getSecretStore(env: AppBindings) { return { getSecret: async (key: string) => (env as any)[key] as string || null }; @@ -48,7 +48,7 @@ function optionalEnv(value: () => string) { // `IDENTITY_PROVIDER` is a test-only seam; production has no such binding. const githubIdentity = new GitHubIdentityProvider(); function identityProvider(env: AppBindings) { - return ((env as any).IDENTITY_PROVIDER as any) ?? githubIdentity; + return env.IDENTITY_PROVIDER ?? githubIdentity; } export function createApiRouterDeps(env: AppBindings, _ctx: ExecutionContext): ApiRouterDeps { @@ -70,10 +70,10 @@ export function createApiRouterDeps(env: AppBindings, _ctx: ExecutionContext): A createService: (installationId?: number | string | null) => new GitHubClient(env as any, String(installationId)), }, config: { - getGlobalConfig: async () => await getGlobalConfig(env as any), - updateGlobalConfig: async (config: any) => await updateGlobalConfig(env as any, config), - loadRepoConfig: async (input: any) => await loadRepoConfig(env as any, input), - invalidateRepoConfigCache: async (owner: string, repo: string) => await invalidateRepoConfigCache(env as any, owner, repo), + getGlobalConfig: async () => await getGlobalConfig(env), + updateGlobalConfig: async (config: any) => await updateGlobalConfig(env, config), + loadRepoConfig: async (input: any) => await loadRepoConfig(env, input), + invalidateRepoConfigCache: async (owner: string, repo: string) => await invalidateRepoConfigCache(env, owner, repo), }, modelRunner: { syncProviderModelCatalog: async () => { @@ -236,13 +236,13 @@ export function createApiRouterDeps(env: AppBindings, _ctx: ExecutionContext): A scheduleBestEffortJobMaintenance: (executionContext: any) => { try { executionContext?.waitUntil( - import('../../../src/server/core/job-recovery').then(m => m.runBestEffortJobMaintenance(env)) + import('./core/job-recovery').then(m => m.runBestEffortJobMaintenance(env)) ); } catch (e) { /* ignore */ } }, createReviewRuntime: () => ({ kv: makeKvStore(env) } as any), - getUpdatesEmailPreference: async (githubUserId: number) => await getUpdatesEmailPreference(env as any, githubUserId), - syncUpdatesEmail: async (githubUserId: number, email: string | null | undefined) => await syncUpdatesEmail(env as any, githubUserId, email), + getUpdatesEmailPreference: async (githubUserId: number) => await getUpdatesEmailPreference(env, githubUserId), + syncUpdatesEmail: async (githubUserId: number, email: string | null | undefined) => await syncUpdatesEmail(env, githubUserId, email), terminateJobWorkflow: async (job: { id: string; workflowInstanceId?: string | null }) => { if (job.workflowInstanceId) { try { @@ -260,8 +260,8 @@ export function createApiRouterDeps(env: AppBindings, _ctx: ExecutionContext): A logger, }, authProvider: { - createOAuthState: async () => await createOAuthState(env as any), - consumeOAuthState: async (state: string) => await consumeOAuthState(env as any, state), + createOAuthState: async () => await createOAuthState(env), + consumeOAuthState: async (state: string) => await consumeOAuthState(env, state), beginAuthorization: async (callbackUrl: string, state: string) => await identityProvider(env).beginAuthorization(callbackUrl, state, env), completeAuthorization: async (code: string, state: string, expectedState: string) => diff --git a/src/server/core/config.ts b/apps/worker/src/core/config.ts similarity index 98% rename from src/server/core/config.ts rename to apps/worker/src/core/config.ts index bb5e323e..120165d8 100644 --- a/src/server/core/config.ts +++ b/apps/worker/src/core/config.ts @@ -1,6 +1,6 @@ import { defaultRepoConfig, normalizeRepoModelConfig, repoConfigSchema, type RepoConfig } from '@codraoss/schema'; import { REPO_CONFIG_CACHE_VERSION } from '@codraoss/schema'; -import type { AppBindings } from '@server/env'; +import type { AppBindings } from '../env'; import { getRepoConfigRecord, syncRepoConfig } from '@codraoss/db/repo-configs'; type CachedConfig = { diff --git a/src/server/core/job-recovery.ts b/apps/worker/src/core/job-recovery.ts similarity index 97% rename from src/server/core/job-recovery.ts rename to apps/worker/src/core/job-recovery.ts index f91be5ba..9c236c80 100644 --- a/src/server/core/job-recovery.ts +++ b/apps/worker/src/core/job-recovery.ts @@ -1,6 +1,6 @@ -import type { AppBindings } from '@server/env'; +import type { AppBindings } from '../env'; import { getTerminalJobsNeedingCheckRunCompletion, markJobCheckRunCompleted, recoverExpiredJobLeases } from '@codraoss/db/jobs'; -import { logger } from '@server/core/logger'; +import { logger } from './logger'; import { GitHubService } from '@codraoss/provider-github'; const MAX_RECOVERY_COUNT = 3; diff --git a/src/server/core/logger.ts b/apps/worker/src/core/logger.ts similarity index 100% rename from src/server/core/logger.ts rename to apps/worker/src/core/logger.ts diff --git a/src/server/core/oauth.ts b/apps/worker/src/core/oauth.ts similarity index 94% rename from src/server/core/oauth.ts rename to apps/worker/src/core/oauth.ts index 682a782b..e6ff9a1c 100644 --- a/src/server/core/oauth.ts +++ b/apps/worker/src/core/oauth.ts @@ -1,5 +1,5 @@ import { randomHex } from '@codraoss/schema/hex'; -import type { AppBindings } from '@server/env'; +import type { AppBindings } from '../env'; const OAUTH_STATE_TTL_SECONDS = 60 * 10; diff --git a/src/server/core/review/index.ts b/apps/worker/src/core/review/index.ts similarity index 94% rename from src/server/core/review/index.ts rename to apps/worker/src/core/review/index.ts index 4b211ada..21f48006 100644 --- a/src/server/core/review/index.ts +++ b/apps/worker/src/core/review/index.ts @@ -1,7 +1,7 @@ import type { ReviewJobMessage } from '@codraoss/schema'; import { runReview, type ReviewJobRunResult } from '@codraoss/core'; -import { createReviewRuntime } from '@server/adapters'; -import type { AppBindings } from '@server/env'; +import { createReviewRuntime } from '../../adapters'; +import type { AppBindings } from '../../env'; // The seam between the Worker and the engine. The engine moved to @codraoss/core; this converts // AppBindings into the ports it takes, and is the ONLY place in production that does. diff --git a/src/server/core/rpc.ts b/apps/worker/src/core/rpc.ts similarity index 100% rename from src/server/core/rpc.ts rename to apps/worker/src/core/rpc.ts diff --git a/src/server/core/sessions.ts b/apps/worker/src/core/sessions.ts similarity index 95% rename from src/server/core/sessions.ts rename to apps/worker/src/core/sessions.ts index 7bca938d..4c1ff7da 100644 --- a/src/server/core/sessions.ts +++ b/apps/worker/src/core/sessions.ts @@ -1,6 +1,6 @@ import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; import type { Context } from 'hono'; -import type { AppEnv, DashboardSessionUser } from '@server/env'; +import type { AppEnv, DashboardSessionUser } from '../env'; const SESSION_COOKIE_NAME = 'codra_session'; const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; diff --git a/src/server/core/telemetry.ts b/apps/worker/src/core/telemetry.ts similarity index 98% rename from src/server/core/telemetry.ts rename to apps/worker/src/core/telemetry.ts index d0c25ea0..6666c324 100644 --- a/src/server/core/telemetry.ts +++ b/apps/worker/src/core/telemetry.ts @@ -1,4 +1,4 @@ -import type { AppBindings } from '@server/env'; +import type { AppBindings } from '../env'; import { logger } from './logger'; const TELEMETRY_SECRET = 'codra-telemetry-v1-secret-8f9a2b5c'; @@ -6,7 +6,7 @@ const INSTANCE_ID_KEY = 'codra:instance_id'; import { queryRows } from '@codraoss/db/client'; // Static import: version string is inlined at build time by Vite - no runtime cost. -import pkg from '../../../package.json'; +import pkg from '../../../../package.json'; const CODRA_VERSION: string = pkg.version; diff --git a/src/server/core/updates-email.ts b/apps/worker/src/core/updates-email.ts similarity index 96% rename from src/server/core/updates-email.ts rename to apps/worker/src/core/updates-email.ts index 1d7e4abf..1c8bf7b4 100644 --- a/src/server/core/updates-email.ts +++ b/apps/worker/src/core/updates-email.ts @@ -1,4 +1,4 @@ -import type { AppBindings } from '@server/env'; +import type { AppBindings } from '../env'; const EMAILS_API_URL = 'https://codra.run/api/emails'; diff --git a/apps/worker/src/env.ts b/apps/worker/src/env.ts index b3160b61..c898474d 100644 --- a/apps/worker/src/env.ts +++ b/apps/worker/src/env.ts @@ -1,5 +1,7 @@ import type { ReviewJobMessage } from '@codraoss/schema'; -import type { DashboardSessionUser, SessionStore } from '@codraoss/core'; +import type { DashboardSessionUser, SessionStore, IdentityProvider } from '@codraoss/core'; + +export type { DashboardSessionUser }; export interface WorkersAiBinding { run(model: string, input: Record, options?: { signal?: AbortSignal }): Promise; @@ -19,7 +21,7 @@ export interface HyperdriveBinding { export interface AppBindings { SESSION_STORE: SessionStore; - IDENTITY_PROVIDER: any; // Type 'any' used to avoid importing from core yet + IDENTITY_PROVIDER: IdentityProvider; AI: WorkersAiBinding; APP_KV: KVNamespace; REVIEW_QUEUE: QueueProducer; diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index f63f12bf..cd0d9df7 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -3,11 +3,11 @@ import { createApiRouterDeps } from './api-deps'; import { ReviewWorkflow } from './workflows/review'; import type { AppBindings } from './env'; import { reviewJobMessageSchema } from '@codraoss/schema'; -import { logger } from '@server/core/logger'; -import { disposeRpc } from '@server/core/rpc'; +import { logger } from './core/logger'; +import { disposeRpc } from './core/rpc'; import { runWithDb } from '@codraoss/db/client'; import { failJob, hasPendingMaintenanceWork, clearSystemActive } from '@codraoss/db/jobs'; -import { runBestEffortJobMaintenance } from '@server/core/job-recovery'; +import { runBestEffortJobMaintenance } from './core/job-recovery'; const app = createApiRouter(); diff --git a/apps/worker/src/ports/cloudflare-orchestrator.ts b/apps/worker/src/ports/cloudflare-orchestrator.ts index ed27767d..e51a4c46 100644 --- a/apps/worker/src/ports/cloudflare-orchestrator.ts +++ b/apps/worker/src/ports/cloudflare-orchestrator.ts @@ -1,10 +1,10 @@ import type { JobOrchestrator } from '@codraoss/core'; import type { ReviewJobMessage } from '@codraoss/schema'; import { FRESH_INVOCATION_YIELD_SECONDS } from '@codraoss/core'; -import { runReviewJob } from '@server/core/review'; +import { runReviewJob } from '../core/review'; import { setJobWorkflowInstance } from '@codraoss/db/jobs'; import { logger } from '@codraoss/core/logger'; -import { runBestEffortJobMaintenance } from '@server/core/job-recovery'; +import { runBestEffortJobMaintenance } from '../core/job-recovery'; import type { AppBindings } from '../env'; import type { WorkflowStep } from 'cloudflare:workers'; @@ -65,7 +65,7 @@ export class CloudflareOrchestrator implements JobOrchestrator { }); } catch (error) { await step.do(`telemetry-failure-${currentPhase}-${attempt}`, async () => { - const { sendTelemetryEvent } = await import('@server/core/telemetry'); + const { sendTelemetryEvent } = await import('../core/telemetry'); await sendTelemetryEvent(env, { linesReviewed: 0, findingsReported: 0, diff --git a/src/server/services/formatter.ts b/apps/worker/src/services/formatter.ts similarity index 100% rename from src/server/services/formatter.ts rename to apps/worker/src/services/formatter.ts diff --git a/eslint.config.js b/eslint.config.js index d2cd9241..4c066008 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,176 +1,172 @@ -import js from '@eslint/js'; -import tseslint from 'typescript-eslint'; -import importX from 'eslint-plugin-import-x'; -import reactHooks from 'eslint-plugin-react-hooks'; -import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'; - -export default tseslint.config( - { - ignores: [ - // `**/` needed: bare `dist/**` misses packages/*/dist, linting emitted .d.ts as source. - '**/dist/**', - '**/node_modules/**','**/.wrangler/**', - 'apps/worker/src/worker-env.d.ts', - 'worker-configuration.d.ts', - ], - }, - - js.configs.recommended, - ...tseslint.configs.recommended, - - { - files: ['**/*.{ts,tsx,js,mjs}'], - plugins: { 'import-x': importX, 'react-hooks': reactHooks }, - settings: { - 'import-x/resolver-next': [ - createTypeScriptImportResolver({ project: './tsconfig.json' }), - ], - }, - rules: { - // TS resolves identifiers correctly (incl. types, `declare`, lib globals); this is redundant and worse. - 'no-undef': 'off', - - 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': ['error', { - caughtErrors: 'none', - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }], - - // Not core `no-duplicate-imports`: it's type-blind, flags `import {X}` + `import type {Y}` split as dupe. - 'import-x/no-duplicates': 'error', - 'import-x/no-self-import': 'error', - 'import-x/no-cycle': 'error', - - 'max-lines': ['error', { max: 400, skipBlankLines: true, skipComments: true }], - - 'react-hooks/exhaustive-deps': 'error', - - // Fires on emoji/variation-selector stripping in finding-title normalizer; that's intentional, test-pinned. - 'no-misleading-character-class': 'off', - - // Deliberate at provider/DB boundaries with unknown shape until parsed; ~100 suppressions otherwise. - '@typescript-eslint/no-explicit-any': 'off', - }, - }, - - { - files: ['src/client/**/*.{ts,tsx}'], - rules: { - 'react-hooks/rules-of-hooks': 'error', - - // Zone block at file bottom only covers packages/**+apps/**, so it can't catch this direction. - 'import-x/no-restricted-paths': ['error', { - zones: [ - { - target: 'src/client/**/*', - from: ['packages/core/**/*', 'src/server/**/*'], - message: 'The review engine and the Worker tree are server-only. Importing either pulls zod/jsonrepair/picomatch into the browser bundle -- exactly what the `vite build` CI step exists to catch. (@codraoss/schema/review-limits is the sanctioned client-side import.)' - } - ] - }], - }, - }, - - { - // vi.mock() intercepts these specifiers by string; list both `@alias/...` and `**/dir/...` forms since sibling imports and tsconfig-alias imports otherwise bypass it. - files: ['src/**/*.{ts,tsx}', 'test/**/*.{ts,tsx}'], - rules: { - 'no-restricted-imports': ['error', { - patterns: [ - { group: ['**/db/jobs-*', '@server/db/jobs-*'], message: 'Import from @server/db/jobs, not a sibling. Eight specs vi.mock that specifier; a direct sibling import silently bypasses the mock.' }, - { group: ['**/db/file-reviews-*', '@server/db/file-reviews-*'], message: 'Import from @server/db/file-reviews, not a sibling. (No spec mocks this one today; the rule keeps the barrel the single entry point.)' }, - { group: ['**/services/model-review-*', '**/services/model-rate-limits', '**/services/model-chain-runner', '**/services/model-support', '@codraoss/models-*'], message: 'Import from @codraoss/models, not a sibling. Four specs vi.mock that specifier.' }, - { group: ['**/core/github/http', '**/core/github/app-auth', '**/core/github/types', '**/core/github/diff-fetch', '**/core/github/review-post', '**/core/github/labels', '@server/core/github/http', '@server/core/github/app-auth', '@server/core/github/types', '@server/core/github/diff-fetch', '@server/core/github/review-post', '@server/core/github/labels'], message: 'Import from @server/core/github, not a sibling. One spec vi.mocks that specifier. (core/github/oauth is deliberately NOT listed: it is the dashboard OAuth flow, not part of the GitHubClient barrel, and routes/auth.ts imports it directly.)' }, - { group: ['**/core/review/*', '@server/core/review/*', '@codraoss/core/review/*'], message: 'Import from @server/core/review, not a sibling. One spec vi.mocks that specifier and workflows/review.ts imports only runReviewJob from it.' }, - { group: ['**/core/model-output/*', '@server/core/model-output/*', '@codraoss/core/model-output/*'], message: 'Import from @codraoss/core/model-output, not a sibling. (The package exports map already refuses to resolve these; the lint rule gives the error at edit time.)' }, - { group: ['**/core/diff/position', '@server/core/diff/position', '@codraoss/core/diff/position'], message: 'Import from @codraoss/core/diff, not a sibling.' }, - { group: ['**/schema-claims', '**/schema-repo-config', '**/schema-enums', '@codraoss/schema/schema-claims', '@codraoss/schema/schema-repo-config', '@codraoss/schema/schema-enums'], message: 'Import from @codraoss/schema, not a sibling. (@codraoss/schema/review-limits is exempt: the client imports it directly to keep zod out of the browser bundle.)' }, - ], - }], - }, - }, - { - // auth.spec.ts (422 lines): suites read-modify-write singleton global_settings, racing under fileParallelism (see DO-NOT-SPLIT header there). Delete entry, don't raise max, once split. - files: ['test/api/auth.spec.ts'], - rules: { - 'max-lines': 'off', - }, - }, - - { - files: [ - 'src/server/db/jobs.ts', - 'src/server/db/file-reviews.ts', - 'src/server/services/model.ts', - 'src/server/core/github/index.ts', - 'packages/schema/src/schema.ts', - ], - rules: { - 'no-restricted-imports': 'off', - }, - }, - - { - files: ['scripts/**/*.{js,mjs}'], - languageOptions: { - globals: { - console: 'readonly', - process: 'readonly', - Buffer: 'readonly', - fetch: 'readonly', - URL: 'readonly', - setTimeout: 'readonly', - clearTimeout: 'readonly', - __dirname: 'readonly', - }, - }, - }, - - { - files: ['packages/**/*.{ts,tsx}', 'apps/**/*.{ts,tsx}'], - rules: { - 'import-x/no-restricted-paths': ['error', { - zones: [ - { - // `src/**` in `from` catches a moved file that kept its old `@server/*` import (re-coupling). - target: 'packages/schema/**/*', - from: ['src/**/*', 'test/**/*', 'scripts/**/*', 'packages/core/**/*', 'packages/provider-github/**/*', 'packages/db/**/*', 'packages/models/**/*', 'packages/api/**/*', 'packages/ui/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] - }, - { - target: 'packages/core/**/*', - from: ['src/**/*', 'test/**/*', 'scripts/**/*', 'packages/provider-github/**/*', 'packages/db/**/*', 'packages/models/**/*', 'packages/api/**/*', 'packages/ui/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] - }, - { - target: 'packages/db/**/*', - from: ['packages/provider-github/**/*', 'packages/models/**/*', 'packages/api/**/*', 'packages/ui/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] - }, - { - target: 'packages/provider-github/**/*', - from: ['packages/db/**/*', 'packages/models/**/*', 'packages/api/**/*', 'packages/ui/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] - }, - { - target: 'packages/models/**/*', - from: ['packages/db/**/*', 'packages/provider-github/**/*', 'packages/api/**/*', 'packages/ui/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] - }, - { - target: 'packages/api/**/*', - from: ['packages/ui/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] - }, - { - target: 'packages/ui/**/*', - from: ['src/**/*', 'packages/core/**/*', 'packages/provider-github/**/*', 'packages/db/**/*', 'packages/models/**/*', 'packages/api/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] - }, - { - target: 'apps/dashboard/**/*', - from: ['packages/core/**/*', 'packages/provider-github/**/*', 'packages/db/**/*', 'packages/models/**/*', 'packages/api/**/*', 'apps/worker/**/*'] - }, - { - target: 'apps/worker/**/*', - from: ['packages/ui/**/*', 'apps/dashboard/**/*'] - } - ] - }] - } - } -); +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import importX from 'eslint-plugin-import-x'; +import reactHooks from 'eslint-plugin-react-hooks'; +import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'; + +export default tseslint.config( + { + ignores: [ + // `**/` needed: bare `dist/**` misses packages/*/dist, linting emitted .d.ts as source. + '**/dist/**', + '**/node_modules/**','**/.wrangler/**', + 'apps/worker/src/worker-env.d.ts', + 'worker-configuration.d.ts', + ], + }, + + js.configs.recommended, + ...tseslint.configs.recommended, + + { + files: ['**/*.{ts,tsx,js,mjs}'], + plugins: { 'import-x': importX, 'react-hooks': reactHooks }, + settings: { + 'import-x/resolver-next': [ + createTypeScriptImportResolver({ project: './tsconfig.json' }), + ], + }, + rules: { + // TS resolves identifiers correctly (incl. types, `declare`, lib globals); this is redundant and worse. + 'no-undef': 'off', + + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['error', { + caughtErrors: 'none', + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }], + + // Not core `no-duplicate-imports`: it's type-blind, flags `import {X}` + `import type {Y}` split as dupe. + 'import-x/no-duplicates': 'error', + 'import-x/no-self-import': 'error', + 'import-x/no-cycle': 'error', + + 'max-lines': ['error', { max: 400, skipBlankLines: true, skipComments: true }], + + 'react-hooks/exhaustive-deps': 'error', + + // Fires on emoji/variation-selector stripping in finding-title normalizer; that's intentional, test-pinned. + 'no-misleading-character-class': 'off', + + // Deliberate at provider/DB boundaries with unknown shape until parsed; ~100 suppressions otherwise. + '@typescript-eslint/no-explicit-any': 'off', + }, + }, + + { + files: ['apps/dashboard/**/*.{ts,tsx}'], + rules: { + 'react-hooks/rules-of-hooks': 'error', + + // Zone block at file bottom only covers packages/**+apps/**, so it can't catch this direction. + 'import-x/no-restricted-paths': ['error', { + zones: [ + { + target: 'apps/dashboard/**/*', + from: ['packages/core/**/*', 'apps/worker/**/*'], + message: 'The review engine and the Worker tree are server-only. Importing either pulls zod/jsonrepair/picomatch into the browser bundle -- exactly what the `vite build` CI step exists to catch. (@codraoss/schema/review-limits is the sanctioned client-side import.)' + } + ] + }], + }, + }, + + { + // vi.mock() intercepts these specifiers by string; list both `@alias/...` and `**/dir/...` forms since sibling imports and tsconfig-alias imports otherwise bypass it. + files: ['apps/**/*.{ts,tsx}', 'test/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': ['error', { + patterns: [ + { group: ['**/db/jobs-*', '@codraoss/db/jobs-*'], message: 'Import from @codraoss/db/jobs, not a sibling. Eight specs vi.mock that specifier; a direct sibling import silently bypasses the mock.' }, + { group: ['**/db/file-reviews-*', '@codraoss/db/file-reviews-*'], message: 'Import from @codraoss/db/file-reviews, not a sibling. (No spec mocks this one today; the rule keeps the barrel the single entry point.)' }, + { group: ['**/services/model-review-*', '**/services/model-rate-limits', '**/services/model-chain-runner', '**/services/model-support', '@codraoss/models-*'], message: 'Import from @codraoss/models, not a sibling. Four specs vi.mock that specifier.' }, + { group: ['**/provider-github/src/http', '**/provider-github/src/app-auth', '**/provider-github/src/types', '**/provider-github/src/diff-fetch', '**/provider-github/src/review-post', '**/provider-github/src/labels'], message: 'Import from @codraoss/provider-github, not a sibling module. One spec vi.mocks that specifier. (oauth is deliberately NOT listed: it is the dashboard OAuth flow, not part of the GitHubClient barrel.)' }, + { group: ['**/core/review/*', '@server/core/review/*', '@codraoss/core/review/*'], message: 'Import from @server/core/review, not a sibling. One spec vi.mocks that specifier and workflows/review.ts imports only runReviewJob from it.' }, + { group: ['**/core/model-output/*', '@server/core/model-output/*', '@codraoss/core/model-output/*'], message: 'Import from @codraoss/core/model-output, not a sibling. (The package exports map already refuses to resolve these; the lint rule gives the error at edit time.)' }, + { group: ['**/core/diff/position', '@server/core/diff/position', '@codraoss/core/diff/position'], message: 'Import from @codraoss/core/diff, not a sibling.' }, + { group: ['**/schema-claims', '**/schema-repo-config', '**/schema-enums', '@codraoss/schema/schema-claims', '@codraoss/schema/schema-repo-config', '@codraoss/schema/schema-enums'], message: 'Import from @codraoss/schema, not a sibling. (@codraoss/schema/review-limits is exempt: the client imports it directly to keep zod out of the browser bundle.)' }, + ], + }], + }, + }, + { + // auth.spec.ts (422 lines): suites read-modify-write singleton global_settings, racing under fileParallelism (see DO-NOT-SPLIT header there). Delete entry, don't raise max, once split. + files: ['test/api/auth.spec.ts'], + rules: { + 'max-lines': 'off', + }, + }, + + { + files: [ + 'packages/schema/src/schema.ts', + ], + rules: { + 'no-restricted-imports': 'off', + }, + }, + + { + files: ['scripts/**/*.{js,mjs}'], + languageOptions: { + globals: { + console: 'readonly', + process: 'readonly', + Buffer: 'readonly', + fetch: 'readonly', + URL: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + __dirname: 'readonly', + }, + }, + }, + + { + files: ['packages/**/*.{ts,tsx}', 'apps/**/*.{ts,tsx}'], + rules: { + 'import-x/no-restricted-paths': ['error', { + zones: [ + { + // `src/**` in `from` catches a moved file that kept its old `@server/*` import (re-coupling). + target: 'packages/schema/**/*', + from: ['test/**/*', 'scripts/**/*', 'packages/core/**/*', 'packages/provider-github/**/*', 'packages/db/**/*', 'packages/models/**/*', 'packages/api/**/*', 'packages/ui/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] + }, + { + target: 'packages/core/**/*', + from: ['test/**/*', 'scripts/**/*', 'packages/provider-github/**/*', 'packages/db/**/*', 'packages/models/**/*', 'packages/api/**/*', 'packages/ui/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] + }, + { + target: 'packages/db/**/*', + from: ['packages/provider-github/**/*', 'packages/models/**/*', 'packages/api/**/*', 'packages/ui/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] + }, + { + target: 'packages/provider-github/**/*', + from: ['packages/db/**/*', 'packages/models/**/*', 'packages/api/**/*', 'packages/ui/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] + }, + { + target: 'packages/models/**/*', + from: ['packages/db/**/*', 'packages/provider-github/**/*', 'packages/api/**/*', 'packages/ui/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] + }, + { + target: 'packages/api/**/*', + from: ['packages/ui/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] + }, + { + target: 'packages/ui/**/*', + from: ['packages/core/**/*', 'packages/provider-github/**/*', 'packages/db/**/*', 'packages/models/**/*', 'packages/api/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] + }, + { + target: 'apps/dashboard/**/*', + from: ['packages/core/**/*', 'packages/provider-github/**/*', 'packages/db/**/*', 'packages/models/**/*', 'packages/api/**/*', 'apps/worker/**/*'] + }, + { + target: 'apps/worker/**/*', + from: ['packages/ui/**/*', 'apps/dashboard/**/*'] + } + ] + }] + } + } +); diff --git a/package.json b/package.json index 876c9bb1..d04aa34b 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "codra", "version": "0.9.4", "description": "Open-source code review engine", + "private": true, "author": "Devarshi Shimpi", "license": "AGPL-3.0-only", "type": "module", @@ -26,7 +27,7 @@ "dev": "concurrently -k -n CLIENT,WORKER -c cyan,green \"npm:dev:client\" \"npm:dev:worker\"", "dev:client": "vite build --watch --mode development", "dev:worker": "cd apps/worker && wrangler dev --local --env-file ../../.dev.vars", - "lint": "eslint src test scripts packages apps", + "lint": "eslint test scripts packages apps", "lint:all": "npm run lint --workspaces --if-present", "start": "npm run dev", "setup:cloudflare": "node scripts/setup-cloudflare.js", diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 2a16588c..b9e12612 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,2 +1,18 @@ export { createApiRouter } from './router'; -export type { ApiRouterDeps, ApiEnv, RepositoriesPort, ConfigPort, PlatformPort } from './ports'; +export type { ApiRouterOptions } from './router'; +export type { + ApiRouterDeps, + ApiEnv, + RepositoriesPort, + ConfigPort, + PlatformPort, + AuthzPort, + AuthorizeContext, + AuthorizeResult, + QuotaCheckInput, + QuotaResult, +} from './ports'; +// Exported so an app embedding this router can reuse the same guards on its own routes. +export { requirePermission, requireQuota } from './middleware/authorize'; +export { requireSession } from './middleware/auth'; +export { requireCsrfHeader } from './middleware/csrf'; diff --git a/packages/api/src/middleware/authorize.ts b/packages/api/src/middleware/authorize.ts new file mode 100644 index 00000000..3497b399 --- /dev/null +++ b/packages/api/src/middleware/authorize.ts @@ -0,0 +1,55 @@ +import type { Context } from 'hono'; +import type { ApiAction } from '@codraoss/schema/api'; +import type { ApiEnv, QuotaCheckInput } from '../ports'; + +// Guards return a Response to short-circuit with, or null to continue. +// Both are inert when the corresponding port is not provided. + +export async function requirePermission( + c: Context, + action: ApiAction, + resource?: { type: string; id?: string }, +): Promise { + const authz = c.env.deps.authz; + if (!authz) return null; + + const user = c.get('sessionUser'); + if (!user) { + return c.json({ error: 'Unauthorized', code: 'unauthorized', action, reason: null }, 401); + } + + const result = await authz.authorize({ user, action, resource }); + if (result.allowed) return null; + + return c.json( + { error: 'Forbidden', code: 'forbidden', action, reason: result.reason ?? null }, + 403, + ); +} + +export async function requireQuota( + c: Context, + input: Omit, +): Promise { + const checkQuota = c.env.deps.checkQuota; + if (!checkQuota) return null; + + const user = c.get('sessionUser') ?? undefined; + const result = await checkQuota({ ...input, user }); + if (result.allowed) return null; + + const headers = result.retryAfterSeconds + ? { 'Retry-After': String(result.retryAfterSeconds) } + : undefined; + + return c.json( + { + error: 'Too many requests', + code: 'quota_exceeded', + action: input.action, + reason: result.reason ?? null, + }, + 429, + headers, + ); +} diff --git a/packages/api/src/ports.ts b/packages/api/src/ports.ts index 72d0f05e..dd6c394c 100644 --- a/packages/api/src/ports.ts +++ b/packages/api/src/ports.ts @@ -1,4 +1,5 @@ import type { DashboardSessionUser, SessionStore, ReviewRuntime } from '@codraoss/core/ports'; +import type { ApiAction } from '@codraoss/schema/api'; // Type stubs that represent what the API layer requires. // By importing types from @codraoss/db, we avoid a runtime dependency while retaining type safety. @@ -76,6 +77,32 @@ export interface WebhookPort { extractReviewRequest: (input: any) => any; } +export interface AuthorizeContext { + user: DashboardSessionUser; + action: ApiAction; + resource?: { type: string; id?: string }; +} + +export type AuthorizeResult = { allowed: true } | { allowed: false; reason?: string }; + +export interface AuthzPort { + authorize(ctx: AuthorizeContext): Promise; + // Returning undefined means "no restrictions"; computed per request, not stored on the session. + listPermissions?(user: DashboardSessionUser): Promise; +} + +export interface QuotaCheckInput { + action: ApiAction; + // Absent on the webhook path, which runs without a signed-in user. + user?: DashboardSessionUser; + subject?: { installationId?: string; owner?: string; repo?: string }; + cost?: number; +} + +export type QuotaResult = + | { allowed: true } + | { allowed: false; retryAfterSeconds?: number; reason?: string }; + export interface ApiRouterDeps { repositories: RepositoriesPort; gitProvider: { @@ -89,6 +116,8 @@ export interface ApiRouterDeps { platform: PlatformPort; authProvider: AuthProviderPort; webhook: WebhookPort; + authz?: AuthzPort; + checkQuota?: (input: QuotaCheckInput) => Promise; } export interface ApiEnv { diff --git a/packages/api/src/router.ts b/packages/api/src/router.ts index 6b583704..9a5c1564 100644 --- a/packages/api/src/router.ts +++ b/packages/api/src/router.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import type { Context } from 'hono'; +import type { Context, MiddlewareHandler } from 'hono'; import type { ApiEnv } from './ports'; import { requireSession } from './middleware/auth'; import { requireCsrfHeader } from './middleware/csrf'; @@ -24,10 +24,24 @@ async function serveIndex(c: Context) { return c.text('Not Found: Please mount UI static assets handler here.', 404); } -export function createApiRouter() { +export interface ApiRouterOptions { + // Cross-cutting request middleware; also sees /webhook and /auth, which the /api/* guards skip. + beforeAuth?: MiddlewareHandler[]; + // Runs on /api/* after the session and CSRF guards, so `sessionUser` is populated. + afterAuth?: MiddlewareHandler[]; + pages?: string[]; + publicPages?: string[]; + // Called last, so paths added under /api/* still inherit the session, CSRF and afterAuth middleware. + routes?: (app: Hono) => void; +} + +export function createApiRouter(options: ApiRouterOptions = {}) { const app = new Hono(); app.use('*', observability); + for (const middleware of options.beforeAuth ?? []) { + app.use('*', middleware); + } app.use('/auth/logout', requireSession); app.use('/auth/logout', requireCsrfHeader); @@ -36,6 +50,9 @@ export function createApiRouter() { app.use('/api/*', requireSession); app.use('/api/*', requireCsrfHeader); + for (const middleware of options.afterAuth ?? []) { + app.use('/api/*', middleware); + } app.route('/api/auth', createAuthApiRouter()); app.route('/api/jobs', createJobsRouter()); @@ -55,5 +72,14 @@ export function createApiRouter() { app.get('/settings', requireSession, serveIndex); app.get('/account', requireSession, serveIndex); + for (const path of options.publicPages ?? []) { + app.get(path, serveIndex); + } + for (const path of options.pages ?? []) { + app.get(path, requireSession, serveIndex); + } + + options.routes?.(app); + return app; } diff --git a/packages/api/src/routes/api/auth.ts b/packages/api/src/routes/api/auth.ts index 0c6327aa..f1c62a7e 100644 --- a/packages/api/src/routes/api/auth.ts +++ b/packages/api/src/routes/api/auth.ts @@ -3,6 +3,7 @@ import { Hono } from 'hono'; import { z } from 'zod'; import { jsonError } from '../../http'; import type { ApiEnv } from '../../ports'; +import { requirePermission } from '../../middleware/authorize'; const emailSchema = z.strictObject({ email: z.string().trim().email().max(254), @@ -28,7 +29,10 @@ export function createAuthApiRouter() { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } - return c.json({ user: sessionUser }); + // Omitted when nothing is restricted, which the dashboard reads as "every action allowed". + const permissions = await c.env.deps.authz?.listPermissions?.(sessionUser); + + return c.json(permissions ? { user: sessionUser, permissions } : { user: sessionUser }); }); app.get('/account', async (c) => { @@ -55,6 +59,8 @@ export function createAuthApiRouter() { }); app.patch('/account', async (c) => { + const denied = await requirePermission(c, 'account.write'); + if (denied) return denied; const sessionUser = c.get('sessionUser'); if (!sessionUser) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); @@ -107,6 +113,8 @@ export function createAuthApiRouter() { }); app.post('/updates-email', async (c) => { + const denied = await requirePermission(c, 'account.updatesEmail.write'); + if (denied) return denied; const sessionUser = c.get('sessionUser'); if (!sessionUser) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); diff --git a/packages/api/src/routes/api/jobs.ts b/packages/api/src/routes/api/jobs.ts index 721d9859..5cad8292 100644 --- a/packages/api/src/routes/api/jobs.ts +++ b/packages/api/src/routes/api/jobs.ts @@ -5,6 +5,7 @@ import { jsonError } from '../../http'; import { parseUnifiedDiff } from '@codraoss/core/diff'; import { buildFileReviewPrompts, changelogExcerptFromDiff, wantsFileContext } from '@codraoss/core/prompts/file-review'; import type { ApiEnv } from '../../ports'; +import { requirePermission, requireQuota } from '../../middleware/authorize'; // Best-effort: .get()/.terminate() throw if instance is gone/already terminal; both non-fatal. async function terminateJobWorkflow(c: Context, job: { id: string; workflowInstanceId?: string | null }) { @@ -28,6 +29,8 @@ export function createJobsRouter() { const app = new Hono(); app.get('/', async (c) => { + const denied = await requirePermission(c, 'jobs.read'); + if (denied) return denied; c.env.deps.platform.scheduleBestEffortJobMaintenance(getExecutionContext(c)); const rawQuery = c.req.query(); @@ -38,6 +41,8 @@ export function createJobsRouter() { }); app.get('/:id', async (c) => { + const denied = await requirePermission(c, 'jobs.read', { type: 'job', id: c.req.param('id') }); + if (denied) return denied; c.env.deps.platform.scheduleBestEffortJobMaintenance(getExecutionContext(c)); const job = await c.env.deps.repositories.jobs.getJobDetail(c.env as any, c.req.param('id')); @@ -66,6 +71,8 @@ export function createJobsRouter() { // diff_input isn't persisted; rebuilt from the job's own base/head commits (not the live PR), via KV cache. app.get('/:id/diffs', async (c) => { + const denied = await requirePermission(c, 'jobs.read', { type: 'job', id: c.req.param('id') }); + if (denied) return denied; const job = await c.env.deps.repositories.jobs.getJobDetail(c.env as any, c.req.param('id')); if (!job) { return jsonError('Job not found.', 404); @@ -171,6 +178,10 @@ export function createJobsRouter() { } app.post('/:id/retry', async (c) => { + const denied = await requirePermission(c, 'jobs.retry', { type: 'job', id: c.req.param('id') }); + if (denied) return denied; + const throttled = await requireQuota(c, { action: 'jobs.retry' }); + if (throttled) return throttled; const jobs = c.env.deps.repositories.jobs; const rawSource = await jobs.getJobForProcessing(c.env as any, c.req.param('id')); if (!rawSource) { @@ -182,6 +193,10 @@ export function createJobsRouter() { // No inheritance; stops the current run first so two workflows can't race. app.post('/:id/rerun', async (c) => { + const denied = await requirePermission(c, 'jobs.rerun', { type: 'job', id: c.req.param('id') }); + if (denied) return denied; + const throttled = await requireQuota(c, { action: 'jobs.rerun' }); + if (throttled) return throttled; const jobs = c.env.deps.repositories.jobs; const rawSource = await jobs.getJobForProcessing(c.env as any, c.req.param('id')); if (!rawSource) { @@ -196,6 +211,8 @@ export function createJobsRouter() { }); app.post('/:id/stop', async (c) => { + const denied = await requirePermission(c, 'jobs.stop', { type: 'job', id: c.req.param('id') }); + if (denied) return denied; const jobs = c.env.deps.repositories.jobs; const id = c.req.param('id'); const raw = await jobs.getJobForProcessing(c.env as any, id); @@ -214,6 +231,8 @@ export function createJobsRouter() { // "wrong" suppresses this finding repo-wide; "right" is measurement only. app.put('/:id/findings/:fingerprint/label', async (c) => { + const denied = await requirePermission(c, 'jobs.label', { type: 'job', id: c.req.param('id') }); + if (denied) return denied; const jobId = c.req.param('id'); const fingerprint = c.req.param('fingerprint'); @@ -240,6 +259,8 @@ export function createJobsRouter() { // Scoped to dashboard rows; a real GitHub deletion still stays recorded. app.delete('/:id/findings/:fingerprint/label', async (c) => { + const denied = await requirePermission(c, 'jobs.label', { type: 'job', id: c.req.param('id') }); + if (denied) return denied; const jobId = c.req.param('id'); const fingerprint = c.req.param('fingerprint'); @@ -251,6 +272,8 @@ export function createJobsRouter() { }); app.delete('/:id', async (c) => { + const denied = await requirePermission(c, 'jobs.delete', { type: 'job', id: c.req.param('id') }); + if (denied) return denied; const jobs = c.env.deps.repositories.jobs; const id = c.req.param('id'); const raw = await jobs.getJobForProcessing(c.env as any, id); diff --git a/packages/api/src/routes/api/models.ts b/packages/api/src/routes/api/models.ts index c5727d39..3b6c4edd 100644 --- a/packages/api/src/routes/api/models.ts +++ b/packages/api/src/routes/api/models.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { jsonError } from '../../http'; import { llmApiFormats } from '@codraoss/schema'; import type { ApiEnv } from '../../ports'; +import { requirePermission, requireQuota } from '../../middleware/authorize'; const apiFormatSchema = z.enum(llmApiFormats); const positiveIntegerSchema = z.number().int().positive().finite(); @@ -59,6 +60,8 @@ export function createModelsRouter() { const app = new Hono(); app.get('/', async (c) => { + const denied = await requirePermission(c, 'models.read'); + if (denied) return denied; const modelConfigsRepo = c.env.deps.repositories.modelConfigs; const [providers, configs] = await Promise.all([ modelConfigsRepo.listLlmProviders(c.env as any), @@ -68,6 +71,8 @@ export function createModelsRouter() { }); app.post('/sync', async (c) => { + const denied = await requirePermission(c, 'models.sync'); + if (denied) return denied; const modelConfigsRepo = c.env.deps.repositories.modelConfigs; const syncErrors = await c.env.deps.modelRunner.syncProviderModelCatalog(); const [providers, configs] = await Promise.all([ @@ -78,11 +83,15 @@ export function createModelsRouter() { }); app.get('/global', async (c) => { + const denied = await requirePermission(c, 'models.read'); + if (denied) return denied; const config = await c.env.deps.config.getGlobalConfig(); return c.json({ config }); }); app.patch('/global', async (c) => { + const denied = await requirePermission(c, 'models.global.write'); + if (denied) return denied; const body = await c.req.json(); const parsed = globalModelConfigSchema.safeParse(body); if (!parsed.success) { @@ -94,6 +103,8 @@ export function createModelsRouter() { }); app.post('/providers', async (c) => { + const denied = await requirePermission(c, 'models.provider.create'); + if (denied) return denied; const parsed = providerCreateSchema.safeParse(await c.req.json()); if (!parsed.success) { return jsonError('Invalid provider config.', 400); @@ -123,6 +134,8 @@ export function createModelsRouter() { }); app.patch('/providers/:id', async (c) => { + const denied = await requirePermission(c, 'models.provider.update', { type: 'llmProvider', id: c.req.param('id') }); + if (denied) return denied; const id = c.req.param('id'); if (!providerIdSchema.safeParse(id).success) { return jsonError('Invalid provider id.', 400); @@ -158,6 +171,8 @@ export function createModelsRouter() { }); app.delete('/providers/:id', async (c) => { + const denied = await requirePermission(c, 'models.provider.delete', { type: 'llmProvider', id: c.req.param('id') }); + if (denied) return denied; const id = c.req.param('id'); if (!providerIdSchema.safeParse(id).success) { return jsonError('Invalid provider id.', 400); @@ -171,6 +186,10 @@ export function createModelsRouter() { }); app.post('/:id/test', async (c) => { + const denied = await requirePermission(c, 'models.test', { type: 'modelConfig', id: c.req.param('id') }); + if (denied) return denied; + const throttled = await requireQuota(c, { action: 'models.test' }); + if (throttled) return throttled; const modelId = readModelIdParam(c.req.param('id')); const parsedModelId = modelIdSchema.safeParse(modelId); if (!parsedModelId.success) { @@ -193,6 +212,8 @@ export function createModelsRouter() { }); app.post('/:id', async (c) => { + const denied = await requirePermission(c, 'models.mapping.write', { type: 'modelConfig', id: c.req.param('id') }); + if (denied) return denied; const modelId = readModelIdParam(c.req.param('id')); const parsedModelId = modelIdSchema.safeParse(modelId); if (!parsedModelId.success) { @@ -216,6 +237,8 @@ export function createModelsRouter() { }); app.delete('/:id', async (c) => { + const denied = await requirePermission(c, 'models.mapping.write', { type: 'modelConfig', id: c.req.param('id') }); + if (denied) return denied; const modelId = readModelIdParam(c.req.param('id')); const parsedModelId = modelIdSchema.safeParse(modelId); if (!parsedModelId.success) { diff --git a/packages/api/src/routes/api/repos.ts b/packages/api/src/routes/api/repos.ts index c10bc2a3..265c2285 100644 --- a/packages/api/src/routes/api/repos.ts +++ b/packages/api/src/routes/api/repos.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono'; import { z } from 'zod'; import type { ApiEnv } from '../../ports'; +import { requirePermission, requireQuota } from '../../middleware/authorize'; import { jsonError } from '../../http'; import { repoConfigSchema } from '@codraoss/schema'; @@ -39,11 +40,15 @@ export function createReposRouter() { const app = new Hono(); app.get('/', async (c) => { + const denied = await requirePermission(c, 'repos.read'); + if (denied) return denied; const repos = await c.env.deps.repositories.repoConfigs.listRepoConfigs(c.env as any); return c.json({ repos }); }); app.get('/install', async (c) => { + const denied = await requirePermission(c, 'repos.install'); + if (denied) return denied; try { return c.redirect(await c.env.deps.gitProvider.getAppInstallationUrl(), 302); } catch (error) { @@ -53,6 +58,10 @@ export function createReposRouter() { }); app.post('/sync', async (c) => { + const denied = await requirePermission(c, 'repos.sync'); + if (denied) return denied; + const throttled = await requireQuota(c, { action: 'repos.sync' }); + if (throttled) return throttled; try { const installations = await c.env.deps.gitProvider.listInstallations(); const synced: string[] = []; @@ -102,6 +111,8 @@ export function createReposRouter() { }); app.get('/:owner/:repo/config', async (c) => { + const denied = await requirePermission(c, 'repos.read', { type: 'repo', id: `${c.req.param('owner')}/${c.req.param('repo')}` }); + if (denied) return denied; const repo = await c.env.deps.repositories.repoConfigs.getRepoConfigRecord(c.env as any, c.req.param('owner'), c.req.param('repo')); if (!repo) { return jsonError('Repository config not found.', 404); @@ -111,6 +122,8 @@ export function createReposRouter() { }); app.patch('/:owner/:repo/config', async (c) => { + const denied = await requirePermission(c, 'repos.config.write', { type: 'repo', id: `${c.req.param('owner')}/${c.req.param('repo')}` }); + if (denied) return denied; const { owner, repo } = c.req.param(); const body = await c.req.json(); const parsedPatch = repoConfigPatchSchema.safeParse(body); diff --git a/packages/api/src/routes/api/settings.ts b/packages/api/src/routes/api/settings.ts index 774f95ce..10ab4d2b 100644 --- a/packages/api/src/routes/api/settings.ts +++ b/packages/api/src/routes/api/settings.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono'; import { z } from 'zod'; import type { ApiEnv } from '../../ports'; +import { requirePermission } from '../../middleware/authorize'; import { jsonError } from '../../http'; import { reviewConcurrencyLevels, reviewMaxCommentsOptions, reviewMaxFilesRange, reviewSettingsSchema } from '@codraoss/schema'; @@ -20,11 +21,15 @@ export function createSettingsRouter() { const app = new Hono(); app.get('/', async (c) => { + const denied = await requirePermission(c, 'settings.read'); + if (denied) return denied; const settings = await c.env.deps.repositories.appSettings.getReviewSettings(c.env as any); return c.json({ settings }); }); app.patch('/', async (c) => { + const denied = await requirePermission(c, 'settings.write'); + if (denied) return denied; const body = await c.req.json().catch(() => null); const parsed = reviewSettingsPatchSchema.safeParse(body); if (!parsed.success) { diff --git a/packages/api/src/routes/api/stats.ts b/packages/api/src/routes/api/stats.ts index 4b9f0a9c..514d0159 100644 --- a/packages/api/src/routes/api/stats.ts +++ b/packages/api/src/routes/api/stats.ts @@ -1,10 +1,13 @@ import { Hono } from 'hono'; import type { ApiEnv } from '../../ports'; +import { requirePermission } from '../../middleware/authorize'; export function createStatsRouter() { const app = new Hono(); app.get('/', async (c) => { + const denied = await requirePermission(c, 'stats.read'); + if (denied) return denied; const daysParam = c.req.query('days'); const days = daysParam ? parseInt(daysParam, 10) : 30; // Grouped in the caller's display zone so the trend matches timestamps shown elsewhere; getStats falls back to UTC if invalid. diff --git a/packages/api/src/routes/webhook.ts b/packages/api/src/routes/webhook.ts index 2daad75a..31819e49 100644 --- a/packages/api/src/routes/webhook.ts +++ b/packages/api/src/routes/webhook.ts @@ -191,6 +191,19 @@ export async function handleGitHubWebhook(c: Context) { }, 202); } + const throttled = await c.env.deps.checkQuota?.({ + action: 'reviews.enqueue', + subject: { + installationId: extracted.installationId, + owner: extracted.owner, + repo: extracted.repo, + }, + }); + if (throttled && !throttled.allowed) { + // Deliberately 202, not 429: GitHub redelivers failed webhooks, so 4xx causes retry storms. + return c.json({ ok: true, ignored: true, reason: 'quota_exceeded' }, 202); + } + const job = await jobsRepo.insertJob(c.env as any, { installationId: extracted.installationId, owner: extracted.owner, diff --git a/packages/core/src/claim-checks.ts b/packages/core/src/claim-checks.ts index 0eecd04f..8290bca1 100644 --- a/packages/core/src/claim-checks.ts +++ b/packages/core/src/claim-checks.ts @@ -1,331 +1,331 @@ -// SOUNDNESS, binding on every change: `refuted` asserts only that "X does not appear" is FALSE. There is no `confirmed` verdict, since a check that can confirm findings manufactures them. Losing a refutation is free; a wrong one silences a real defect. -import type { FileDiff } from './diff'; -import { normalizeDiffText } from './fingerprint'; - -const PROXIMITY_WINDOW_LINES = 25; - -const MIN_IDENTIFIER_LENGTH = 3; - -const ABSENCE_PATTERNS: readonly RegExp[] = [ - /\b(?:never|not|no longer)\s+(?:being\s+)?(?:passed|provided|supplied|forwarded|included|used|called|invoked|awaited|checked|set|declared|defined|imported)\b/i, - /\bdoes not\s+(?:pass|include|call|use|await|check|set|import)\b/i, - /\bfails to\s+(?:pass|include|call|await|check|import)\b/i, - /\bwithout\s+(?:passing|including|calling|awaiting|checking|importing)\b/i, - /\b(?:missing|omitted|absent)\b/i, - /\bis not defined\b/i, -]; - -const IDENTIFIER_STOPLIST = new Set([ - 'await', 'async', 'if', 'else', 'try', 'catch', 'finally', 'return', 'throw', 'new', 'const', - 'let', 'var', 'function', 'class', 'this', 'super', 'import', 'export', 'from', 'default', - 'null', 'undefined', 'true', 'false', 'void', 'typeof', 'instanceof', 'delete', 'yield', - 'props', 'state', 'error', 'err', 'data', 'value', 'key', 'id', 'type', 'name', 'index', - 'result', 'response', 'request', 'req', 'res', 'params', 'options', 'config', 'args', -]); - -const VERSION_CLAIM_PATTERNS: readonly RegExp[] = [ - /\b(?:does not|doesn't|do not|don't)\s+exist\b/i, - /\b(?:non-?existent|nonexistent)\b/i, - /\bis not a valid\b/i, - /\blatest (?:major )?version\b/i, - /\bno such (?:version|tag|release)\b/i, - /\bnot a valid (?:configuration )?(?:option|key|property)\b/i, - /\b(?:does not|doesn't|do not|don't)\s+(?:expose|provide|have|support|include|offer)\b/i, - /\bno such (?:function|method|export|property|api|field)\b/i, - /\bis not (?:exposed|exported|available) (?:by|from|in)\b/i, -]; - -// Same soundness rule as the absence checker above. - -const CROSS_FILE_SUBJECT = /\b(?:other|another|external|downstream|consuming|importing|dependent|calling)\s+(?:module|file|component|caller|package|consumer|import)s?\b/i; -const CROSS_FILE_CONSEQUENCE = /\b(?:break|breaks|breaking|broken|fail|fails|failing|error|errors|cannot import|can't import|unable to|compilation|compile|prevent|prevents|preventing|block|blocks|blocking)\b/i; - -const ENVIRONMENT_HEDGE = /\b(?:depending on|might not|may not|could be undefined|if (?:this|the|it)\b[^.]{0,60}\b(?:is )?(?:rendered|run|executed|used)\b)/i; -const ENVIRONMENT_SUBJECT = /\b(?:older|legacy|earlier|some)\s+(?:node(?:\.js)?|browsers?|runtimes?|environments?|engines?|versions?)\b|\bserver[- ]side\b|\bSSR\b|\bhydration\b|\bpolyfill\b|\bis not defined on the server\b/i; - -const CALLEE_FAILURE_CONDITION = /\b(?:if|when|should|were)\b(?:(?!\.\s)[^;!?]){0,62}\b(?:fails?|failing|rejects?|rejecting|throws?|throwing|errors? out)\b/i; -const CALLEE_CALL_SHAPE = /[\w.$]{1,50}\s*\(\s*\)|`[\w.$]{1,50}\(/; -const CALLEE_UNHANDLED_OUTCOME = /\bunhandled\b|\bunhandled promise\b|\bnot (?:caught|handled)\b|\bno (?:\.)?catch\b|\bwithout (?:a )?(?:try|catch)\b|\bcrash\b/i; - -export type UndecidableClaimReason = 'cross-file' | 'environment' | 'callee-errors'; - -/** Refutes a claim whose truth lives outside the diff; two signals per family, since one is ordinary. */ -export function refuteUndecidableClaim(input: { title: string; body: string }): UndecidableClaimReason | null { - const text = `${input.title}\n${input.body}`; - - if (CROSS_FILE_SUBJECT.test(text) && CROSS_FILE_CONSEQUENCE.test(text)) return 'cross-file'; - if (ENVIRONMENT_HEDGE.test(text) && ENVIRONMENT_SUBJECT.test(text)) return 'environment'; - if (CALLEE_FAILURE_CONDITION.test(text) && CALLEE_CALL_SHAPE.test(text) && CALLEE_UNHANDLED_OUTCOME.test(text)) { - return 'callee-errors'; - } - - return null; -} - -const FULL_SHA_PATTERN = /\b[0-9a-f]{40}\b/; - -export function looksLikeExternalVersionClaim(title: string, body: string): boolean { - const text = `${title}\n${body}`; - return VERSION_CLAIM_PATTERNS.some((pattern) => pattern.test(text)); -} - -export function isVersionClaimRefutedByPin(input: { title: string; body: string; anchorContent: string }): boolean { - if (!looksLikeExternalVersionClaim(input.title, input.body)) return false; - return FULL_SHA_PATTERN.test(input.anchorContent); -} - -/** One line the identifier could be found on; `hunkIndex` is null for lines from the post-image. */ -type PresenceEntry = { newLineNumber: number | undefined; hunkIndex: number | null; code: string }; - -export type PresenceIndex = { - byToken: Map; - entries: PresenceEntry[]; - hunkByLine: Map; -}; - -export type AbsenceClaimVerdict = - | { - status: 'unknown'; - reason: - | 'not_absence_shaped' - | 'no_identifier' - | 'ambiguous_identifier' - | 'stoplisted' - | 'not_present' - | 'out_of_window'; - } - | { status: 'refuted'; identifier: string; line: number | undefined }; - -type CommentSyntax = { line: readonly string[]; block: boolean }; - -// Must stay complete: a misclassified file keeps comment text as code, refuting real absence claims. -const HASH_COMMENT_EXTENSIONS = new Set([ - 'py', 'pyi', 'rb', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', - 'tf', 'tfvars', 'hcl', 'pl', 'pm', 'r', 'jl', 'nim', 'cr', 'ex', 'exs', 'elixir', 'gemspec', - 'dockerfile', 'containerfile', 'mk', 'cmake', 'gradle', 'properties', 'env', 'gitignore', - 'dockerignore', 'editorconfig', -]); - -const HASH_COMMENT_FILENAMES = new Set([ - 'dockerfile', 'containerfile', 'makefile', 'gnumakefile', 'rakefile', 'gemfile', 'brewfile', - 'procfile', 'vagrantfile', 'justfile', 'cmakelists.txt', '.gitignore', '.dockerignore', '.env', -]); - -export function commentSyntaxFor(path: string): CommentSyntax { - const name = path.toLowerCase().split('/').pop() ?? ''; - if (HASH_COMMENT_FILENAMES.has(name)) return { line: ['#'], block: false }; - - const ext = name.includes('.') ? name.split('.').pop() ?? '' : ''; - if (HASH_COMMENT_EXTENSIONS.has(ext)) return { line: ['#'], block: false }; - - if (ext === 'sql') return { line: ['--'], block: true }; - if (ext === 'lua') return { line: ['--'], block: true }; - if (ext === 'hs' || ext === 'elm' || ext === 'ada') return { line: ['--'], block: false }; - if (ext === 'vim') return { line: ['"'], block: false }; - if (ext === 'clj' || ext === 'cljs' || ext === 'edn' || ext === 'lisp' || ext === 'scm') { - return { line: [';'], block: false }; - } - return { line: ['//'], block: true }; -} - -export function stripCommentsAndStrings(input: string, syntax: CommentSyntax): string | null { - let out = ''; - let i = 0; - - while (i < input.length) { - const rest = input.slice(i); - - if (syntax.line.some((token) => rest.startsWith(token))) break; - - if (syntax.block && rest.startsWith('/*')) { - const end = input.indexOf('*/', i + 2); - if (end === -1) return null; - out += ' '; - i = end + 2; - continue; - } - - const char = input[i]; - - if (char === "'" || char === '"') { - const close = findStringEnd(input, i + 1, char); - if (close === -1) return null; - out += ' '; - i = close + 1; - continue; - } - - if (char === '`') { - const scanned = scanTemplateLiteral(input, i); - if (!scanned) return null; - out += scanned.code; - i = scanned.next; - continue; - } - - out += char; - i += 1; - } - - return out; -} - -function findStringEnd(input: string, start: number, quote: string): number { - for (let i = start; i < input.length; i++) { - if (input[i] === '\\') { - i += 1; - continue; - } - if (input[i] === quote) return i; - } - return -1; -} - -function scanTemplateLiteral(input: string, start: number): { code: string; next: number } | null { - let code = ' '; - let i = start + 1; - - while (i < input.length) { - if (input[i] === '\\') { - i += 2; - continue; - } - if (input[i] === '`') return { code, next: i + 1 }; - if (input[i] === '$' && input[i + 1] === '{') { - let depth = 1; - let j = i + 2; - while (j < input.length && depth > 0) { - if (input[j] === '{') depth += 1; - else if (input[j] === '}') depth -= 1; - j += 1; - } - if (depth !== 0) return null; - code += ` ${input.slice(i + 2, j - 1)} `; - i = j; - continue; - } - i += 1; - } - - return null; -} - -// MEASURED AND REJECTED: a call-site/reachability gate. The withheld slice had precision 27.3% vs an -// 18.7% pooled baseline, and "mentions callers" scored -0.3 on the codra-only subset. Any gate proposed -// from this corpus must be re-checked on the codra-only subset -- pooled signals do not survive it. - -const TOKEN_PATTERN = /[A-Za-z_$][\w$]*/g; - -/** Where each identifier appears after the change; without a post-image the index sees only the diff. */ -export function buildPresenceIndex(file: FileDiff, fileContent?: string | null): PresenceIndex { - const syntax = commentSyntaxFor(file.path); - const byToken = new Map(); - const entries: PresenceEntry[] = []; - const hunkByLine = new Map(); - - const add = (entry: PresenceEntry) => { - entries.push(entry); - for (const match of entry.code.matchAll(TOKEN_PATTERN)) { - const token = match[0]; - const existing = byToken.get(token); - if (existing) existing.push(entry); - else byToken.set(token, [entry]); - } - }; - - file.hunks.forEach((hunk, hunkIndex) => { - for (const line of hunk.lines) { - if (line.newLineNumber !== undefined) hunkByLine.set(line.newLineNumber, hunkIndex); - - if (line.kind === 'del') continue; - - const code = stripCommentsAndStrings(normalizeDiffText(line.content), syntax); - if (code === null) continue; - - add({ newLineNumber: line.newLineNumber, hunkIndex, code }); - } - }); - - if (fileContent) { - const lines = fileContent.split('\n'); - for (let i = 0; i < lines.length; i++) { - const newLineNumber = i + 1; - if (hunkByLine.has(newLineNumber)) continue; - - const code = stripCommentsAndStrings(normalizeDiffText(lines[i]), syntax); - if (code === null) continue; - - add({ newLineNumber, hunkIndex: null, code }); - } - } - - return { byToken, entries, hunkByLine }; -} - -const SIMPLE_IDENTIFIER = /^[A-Za-z_$][\w$]*$/; -const DOTTED_IDENTIFIER = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/; - -function extractIdentifier(sentence: string): { identifier: string } | 'none' | 'ambiguous' { - const spans = [ - ...sentence.matchAll(/`([^`]+)`/g), - ...sentence.matchAll(/'([^']+)'/g), - ...sentence.matchAll(/"([^"]+)"/g), - ].map((match) => match[1].trim()); - - const candidates = new Set( - spans.filter((span) => SIMPLE_IDENTIFIER.test(span) || DOTTED_IDENTIFIER.test(span)), - ); - - if (candidates.size === 0) return 'none'; - if (candidates.size > 1) return 'ambiguous'; - return { identifier: [...candidates][0] }; -} - -function absenceSentences(text: string): string[] { - return text.split(/[.;\n]/).filter((sentence) => ABSENCE_PATTERNS.some((pattern) => pattern.test(sentence))); -} - -export function checkAbsenceClaim(input: { - title: string; - body: string; - anchorLine: number | undefined; - index: PresenceIndex; -}): AbsenceClaimVerdict { - const text = `${input.title}\n${input.body.slice(0, 600)}`; - - const sentences = absenceSentences(text); - if (sentences.length === 0) return { status: 'unknown', reason: 'not_absence_shaped' }; - - let identifier: string | undefined; - for (const sentence of sentences) { - const extracted = extractIdentifier(sentence); - if (extracted === 'ambiguous') return { status: 'unknown', reason: 'ambiguous_identifier' }; - if (extracted !== 'none') { - identifier = extracted.identifier; - break; - } - } - if (!identifier) return { status: 'unknown', reason: 'no_identifier' }; - - const head = identifier.split('.')[0]; - if (identifier.length < MIN_IDENTIFIER_LENGTH) return { status: 'unknown', reason: 'stoplisted' }; - if (IDENTIFIER_STOPLIST.has(identifier.toLowerCase()) || IDENTIFIER_STOPLIST.has(head.toLowerCase())) { - return { status: 'unknown', reason: 'stoplisted' }; - } - - const occurrences = identifier.includes('.') - ? input.index.entries.filter((entry) => entry.code.replace(/\s*\.\s*/g, '.').includes(identifier)) - : (input.index.byToken.get(identifier) ?? []); - - if (occurrences.length === 0) return { status: 'unknown', reason: 'not_present' }; - - const anchorHunk = input.anchorLine !== undefined ? input.index.hunkByLine.get(input.anchorLine) : undefined; - const nearby = occurrences.find((entry) => { - if (anchorHunk !== undefined && entry.hunkIndex !== null && entry.hunkIndex === anchorHunk) return true; - if (input.anchorLine === undefined || entry.newLineNumber === undefined) return false; - return Math.abs(entry.newLineNumber - input.anchorLine) <= PROXIMITY_WINDOW_LINES; - }); - - if (!nearby) return { status: 'unknown', reason: 'out_of_window' }; - return { status: 'refuted', identifier, line: nearby.newLineNumber }; -} +// SOUNDNESS, binding on every change: `refuted` asserts only that "X does not appear" is FALSE. There is no `confirmed` verdict, since a check that can confirm findings manufactures them. Losing a refutation is free; a wrong one silences a real defect. +import type { FileDiff } from './diff'; +import { normalizeDiffText } from './fingerprint'; + +const PROXIMITY_WINDOW_LINES = 25; + +const MIN_IDENTIFIER_LENGTH = 3; + +const ABSENCE_PATTERNS: readonly RegExp[] = [ + /\b(?:never|not|no longer)\s+(?:being\s+)?(?:passed|provided|supplied|forwarded|included|used|called|invoked|awaited|checked|set|declared|defined|imported)\b/i, + /\bdoes not\s+(?:pass|include|call|use|await|check|set|import)\b/i, + /\bfails to\s+(?:pass|include|call|await|check|import)\b/i, + /\bwithout\s+(?:passing|including|calling|awaiting|checking|importing)\b/i, + /\b(?:missing|omitted|absent)\b/i, + /\bis not defined\b/i, +]; + +const IDENTIFIER_STOPLIST = new Set([ + 'await', 'async', 'if', 'else', 'try', 'catch', 'finally', 'return', 'throw', 'new', 'const', + 'let', 'var', 'function', 'class', 'this', 'super', 'import', 'export', 'from', 'default', + 'null', 'undefined', 'true', 'false', 'void', 'typeof', 'instanceof', 'delete', 'yield', + 'props', 'state', 'error', 'err', 'data', 'value', 'key', 'id', 'type', 'name', 'index', + 'result', 'response', 'request', 'req', 'res', 'params', 'options', 'config', 'args', +]); + +const VERSION_CLAIM_PATTERNS: readonly RegExp[] = [ + /\b(?:does not|doesn't|do not|don't)\s+exist\b/i, + /\b(?:non-?existent|nonexistent)\b/i, + /\bis not a valid\b/i, + /\blatest (?:major )?version\b/i, + /\bno such (?:version|tag|release)\b/i, + /\bnot a valid (?:configuration )?(?:option|key|property)\b/i, + /\b(?:does not|doesn't|do not|don't)\s+(?:expose|provide|have|support|include|offer)\b/i, + /\bno such (?:function|method|export|property|api|field)\b/i, + /\bis not (?:exposed|exported|available) (?:by|from|in)\b/i, +]; + +// Same soundness rule as the absence checker above. + +const CROSS_FILE_SUBJECT = /\b(?:other|another|external|downstream|consuming|importing|dependent|calling)\s+(?:module|file|component|caller|package|consumer|import)s?\b/i; +const CROSS_FILE_CONSEQUENCE = /\b(?:break|breaks|breaking|broken|fail|fails|failing|error|errors|cannot import|can't import|unable to|compilation|compile|prevent|prevents|preventing|block|blocks|blocking)\b/i; + +const ENVIRONMENT_HEDGE = /\b(?:depending on|might not|may not|could be undefined|if (?:this|the|it)\b[^.]{0,60}\b(?:is )?(?:rendered|run|executed|used)\b)/i; +const ENVIRONMENT_SUBJECT = /\b(?:older|legacy|earlier|some)\s+(?:node(?:\.js)?|browsers?|runtimes?|environments?|engines?|versions?)\b|\bserver[- ]side\b|\bSSR\b|\bhydration\b|\bpolyfill\b|\bis not defined on the server\b/i; + +const CALLEE_FAILURE_CONDITION = /\b(?:if|when|should|were)\b(?:(?!\.\s)[^;!?]){0,62}\b(?:fails?|failing|rejects?|rejecting|throws?|throwing|errors? out)\b/i; +const CALLEE_CALL_SHAPE = /[\w.$]{1,50}\s*\(\s*\)|`[\w.$]{1,50}\(/; +const CALLEE_UNHANDLED_OUTCOME = /\bunhandled\b|\bunhandled promise\b|\bnot (?:caught|handled)\b|\bno (?:\.)?catch\b|\bwithout (?:a )?(?:try|catch)\b|\bcrash\b/i; + +export type UndecidableClaimReason = 'cross-file' | 'environment' | 'callee-errors'; + +/** Refutes a claim whose truth lives outside the diff; two signals per family, since one is ordinary. */ +export function refuteUndecidableClaim(input: { title: string; body: string }): UndecidableClaimReason | null { + const text = `${input.title}\n${input.body}`; + + if (CROSS_FILE_SUBJECT.test(text) && CROSS_FILE_CONSEQUENCE.test(text)) return 'cross-file'; + if (ENVIRONMENT_HEDGE.test(text) && ENVIRONMENT_SUBJECT.test(text)) return 'environment'; + if (CALLEE_FAILURE_CONDITION.test(text) && CALLEE_CALL_SHAPE.test(text) && CALLEE_UNHANDLED_OUTCOME.test(text)) { + return 'callee-errors'; + } + + return null; +} + +const FULL_SHA_PATTERN = /\b[0-9a-f]{40}\b/; + +export function looksLikeExternalVersionClaim(title: string, body: string): boolean { + const text = `${title}\n${body}`; + return VERSION_CLAIM_PATTERNS.some((pattern) => pattern.test(text)); +} + +export function isVersionClaimRefutedByPin(input: { title: string; body: string; anchorContent: string }): boolean { + if (!looksLikeExternalVersionClaim(input.title, input.body)) return false; + return FULL_SHA_PATTERN.test(input.anchorContent); +} + +/** One line the identifier could be found on; `hunkIndex` is null for lines from the post-image. */ +type PresenceEntry = { newLineNumber: number | undefined; hunkIndex: number | null; code: string }; + +export type PresenceIndex = { + byToken: Map; + entries: PresenceEntry[]; + hunkByLine: Map; +}; + +export type AbsenceClaimVerdict = + | { + status: 'unknown'; + reason: + | 'not_absence_shaped' + | 'no_identifier' + | 'ambiguous_identifier' + | 'stoplisted' + | 'not_present' + | 'out_of_window'; + } + | { status: 'refuted'; identifier: string; line: number | undefined }; + +type CommentSyntax = { line: readonly string[]; block: boolean }; + +// Must stay complete: a misclassified file keeps comment text as code, refuting real absence claims. +const HASH_COMMENT_EXTENSIONS = new Set([ + 'py', 'pyi', 'rb', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', + 'tf', 'tfvars', 'hcl', 'pl', 'pm', 'r', 'jl', 'nim', 'cr', 'ex', 'exs', 'elixir', 'gemspec', + 'dockerfile', 'containerfile', 'mk', 'cmake', 'gradle', 'properties', 'env', 'gitignore', + 'dockerignore', 'editorconfig', +]); + +const HASH_COMMENT_FILENAMES = new Set([ + 'dockerfile', 'containerfile', 'makefile', 'gnumakefile', 'rakefile', 'gemfile', 'brewfile', + 'procfile', 'vagrantfile', 'justfile', 'cmakelists.txt', '.gitignore', '.dockerignore', '.env', +]); + +export function commentSyntaxFor(path: string): CommentSyntax { + const name = path.toLowerCase().split('/').pop() ?? ''; + if (HASH_COMMENT_FILENAMES.has(name)) return { line: ['#'], block: false }; + + const ext = name.includes('.') ? name.split('.').pop() ?? '' : ''; + if (HASH_COMMENT_EXTENSIONS.has(ext)) return { line: ['#'], block: false }; + + if (ext === 'sql') return { line: ['--'], block: true }; + if (ext === 'lua') return { line: ['--'], block: true }; + if (ext === 'hs' || ext === 'elm' || ext === 'ada') return { line: ['--'], block: false }; + if (ext === 'vim') return { line: ['"'], block: false }; + if (ext === 'clj' || ext === 'cljs' || ext === 'edn' || ext === 'lisp' || ext === 'scm') { + return { line: [';'], block: false }; + } + return { line: ['//'], block: true }; +} + +export function stripCommentsAndStrings(input: string, syntax: CommentSyntax): string | null { + let out = ''; + let i = 0; + + while (i < input.length) { + const rest = input.slice(i); + + if (syntax.line.some((token) => rest.startsWith(token))) break; + + if (syntax.block && rest.startsWith('/*')) { + const end = input.indexOf('*/', i + 2); + if (end === -1) return null; + out += ' '; + i = end + 2; + continue; + } + + const char = input[i]; + + if (char === "'" || char === '"') { + const close = findStringEnd(input, i + 1, char); + if (close === -1) return null; + out += ' '; + i = close + 1; + continue; + } + + if (char === '`') { + const scanned = scanTemplateLiteral(input, i); + if (!scanned) return null; + out += scanned.code; + i = scanned.next; + continue; + } + + out += char; + i += 1; + } + + return out; +} + +function findStringEnd(input: string, start: number, quote: string): number { + for (let i = start; i < input.length; i++) { + if (input[i] === '\\') { + i += 1; + continue; + } + if (input[i] === quote) return i; + } + return -1; +} + +function scanTemplateLiteral(input: string, start: number): { code: string; next: number } | null { + let code = ' '; + let i = start + 1; + + while (i < input.length) { + if (input[i] === '\\') { + i += 2; + continue; + } + if (input[i] === '`') return { code, next: i + 1 }; + if (input[i] === '$' && input[i + 1] === '{') { + let depth = 1; + let j = i + 2; + while (j < input.length && depth > 0) { + if (input[j] === '{') depth += 1; + else if (input[j] === '}') depth -= 1; + j += 1; + } + if (depth !== 0) return null; + code += ` ${input.slice(i + 2, j - 1)} `; + i = j; + continue; + } + i += 1; + } + + return null; +} + +// MEASURED AND REJECTED: a call-site/reachability gate. The withheld slice had precision 27.3% vs an +// 18.7% pooled baseline, and "mentions callers" scored -0.3 on the codra-only subset. Any gate proposed +// from this corpus must be re-checked on the codra-only subset -- pooled signals do not survive it. + +const TOKEN_PATTERN = /[A-Za-z_$][\w$]*/g; + +/** Where each identifier appears after the change; without a post-image the index sees only the diff. */ +export function buildPresenceIndex(file: FileDiff, fileContent?: string | null): PresenceIndex { + const syntax = commentSyntaxFor(file.path); + const byToken = new Map(); + const entries: PresenceEntry[] = []; + const hunkByLine = new Map(); + + const add = (entry: PresenceEntry) => { + entries.push(entry); + for (const match of entry.code.matchAll(TOKEN_PATTERN)) { + const token = match[0]; + const existing = byToken.get(token); + if (existing) existing.push(entry); + else byToken.set(token, [entry]); + } + }; + + file.hunks.forEach((hunk, hunkIndex) => { + for (const line of hunk.lines) { + if (line.newLineNumber !== undefined) hunkByLine.set(line.newLineNumber, hunkIndex); + + if (line.kind === 'del') continue; + + const code = stripCommentsAndStrings(normalizeDiffText(line.content), syntax); + if (code === null) continue; + + add({ newLineNumber: line.newLineNumber, hunkIndex, code }); + } + }); + + if (fileContent) { + const lines = fileContent.split('\n'); + for (let i = 0; i < lines.length; i++) { + const newLineNumber = i + 1; + if (hunkByLine.has(newLineNumber)) continue; + + const code = stripCommentsAndStrings(normalizeDiffText(lines[i]), syntax); + if (code === null) continue; + + add({ newLineNumber, hunkIndex: null, code }); + } + } + + return { byToken, entries, hunkByLine }; +} + +const SIMPLE_IDENTIFIER = /^[A-Za-z_$][\w$]*$/; +const DOTTED_IDENTIFIER = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/; + +function extractIdentifier(sentence: string): { identifier: string } | 'none' | 'ambiguous' { + const spans = [ + ...sentence.matchAll(/`([^`]+)`/g), + ...sentence.matchAll(/'([^']+)'/g), + ...sentence.matchAll(/"([^"]+)"/g), + ].map((match) => match[1].trim()); + + const candidates = new Set( + spans.filter((span) => SIMPLE_IDENTIFIER.test(span) || DOTTED_IDENTIFIER.test(span)), + ); + + if (candidates.size === 0) return 'none'; + if (candidates.size > 1) return 'ambiguous'; + return { identifier: [...candidates][0] }; +} + +function absenceSentences(text: string): string[] { + return text.split(/[.;\n]/).filter((sentence) => ABSENCE_PATTERNS.some((pattern) => pattern.test(sentence))); +} + +export function checkAbsenceClaim(input: { + title: string; + body: string; + anchorLine: number | undefined; + index: PresenceIndex; +}): AbsenceClaimVerdict { + const text = `${input.title}\n${input.body.slice(0, 600)}`; + + const sentences = absenceSentences(text); + if (sentences.length === 0) return { status: 'unknown', reason: 'not_absence_shaped' }; + + let identifier: string | undefined; + for (const sentence of sentences) { + const extracted = extractIdentifier(sentence); + if (extracted === 'ambiguous') return { status: 'unknown', reason: 'ambiguous_identifier' }; + if (extracted !== 'none') { + identifier = extracted.identifier; + break; + } + } + if (!identifier) return { status: 'unknown', reason: 'no_identifier' }; + + const head = identifier.split('.')[0]; + if (identifier.length < MIN_IDENTIFIER_LENGTH) return { status: 'unknown', reason: 'stoplisted' }; + if (IDENTIFIER_STOPLIST.has(identifier.toLowerCase()) || IDENTIFIER_STOPLIST.has(head.toLowerCase())) { + return { status: 'unknown', reason: 'stoplisted' }; + } + + const occurrences = identifier.includes('.') + ? input.index.entries.filter((entry) => entry.code.replace(/\s*\.\s*/g, '.').includes(identifier)) + : (input.index.byToken.get(identifier) ?? []); + + if (occurrences.length === 0) return { status: 'unknown', reason: 'not_present' }; + + const anchorHunk = input.anchorLine !== undefined ? input.index.hunkByLine.get(input.anchorLine) : undefined; + const nearby = occurrences.find((entry) => { + if (anchorHunk !== undefined && entry.hunkIndex !== null && entry.hunkIndex === anchorHunk) return true; + if (input.anchorLine === undefined || entry.newLineNumber === undefined) return false; + return Math.abs(entry.newLineNumber - input.anchorLine) <= PROXIMITY_WINDOW_LINES; + }); + + if (!nearby) return { status: 'unknown', reason: 'out_of_window' }; + return { status: 'refuted', identifier, line: nearby.newLineNumber }; +} diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index 37405e47..f320dbbb 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -1,304 +1,304 @@ -import picomatch from 'picomatch'; -import { MAX_TOTAL_DIFF_CHARS } from '../constants'; -import type { RepoConfig } from '@codraoss/schema'; -import { - type DiffLineKind, - type DiffLine, - type DiffHunk, - type FileDiff, - getValidNewLines, - getValidPositions, - findPositionForLine, - truncateFileDiff, - chunkFileDiff, -} from './position'; - -export { - type DiffLineKind, - type DiffLine, - type DiffHunk, - type FileDiff, - getValidNewLines, - getValidPositions, - findPositionForLine, - truncateFileDiff, - chunkFileDiff, -}; - -const defaultSkipMatchers = ['**/*.lock', '**/package-lock.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/*.min.js'].map((pattern) => - picomatch(pattern, { dot: true }), -); - -export function isReviewableFile(path: string, customMatchers: ReturnType[]) { - if (defaultSkipMatchers.some((matcher) => matcher(path))) return false; - if (customMatchers.some((matcher) => matcher(path))) return false; - return true; -} - -export function parseDiffHeaderPath(line: string) { - const rest = line.slice('diff --git '.length); - - if (rest.startsWith('a/')) { - const n = (rest.length - 5) / 2; - if (Number.isInteger(n) && n > 0 && rest[2 + n] === ' ' && rest.startsWith('b/', 3 + n)) { - const a = rest.slice(2, 2 + n); - if (a === rest.slice(5 + n)) return a; - } - } - - const bStart = rest.indexOf(' b/', rest.startsWith('a/') ? 2 : 0); - const bPath = bStart === -1 ? rest.slice(rest.lastIndexOf(' ') + 1) : rest.slice(bStart + 3); - return bPath.startsWith('b/') ? bPath.slice(2) : bPath; -} - -function parseHunkHeader(line: string): { oldLine: number; newLine: number } | null { - const match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); - if (!match) { - return null; - } - - return { - oldLine: Number.parseInt(match[1], 10), - newLine: Number.parseInt(match[2], 10), - }; -} - -function classifyDiffLine(prefix: ' ' | '+' | '-', content: string, oldLine: number, newLine: number, position: number): DiffLine { - if (prefix === ' ') { - return { kind: 'context', content, oldLineNumber: oldLine, newLineNumber: newLine, position }; - } - - if (prefix === '+') { - return { kind: 'add', content, newLineNumber: newLine, position }; - } - - return { kind: 'del', content, oldLineNumber: oldLine, position }; -} - -function finishFile(files: FileDiff[], currentFile: FileDiff | null) { - if (currentFile) { - files.push(currentFile); - } -} - -export function parseUnifiedDiff(rawDiff: string, reviewConfig?: RepoConfig['review']): FileDiff[] { - const files: FileDiff[] = []; - const customMatchers = reviewConfig?.skip_files?.map((pattern) => picomatch(pattern, { dot: true })) ?? []; - - let currentFile: FileDiff | null = null; - let currentHunk: DiffHunk | null = null; - let oldLine = 0; - let newLine = 0; - let position = 0; - let isIgnored = false; - - const pushCurrentFile = () => { - finishFile(files, currentFile); - currentFile = null; - currentHunk = null; - oldLine = 0; - newLine = 0; - position = 0; - isIgnored = false; - }; - - let startIndex = 0; - const length = rawDiff.length; - - while (startIndex < length) { - let endIndex = rawDiff.indexOf('\n', startIndex); - if (endIndex === -1) { - endIndex = length; - } - - let line = rawDiff.substring(startIndex, endIndex); - if (line.charCodeAt(line.length - 1) === 13) { - line = line.slice(0, -1); - } - - startIndex = endIndex + 1; - - if (line.startsWith('diff --git ')) { - pushCurrentFile(); - const path = parseDiffHeaderPath(line); - - currentFile = { - path, - previousPath: null, - isNew: false, - isDeleted: false, - isBinary: false, - lineCount: 0, - hunks: [], - }; - - if (reviewConfig) { - isIgnored = !isReviewableFile(path, customMatchers); - } - continue; - } - - if (!currentFile) { - continue; - } - - if (line.startsWith('rename from ')) { - currentFile.previousPath = line.slice(12); - continue; - } - - if (line.startsWith('rename to ')) { - const nextPath = line.slice(10); - currentFile.path = nextPath.startsWith('b/') ? nextPath.slice(2) : nextPath; - if (reviewConfig) { - isIgnored = !isReviewableFile(currentFile.path, customMatchers); - } - continue; - } - - if (line.startsWith('new file mode ')) { - currentFile.isNew = true; - continue; - } - - if (line.startsWith('deleted file mode ')) { - currentFile.isDeleted = true; - isIgnored = true; - continue; - } - - if (line.startsWith('Binary files ') || line.startsWith('GIT binary patch')) { - currentFile.isBinary = true; - isIgnored = true; - continue; - } - - if (line.startsWith('+++ ')) { - const nextPath = line.slice(4); - currentFile.path = nextPath.startsWith('b/') ? nextPath.slice(2) : nextPath; - if (reviewConfig) { - isIgnored = !isReviewableFile(currentFile.path, customMatchers); - } - continue; - } - - if (isIgnored) { - continue; - } - - if (line.startsWith('--- ')) { - continue; - } - - if (line.startsWith('@@ ')) { - const header = parseHunkHeader(line); - if (!header) { - continue; - } - - oldLine = header.oldLine; - newLine = header.newLine; - currentHunk = { header: line, lines: [] }; - currentFile.hunks.push(currentHunk); - continue; - } - - if (!currentHunk) { - continue; - } - - const prefix = line[0]; - if (prefix !== ' ' && prefix !== '+' && prefix !== '-') { - continue; - } - - position += 1; - const diffLine = classifyDiffLine(prefix, line.slice(1), oldLine, newLine, position); - currentHunk.lines.push(diffLine); - currentFile.lineCount += 1; - - if (diffLine.kind !== 'del') newLine += 1; - if (diffLine.kind !== 'add') oldLine += 1; - } - - pushCurrentFile(); - - return files.filter((file) => file.path); -} - -/** Shape returned by forge compare/files endpoints. Maps to GitHub's pulls/files JSON. */ -export type DiffFileEntry = { - filename: string; - previous_filename?: string | null; - status?: string; - patch?: string | null; -}; - -export function buildUnifiedDiffFromFiles(files: DiffFileEntry[]): string { - const out: string[] = []; - - for (const file of files) { - const newPath = file.filename; - const oldPath = file.previous_filename || file.filename; - const isAdded = file.status === 'added'; - const isRemoved = file.status === 'removed'; - - out.push(`diff --git a/${oldPath} b/${newPath}`); - if (isAdded) out.push('new file mode 100644'); - if (isRemoved) out.push('deleted file mode 100644'); - if (file.previous_filename && file.previous_filename !== newPath) { - out.push(`rename from ${file.previous_filename}`); - out.push(`rename to ${newPath}`); - } - - if (!file.patch) { - out.push(`Binary files a/${oldPath} and b/${newPath} differ`); - continue; - } - - out.push(isAdded ? '--- /dev/null' : `--- a/${oldPath}`); - out.push(isRemoved ? '+++ /dev/null' : `+++ b/${newPath}`); - out.push(file.patch); - } - - return out.length > 0 ? `${out.join('\n')}\n` : ''; -} - -export function filterReviewableFiles( - files: FileDiff[], - config: RepoConfig['review'], - maxFiles: number, - // Overridable for tests; production always uses the constant. - maxTotalDiffChars: number = MAX_TOTAL_DIFF_CHARS, -): { files: FileDiff[]; skipped: number } { - const customMatchers = config.skip_files.map((pattern) => picomatch(pattern, { dot: true })); - - const reviewable: FileDiff[] = []; - for (const file of files) { - if (file.isDeleted || file.isBinary) continue; - if (defaultSkipMatchers.some((matcher) => matcher(file.path))) continue; - if (customMatchers.some((matcher) => matcher(file.path))) continue; - reviewable.push(file); - } - reviewable.sort((left, right) => Number(left.isNew) - Number(right.isNew) || left.path.localeCompare(right.path)); - - const withinFileLimit = reviewable.slice(0, maxFiles); - - // Job-level input ceiling. Files are kept or dropped whole; half a file's hunks would lie. - const kept: FileDiff[] = []; - let totalChars = 0; - for (const file of withinFileLimit) { - const fileChars = file.hunks.reduce( - (sum, hunk) => sum + hunk.lines.reduce((lineSum, line) => lineSum + line.content.length + 1, 0), - 0, - ); - if (kept.length > 0 && totalChars + fileChars > maxTotalDiffChars) break; - kept.push(file); - totalChars += fileChars; - } - - return { - files: kept, - skipped: Math.max(0, reviewable.length - kept.length), - }; -} +import picomatch from 'picomatch'; +import { MAX_TOTAL_DIFF_CHARS } from '../constants'; +import type { RepoConfig } from '@codraoss/schema'; +import { + type DiffLineKind, + type DiffLine, + type DiffHunk, + type FileDiff, + getValidNewLines, + getValidPositions, + findPositionForLine, + truncateFileDiff, + chunkFileDiff, +} from './position'; + +export { + type DiffLineKind, + type DiffLine, + type DiffHunk, + type FileDiff, + getValidNewLines, + getValidPositions, + findPositionForLine, + truncateFileDiff, + chunkFileDiff, +}; + +const defaultSkipMatchers = ['**/*.lock', '**/package-lock.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/*.min.js'].map((pattern) => + picomatch(pattern, { dot: true }), +); + +export function isReviewableFile(path: string, customMatchers: ReturnType[]) { + if (defaultSkipMatchers.some((matcher) => matcher(path))) return false; + if (customMatchers.some((matcher) => matcher(path))) return false; + return true; +} + +export function parseDiffHeaderPath(line: string) { + const rest = line.slice('diff --git '.length); + + if (rest.startsWith('a/')) { + const n = (rest.length - 5) / 2; + if (Number.isInteger(n) && n > 0 && rest[2 + n] === ' ' && rest.startsWith('b/', 3 + n)) { + const a = rest.slice(2, 2 + n); + if (a === rest.slice(5 + n)) return a; + } + } + + const bStart = rest.indexOf(' b/', rest.startsWith('a/') ? 2 : 0); + const bPath = bStart === -1 ? rest.slice(rest.lastIndexOf(' ') + 1) : rest.slice(bStart + 3); + return bPath.startsWith('b/') ? bPath.slice(2) : bPath; +} + +function parseHunkHeader(line: string): { oldLine: number; newLine: number } | null { + const match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); + if (!match) { + return null; + } + + return { + oldLine: Number.parseInt(match[1], 10), + newLine: Number.parseInt(match[2], 10), + }; +} + +function classifyDiffLine(prefix: ' ' | '+' | '-', content: string, oldLine: number, newLine: number, position: number): DiffLine { + if (prefix === ' ') { + return { kind: 'context', content, oldLineNumber: oldLine, newLineNumber: newLine, position }; + } + + if (prefix === '+') { + return { kind: 'add', content, newLineNumber: newLine, position }; + } + + return { kind: 'del', content, oldLineNumber: oldLine, position }; +} + +function finishFile(files: FileDiff[], currentFile: FileDiff | null) { + if (currentFile) { + files.push(currentFile); + } +} + +export function parseUnifiedDiff(rawDiff: string, reviewConfig?: RepoConfig['review']): FileDiff[] { + const files: FileDiff[] = []; + const customMatchers = reviewConfig?.skip_files?.map((pattern) => picomatch(pattern, { dot: true })) ?? []; + + let currentFile: FileDiff | null = null; + let currentHunk: DiffHunk | null = null; + let oldLine = 0; + let newLine = 0; + let position = 0; + let isIgnored = false; + + const pushCurrentFile = () => { + finishFile(files, currentFile); + currentFile = null; + currentHunk = null; + oldLine = 0; + newLine = 0; + position = 0; + isIgnored = false; + }; + + let startIndex = 0; + const length = rawDiff.length; + + while (startIndex < length) { + let endIndex = rawDiff.indexOf('\n', startIndex); + if (endIndex === -1) { + endIndex = length; + } + + let line = rawDiff.substring(startIndex, endIndex); + if (line.charCodeAt(line.length - 1) === 13) { + line = line.slice(0, -1); + } + + startIndex = endIndex + 1; + + if (line.startsWith('diff --git ')) { + pushCurrentFile(); + const path = parseDiffHeaderPath(line); + + currentFile = { + path, + previousPath: null, + isNew: false, + isDeleted: false, + isBinary: false, + lineCount: 0, + hunks: [], + }; + + if (reviewConfig) { + isIgnored = !isReviewableFile(path, customMatchers); + } + continue; + } + + if (!currentFile) { + continue; + } + + if (line.startsWith('rename from ')) { + currentFile.previousPath = line.slice(12); + continue; + } + + if (line.startsWith('rename to ')) { + const nextPath = line.slice(10); + currentFile.path = nextPath.startsWith('b/') ? nextPath.slice(2) : nextPath; + if (reviewConfig) { + isIgnored = !isReviewableFile(currentFile.path, customMatchers); + } + continue; + } + + if (line.startsWith('new file mode ')) { + currentFile.isNew = true; + continue; + } + + if (line.startsWith('deleted file mode ')) { + currentFile.isDeleted = true; + isIgnored = true; + continue; + } + + if (line.startsWith('Binary files ') || line.startsWith('GIT binary patch')) { + currentFile.isBinary = true; + isIgnored = true; + continue; + } + + if (line.startsWith('+++ ')) { + const nextPath = line.slice(4); + currentFile.path = nextPath.startsWith('b/') ? nextPath.slice(2) : nextPath; + if (reviewConfig) { + isIgnored = !isReviewableFile(currentFile.path, customMatchers); + } + continue; + } + + if (isIgnored) { + continue; + } + + if (line.startsWith('--- ')) { + continue; + } + + if (line.startsWith('@@ ')) { + const header = parseHunkHeader(line); + if (!header) { + continue; + } + + oldLine = header.oldLine; + newLine = header.newLine; + currentHunk = { header: line, lines: [] }; + currentFile.hunks.push(currentHunk); + continue; + } + + if (!currentHunk) { + continue; + } + + const prefix = line[0]; + if (prefix !== ' ' && prefix !== '+' && prefix !== '-') { + continue; + } + + position += 1; + const diffLine = classifyDiffLine(prefix, line.slice(1), oldLine, newLine, position); + currentHunk.lines.push(diffLine); + currentFile.lineCount += 1; + + if (diffLine.kind !== 'del') newLine += 1; + if (diffLine.kind !== 'add') oldLine += 1; + } + + pushCurrentFile(); + + return files.filter((file) => file.path); +} + +/** Shape returned by forge compare/files endpoints. Maps to GitHub's pulls/files JSON. */ +export type DiffFileEntry = { + filename: string; + previous_filename?: string | null; + status?: string; + patch?: string | null; +}; + +export function buildUnifiedDiffFromFiles(files: DiffFileEntry[]): string { + const out: string[] = []; + + for (const file of files) { + const newPath = file.filename; + const oldPath = file.previous_filename || file.filename; + const isAdded = file.status === 'added'; + const isRemoved = file.status === 'removed'; + + out.push(`diff --git a/${oldPath} b/${newPath}`); + if (isAdded) out.push('new file mode 100644'); + if (isRemoved) out.push('deleted file mode 100644'); + if (file.previous_filename && file.previous_filename !== newPath) { + out.push(`rename from ${file.previous_filename}`); + out.push(`rename to ${newPath}`); + } + + if (!file.patch) { + out.push(`Binary files a/${oldPath} and b/${newPath} differ`); + continue; + } + + out.push(isAdded ? '--- /dev/null' : `--- a/${oldPath}`); + out.push(isRemoved ? '+++ /dev/null' : `+++ b/${newPath}`); + out.push(file.patch); + } + + return out.length > 0 ? `${out.join('\n')}\n` : ''; +} + +export function filterReviewableFiles( + files: FileDiff[], + config: RepoConfig['review'], + maxFiles: number, + // Overridable for tests; production always uses the constant. + maxTotalDiffChars: number = MAX_TOTAL_DIFF_CHARS, +): { files: FileDiff[]; skipped: number } { + const customMatchers = config.skip_files.map((pattern) => picomatch(pattern, { dot: true })); + + const reviewable: FileDiff[] = []; + for (const file of files) { + if (file.isDeleted || file.isBinary) continue; + if (defaultSkipMatchers.some((matcher) => matcher(file.path))) continue; + if (customMatchers.some((matcher) => matcher(file.path))) continue; + reviewable.push(file); + } + reviewable.sort((left, right) => Number(left.isNew) - Number(right.isNew) || left.path.localeCompare(right.path)); + + const withinFileLimit = reviewable.slice(0, maxFiles); + + // Job-level input ceiling. Files are kept or dropped whole; half a file's hunks would lie. + const kept: FileDiff[] = []; + let totalChars = 0; + for (const file of withinFileLimit) { + const fileChars = file.hunks.reduce( + (sum, hunk) => sum + hunk.lines.reduce((lineSum, line) => lineSum + line.content.length + 1, 0), + 0, + ); + if (kept.length > 0 && totalChars + fileChars > maxTotalDiffChars) break; + kept.push(file); + totalChars += fileChars; + } + + return { + files: kept, + skipped: Math.max(0, reviewable.length - kept.length), + }; +} diff --git a/packages/core/src/diff/position.ts b/packages/core/src/diff/position.ts index 439778de..3de84b0d 100644 --- a/packages/core/src/diff/position.ts +++ b/packages/core/src/diff/position.ts @@ -1,161 +1,161 @@ -export type DiffLineKind = 'context' | 'add' | 'del'; - -export type DiffLine = { - kind: DiffLineKind; - content: string; - oldLineNumber?: number; - newLineNumber?: number; - position: number; -}; - -export type DiffHunk = { - header: string; - lines: DiffLine[]; -}; - -export type FileDiff = { - path: string; - previousPath: string | null; - isNew: boolean; - isDeleted: boolean; - isBinary: boolean; - lineCount: number; - hunks: DiffHunk[]; - isTruncated?: boolean; - originalLineCount?: number; -}; - -export function getValidNewLines(file: FileDiff) { - const newLines = new Set(); - for (const hunk of file.hunks) { - for (const line of hunk.lines) { - if (line.kind !== 'del' && line.newLineNumber !== undefined) { - newLines.add(line.newLineNumber); - } - } - } - - return newLines; -} - -export function getValidPositions(file: FileDiff) { - const positions = new Set(); - for (const hunk of file.hunks) { - for (const line of hunk.lines) { - if (line.kind !== 'del') { - positions.add(line.position); - } - } - } - - return positions; -} - -export function findPositionForLine(file: FileDiff, lineNumber: number) { - for (const hunk of file.hunks) { - for (const line of hunk.lines) { - if (line.newLineNumber === lineNumber && line.kind !== 'del') { - return line.position; - } - } - } - - return undefined; -} - - -export function truncateFileDiff(file: FileDiff, maxLines: number): FileDiff { - if (file.lineCount <= maxLines) { - return file; - } - - let currentLines = 0; - const keptHunks: DiffHunk[] = []; - - for (const hunk of file.hunks) { - const remainingLines = maxLines - currentLines; - if (remainingLines <= 0) { - break; - } - - if (hunk.lines.length <= remainingLines) { - keptHunks.push(hunk); - currentLines += hunk.lines.length; - continue; - } - - keptHunks.push({ - ...hunk, - lines: hunk.lines.slice(0, remainingLines), - }); - currentLines += remainingLines; - break; - } - - return { - ...file, - hunks: keptHunks, - lineCount: currentLines, - isTruncated: true, - originalLineCount: file.lineCount, - }; -} - -export function chunkFileDiff(file: FileDiff, maxLinesPerChunk: number): FileDiff[] { - if (file.lineCount <= maxLinesPerChunk) { - return [file]; - } - - const chunks: FileDiff[] = []; - let currentHunks: DiffHunk[] = []; - let currentLines = 0; - - for (const hunk of file.hunks) { - let linesRemainingInHunk = hunk.lines; - - while (linesRemainingInHunk.length > 0) { - const roomInChunk = maxLinesPerChunk - currentLines; - - if (roomInChunk <= 0) { - chunks.push({ - ...file, - hunks: currentHunks, - lineCount: currentLines, - isTruncated: true, - originalLineCount: file.lineCount, - }); - currentHunks = []; - currentLines = 0; - continue; - } - - if (linesRemainingInHunk.length <= roomInChunk) { - currentHunks.push({ - ...hunk, - lines: linesRemainingInHunk, - }); - currentLines += linesRemainingInHunk.length; - linesRemainingInHunk = []; - } else { - currentHunks.push({ - ...hunk, - lines: linesRemainingInHunk.slice(0, roomInChunk), - }); - currentLines += roomInChunk; - linesRemainingInHunk = linesRemainingInHunk.slice(roomInChunk); - } - } - } - - if (currentHunks.length > 0) { - chunks.push({ - ...file, - hunks: currentHunks, - lineCount: currentLines, - isTruncated: true, - originalLineCount: file.lineCount, - }); - } - - return chunks; -} +export type DiffLineKind = 'context' | 'add' | 'del'; + +export type DiffLine = { + kind: DiffLineKind; + content: string; + oldLineNumber?: number; + newLineNumber?: number; + position: number; +}; + +export type DiffHunk = { + header: string; + lines: DiffLine[]; +}; + +export type FileDiff = { + path: string; + previousPath: string | null; + isNew: boolean; + isDeleted: boolean; + isBinary: boolean; + lineCount: number; + hunks: DiffHunk[]; + isTruncated?: boolean; + originalLineCount?: number; +}; + +export function getValidNewLines(file: FileDiff) { + const newLines = new Set(); + for (const hunk of file.hunks) { + for (const line of hunk.lines) { + if (line.kind !== 'del' && line.newLineNumber !== undefined) { + newLines.add(line.newLineNumber); + } + } + } + + return newLines; +} + +export function getValidPositions(file: FileDiff) { + const positions = new Set(); + for (const hunk of file.hunks) { + for (const line of hunk.lines) { + if (line.kind !== 'del') { + positions.add(line.position); + } + } + } + + return positions; +} + +export function findPositionForLine(file: FileDiff, lineNumber: number) { + for (const hunk of file.hunks) { + for (const line of hunk.lines) { + if (line.newLineNumber === lineNumber && line.kind !== 'del') { + return line.position; + } + } + } + + return undefined; +} + + +export function truncateFileDiff(file: FileDiff, maxLines: number): FileDiff { + if (file.lineCount <= maxLines) { + return file; + } + + let currentLines = 0; + const keptHunks: DiffHunk[] = []; + + for (const hunk of file.hunks) { + const remainingLines = maxLines - currentLines; + if (remainingLines <= 0) { + break; + } + + if (hunk.lines.length <= remainingLines) { + keptHunks.push(hunk); + currentLines += hunk.lines.length; + continue; + } + + keptHunks.push({ + ...hunk, + lines: hunk.lines.slice(0, remainingLines), + }); + currentLines += remainingLines; + break; + } + + return { + ...file, + hunks: keptHunks, + lineCount: currentLines, + isTruncated: true, + originalLineCount: file.lineCount, + }; +} + +export function chunkFileDiff(file: FileDiff, maxLinesPerChunk: number): FileDiff[] { + if (file.lineCount <= maxLinesPerChunk) { + return [file]; + } + + const chunks: FileDiff[] = []; + let currentHunks: DiffHunk[] = []; + let currentLines = 0; + + for (const hunk of file.hunks) { + let linesRemainingInHunk = hunk.lines; + + while (linesRemainingInHunk.length > 0) { + const roomInChunk = maxLinesPerChunk - currentLines; + + if (roomInChunk <= 0) { + chunks.push({ + ...file, + hunks: currentHunks, + lineCount: currentLines, + isTruncated: true, + originalLineCount: file.lineCount, + }); + currentHunks = []; + currentLines = 0; + continue; + } + + if (linesRemainingInHunk.length <= roomInChunk) { + currentHunks.push({ + ...hunk, + lines: linesRemainingInHunk, + }); + currentLines += linesRemainingInHunk.length; + linesRemainingInHunk = []; + } else { + currentHunks.push({ + ...hunk, + lines: linesRemainingInHunk.slice(0, roomInChunk), + }); + currentLines += roomInChunk; + linesRemainingInHunk = linesRemainingInHunk.slice(roomInChunk); + } + } + } + + if (currentHunks.length > 0) { + chunks.push({ + ...file, + hunks: currentHunks, + lineCount: currentLines, + isTruncated: true, + originalLineCount: file.lineCount, + }); + } + + return chunks; +} diff --git a/packages/core/src/finding-gates.ts b/packages/core/src/finding-gates.ts index 712b1631..d848b295 100644 --- a/packages/core/src/finding-gates.ts +++ b/packages/core/src/finding-gates.ts @@ -1,154 +1,154 @@ -import type { FindingDisposition, ParsedReviewComment, RepoConfig } from '@codraoss/schema'; -import type { FileDiff } from './diff'; -import type { ReviewModel } from './ports'; -import { renderDiffSnippet, parseVerifyResponse, type VerifyCandidate } from './prompts/verify'; -import { logger } from './logger'; -import { reviewBreadth } from './prompts/file-review'; - -type VerifiableJob = { id: string }; - -const LOW_YIELD_TITLE = /missing|redundant|repetitive|inconsisten|documentation|\btype\b|\bany\b|potential/i; - -export function shadowEvaluate(candidates: ParsedReviewComment[], posted: ParsedReviewComment[]) { - const postedSet = new Set(posted); - const count = (predicate: (c: ParsedReviewComment) => boolean) => ({ - wouldDrop: candidates.filter(predicate).length, - wouldDropPosted: posted.filter(predicate).length, - }); - - return { - candidates: candidates.length, - posted: postedSet.size, - dropP3AndNit: count((c) => c.severity === 'P3' || c.severity === 'nit'), - dropLowYieldTitle: count((c) => LOW_YIELD_TITLE.test(c.title)), - dropUnmatchedEvidence: count((c) => !c.evidence), - }; -} - -function verifyCandidateLimit(breadth: number) { - return Math.min(40, Math.max(10, breadth * 3)); -} - -import { VERIFY_MIN_ANSWER_RATIO } from './constants'; - -export type VerifyDrop = { - comment: ParsedReviewComment; - disposition: Extract; - reason?: string; -}; - -/** `null` means verification ran; any other value means findings were posted unverified. */ -export type VerifySkipReason = 'no_verifiable_candidates' | 'low_answer_ratio' | 'verify_call_failed'; - -export type VerifyOutcome = { - comments: ParsedReviewComment[]; - dropped: VerifyDrop[]; - reasons: Map; - skipped: VerifySkipReason | null; -}; - -export async function verifyFindings(params: { - job: VerifiableJob; - config: RepoConfig; - files: FileDiff[]; - comments: ParsedReviewComment[]; - model: Pick; - maxCandidates?: number; -}): Promise { - const { comments, files, model, config, job } = params; - - const keepAll = (skipped: VerifySkipReason | null): VerifyOutcome => ({ - comments, - dropped: [], - reasons: new Map(), - skipped, - }); - - if (comments.length === 0) return keepAll(null); - - const limit = verifyCandidateLimit(params.maxCandidates ?? reviewBreadth(config.review)); - const toVerify = comments.slice(0, limit); - - const fileByPath = new Map(files.map((file) => [file.path, file])); - const prepared = toVerify.map((comment) => ({ - comment, - snippet: renderDiffSnippet(fileByPath.get(comment.path), comment.line ?? undefined), - })); - - const verifiable = prepared.filter((entry) => entry.snippet !== '' || entry.comment.evidence); - if (verifiable.length === 0) return keepAll('no_verifiable_candidates'); - - const candidates: VerifyCandidate[] = verifiable.map((entry, index) => ({ - index, - path: entry.comment.path, - line: entry.comment.line ?? null, - title: entry.comment.title, - body: entry.comment.body, - snippet: entry.snippet, - evidence: entry.comment.evidence ?? null, - })); - - try { - const response = await model.verifyFindings({ candidates, config }); - const results = parseVerifyResponse(response.rawText); - - const byIndex = new Map(); - const conflicting = new Set(); - for (const result of results) { - if (!Number.isInteger(result.index) || result.index < 0 || result.index >= candidates.length) continue; - const verdict = result.decidable === false ? 'drop' as const : result.verdict; - const prior = byIndex.get(result.index); - if (prior && prior.verdict !== verdict) { - conflicting.add(result.index); - continue; - } - if (!prior) byIndex.set(result.index, { verdict, reason: result.reason }); - } - for (const index of conflicting) byIndex.delete(index); - - const answered = byIndex.size; - if (answered === 0 || answered / candidates.length < VERIFY_MIN_ANSWER_RATIO) { - logger.warn('Verification did not answer enough indices; keeping all findings', { - jobId: job.id, candidates: candidates.length, answered, - }); - return keepAll('low_answer_ratio'); - } - - const dropped: VerifyDrop[] = []; - const reasons = new Map(); - - verifiable.forEach((entry, index) => { - const result = byIndex.get(index); - if (result?.reason) reasons.set(entry.comment, result.reason); - - if (result?.verdict === 'drop') { - dropped.push({ comment: entry.comment, disposition: 'verify', reason: result.reason }); - return; - } - if (!result) { - dropped.push({ - comment: entry.comment, - disposition: 'verify_unanswered', - reason: 'the verifier returned no verdict for this finding', - }); - } - }); - - const droppedSet = new Set(dropped.map((drop) => drop.comment)); - logger.info('Verification pass complete', { - jobId: job.id, - candidates: candidates.length, - answered, - dropped: dropped.length, - topReasons: dropped.slice(0, 5).map((drop) => drop.reason), - }); - - return { comments: comments.filter((comment) => !droppedSet.has(comment)), dropped, reasons, skipped: null }; - } catch (error) { - logger.warn('Verification pass failed; posting pre-verification findings', { - jobId: job.id, - error: error instanceof Error ? error.message : String(error), - }); - return keepAll('verify_call_failed'); - } -} +import type { FindingDisposition, ParsedReviewComment, RepoConfig } from '@codraoss/schema'; +import type { FileDiff } from './diff'; +import type { ReviewModel } from './ports'; +import { renderDiffSnippet, parseVerifyResponse, type VerifyCandidate } from './prompts/verify'; +import { logger } from './logger'; +import { reviewBreadth } from './prompts/file-review'; + +type VerifiableJob = { id: string }; + +const LOW_YIELD_TITLE = /missing|redundant|repetitive|inconsisten|documentation|\btype\b|\bany\b|potential/i; + +export function shadowEvaluate(candidates: ParsedReviewComment[], posted: ParsedReviewComment[]) { + const postedSet = new Set(posted); + const count = (predicate: (c: ParsedReviewComment) => boolean) => ({ + wouldDrop: candidates.filter(predicate).length, + wouldDropPosted: posted.filter(predicate).length, + }); + + return { + candidates: candidates.length, + posted: postedSet.size, + dropP3AndNit: count((c) => c.severity === 'P3' || c.severity === 'nit'), + dropLowYieldTitle: count((c) => LOW_YIELD_TITLE.test(c.title)), + dropUnmatchedEvidence: count((c) => !c.evidence), + }; +} + +function verifyCandidateLimit(breadth: number) { + return Math.min(40, Math.max(10, breadth * 3)); +} + +import { VERIFY_MIN_ANSWER_RATIO } from './constants'; + +export type VerifyDrop = { + comment: ParsedReviewComment; + disposition: Extract; + reason?: string; +}; + +/** `null` means verification ran; any other value means findings were posted unverified. */ +export type VerifySkipReason = 'no_verifiable_candidates' | 'low_answer_ratio' | 'verify_call_failed'; + +export type VerifyOutcome = { + comments: ParsedReviewComment[]; + dropped: VerifyDrop[]; + reasons: Map; + skipped: VerifySkipReason | null; +}; + +export async function verifyFindings(params: { + job: VerifiableJob; + config: RepoConfig; + files: FileDiff[]; + comments: ParsedReviewComment[]; + model: Pick; + maxCandidates?: number; +}): Promise { + const { comments, files, model, config, job } = params; + + const keepAll = (skipped: VerifySkipReason | null): VerifyOutcome => ({ + comments, + dropped: [], + reasons: new Map(), + skipped, + }); + + if (comments.length === 0) return keepAll(null); + + const limit = verifyCandidateLimit(params.maxCandidates ?? reviewBreadth(config.review)); + const toVerify = comments.slice(0, limit); + + const fileByPath = new Map(files.map((file) => [file.path, file])); + const prepared = toVerify.map((comment) => ({ + comment, + snippet: renderDiffSnippet(fileByPath.get(comment.path), comment.line ?? undefined), + })); + + const verifiable = prepared.filter((entry) => entry.snippet !== '' || entry.comment.evidence); + if (verifiable.length === 0) return keepAll('no_verifiable_candidates'); + + const candidates: VerifyCandidate[] = verifiable.map((entry, index) => ({ + index, + path: entry.comment.path, + line: entry.comment.line ?? null, + title: entry.comment.title, + body: entry.comment.body, + snippet: entry.snippet, + evidence: entry.comment.evidence ?? null, + })); + + try { + const response = await model.verifyFindings({ candidates, config }); + const results = parseVerifyResponse(response.rawText); + + const byIndex = new Map(); + const conflicting = new Set(); + for (const result of results) { + if (!Number.isInteger(result.index) || result.index < 0 || result.index >= candidates.length) continue; + const verdict = result.decidable === false ? 'drop' as const : result.verdict; + const prior = byIndex.get(result.index); + if (prior && prior.verdict !== verdict) { + conflicting.add(result.index); + continue; + } + if (!prior) byIndex.set(result.index, { verdict, reason: result.reason }); + } + for (const index of conflicting) byIndex.delete(index); + + const answered = byIndex.size; + if (answered === 0 || answered / candidates.length < VERIFY_MIN_ANSWER_RATIO) { + logger.warn('Verification did not answer enough indices; keeping all findings', { + jobId: job.id, candidates: candidates.length, answered, + }); + return keepAll('low_answer_ratio'); + } + + const dropped: VerifyDrop[] = []; + const reasons = new Map(); + + verifiable.forEach((entry, index) => { + const result = byIndex.get(index); + if (result?.reason) reasons.set(entry.comment, result.reason); + + if (result?.verdict === 'drop') { + dropped.push({ comment: entry.comment, disposition: 'verify', reason: result.reason }); + return; + } + if (!result) { + dropped.push({ + comment: entry.comment, + disposition: 'verify_unanswered', + reason: 'the verifier returned no verdict for this finding', + }); + } + }); + + const droppedSet = new Set(dropped.map((drop) => drop.comment)); + logger.info('Verification pass complete', { + jobId: job.id, + candidates: candidates.length, + answered, + dropped: dropped.length, + topReasons: dropped.slice(0, 5).map((drop) => drop.reason), + }); + + return { comments: comments.filter((comment) => !droppedSet.has(comment)), dropped, reasons, skipped: null }; + } catch (error) { + logger.warn('Verification pass failed; posting pre-verification findings', { + jobId: job.id, + error: error instanceof Error ? error.message : String(error), + }); + return keepAll('verify_call_failed'); + } +} diff --git a/packages/core/src/fingerprint.ts b/packages/core/src/fingerprint.ts index 72fdbe82..1a4b080e 100644 --- a/packages/core/src/fingerprint.ts +++ b/packages/core/src/fingerprint.ts @@ -1,45 +1,45 @@ - -export function fnv1a32Hex(input: string): string { - let hash = 0x811c9dc5; - for (let i = 0; i < input.length; i++) { - hash ^= input.charCodeAt(i); - hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0; - } - return hash.toString(16).padStart(8, '0'); -} - -export function normalizeDiffText(input: string): string { - return input - .replace(/^\s*\d*\s+\d*\s*[+\- ]?/, '') - .replace(/\s+/g, ' ') - .trim(); -} - -export function foldEvidenceText(input: string): string { - return normalizeDiffText(input) - .replace(/[‘’‚‛′]/g, "'") - .replace(/[“”„‟″]/g, '"') - .replace(/[‐-―−]/g, '-') - .replace(/…/g, '...'); -} - -export function normalizeFindingTitle(title: string): string { - return title.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); -} - -export function buildFindingFingerprint(path: string, title: string): string { - return fnv1a32Hex(`${path}\u0000${normalizeFindingTitle(title)}`); -} - -export function buildFindingFingerprintV2( - path: string, - claimType: string | null | undefined, - anchorHash: string | null | undefined, -): string | null { - if (!anchorHash) return null; - return fnv1a32Hex(`v2 ${path} ${claimType ?? 'other'} ${anchorHash}`); -} - -export function buildAnchorHash(lineContent: string): string { - return fnv1a32Hex(normalizeDiffText(lineContent)); -} + +export function fnv1a32Hex(input: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0; + } + return hash.toString(16).padStart(8, '0'); +} + +export function normalizeDiffText(input: string): string { + return input + .replace(/^\s*\d*\s+\d*\s*[+\- ]?/, '') + .replace(/\s+/g, ' ') + .trim(); +} + +export function foldEvidenceText(input: string): string { + return normalizeDiffText(input) + .replace(/[‘’‚‛′]/g, "'") + .replace(/[“”„‟″]/g, '"') + .replace(/[‐-―−]/g, '-') + .replace(/…/g, '...'); +} + +export function normalizeFindingTitle(title: string): string { + return title.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); +} + +export function buildFindingFingerprint(path: string, title: string): string { + return fnv1a32Hex(`${path}\u0000${normalizeFindingTitle(title)}`); +} + +export function buildFindingFingerprintV2( + path: string, + claimType: string | null | undefined, + anchorHash: string | null | undefined, +): string | null { + if (!anchorHash) return null; + return fnv1a32Hex(`v2 ${path} ${claimType ?? 'other'} ${anchorHash}`); +} + +export function buildAnchorHash(lineContent: string): string { + return fnv1a32Hex(normalizeDiffText(lineContent)); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d414adec..3bbb2e4f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,28 +1,28 @@ -export { - runReview, - type ReviewJobRunResult, - FRESH_INVOCATION_YIELD_SECONDS, - NextPhaseError, - failJobAndCheckRun, - extractReviewRequest, - type ReviewRequest, - getDiffFiles, - getOrFetchRawDiffForCompletedJob, - budgetAwareFileLimit, - estimatedSubrequestsPerFile, - BIN_DIFF_CHAR_BUDGET, - BIN_MAX_FILES, - BIN_TARGET_DIFF_LINES, - PACKABLE_MAX_DIFF_LINES, - narrowUnit, - planReviewUnits, - unitFiles, - proportionalSplit, - type LedgerEntry, - type ReviewUnit, - verifyFindings, - type VerifyDrop, - type VerifyOutcome, -} from './review'; - -export * from './ports'; +export { + runReview, + type ReviewJobRunResult, + FRESH_INVOCATION_YIELD_SECONDS, + NextPhaseError, + failJobAndCheckRun, + extractReviewRequest, + type ReviewRequest, + getDiffFiles, + getOrFetchRawDiffForCompletedJob, + budgetAwareFileLimit, + estimatedSubrequestsPerFile, + BIN_DIFF_CHAR_BUDGET, + BIN_MAX_FILES, + BIN_TARGET_DIFF_LINES, + PACKABLE_MAX_DIFF_LINES, + narrowUnit, + planReviewUnits, + unitFiles, + proportionalSplit, + type LedgerEntry, + type ReviewUnit, + verifyFindings, + type VerifyDrop, + type VerifyOutcome, +} from './review'; + +export * from './ports'; diff --git a/packages/core/src/logger.ts b/packages/core/src/logger.ts index 2ad97fa7..8910ca0e 100644 --- a/packages/core/src/logger.ts +++ b/packages/core/src/logger.ts @@ -1,108 +1,108 @@ - -/** - * The logging port. A correct implementation must: - * - never throw, for any input, including circular objects (callers log on failure paths, so a - * throwing logger converts a handled error into an unhandled one); - * - never block the caller on I/O; - * - scrub secrets before emitting, using `scrubString`/`redact` below rather than its own rules. - * Ordering between calls is not guaranteed and callers must not rely on it. - */ -export interface Logger { - info(message: string, data?: unknown): void; - warn(message: string, data?: unknown): void; - error(message: string, data?: unknown): void; - debug(message: string, data?: unknown): void; -} - -const SENSITIVE_KEYS = [ - 'api_key', - 'api-key', - 'apikey', - 'secret', - 'password', - 'token', - 'private_key', - 'private-key', - 'database_url', - 'authorization', - 'session', - 'cookie', -]; - -const JWT = /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*/g; -const BEARER = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi; - -export function scrubString(value: string): string { - return value.replace(JWT, '[REDACTED_JWT]').replace(BEARER, (m) => `${m.split(/\s+/)[0]} [REDACTED]`); -} - -export function redact(obj: any): any { - if (obj === null || obj === undefined) return obj; - if (typeof obj !== 'object') { - return typeof obj === 'string' ? scrubString(obj) : obj; - } - if (Array.isArray(obj)) return obj.map(redact); - if (obj instanceof Error) { - return { - name: obj.name, - message: scrubString(obj.message), - ...(obj.stack ? { stack: scrubString(obj.stack) } : {}), - }; - } - - const redacted: any = {}; - for (const [key, value] of Object.entries(obj)) { - const lowerKey = key.toLowerCase(); - if (SENSITIVE_KEYS.some((sk) => lowerKey.includes(sk))) { - redacted[key] = '[REDACTED]'; - } else { - redacted[key] = redact(value); - } - } - return redacted; -} - -export function formatLogRecord( - level: string, - message: string, - contexts: Array>, - data?: any, -): Record { - return { - timestamp: new Date().toISOString(), - level, - message: scrubString(message), - ...contexts.reduce>((merged, context) => Object.assign(merged, redact(context)), {}), - ...(data ? { data: redact(data) } : {}), - }; -} - -export const consoleLogger: Logger = { - info: (message, data) => console.log(JSON.stringify(formatLogRecord('info', message, [], data))), - warn: (message, data) => console.warn(JSON.stringify(formatLogRecord('warn', message, [], data))), - error: (message, data) => console.error(JSON.stringify(formatLogRecord('error', message, [], data))), - debug: (message, data) => console.log(JSON.stringify(formatLogRecord('debug', message, [], data))), -}; - -let sink: Logger = consoleLogger; - -/** - * Installs the host's logger. Called once at import scope by src/server/core/logger.ts, and by tests - * that want to capture output. - * - * This is the one piece of module-level mutable state in this package, and it is deliberate: `logger` - * below is used at import scope by fifteen modules here, several of them (model-output/*, rules/*, - * finding-gates.ts) pure functions with no runtime parameter to hang a port off. Threading a Logger - * argument through all of them would be by far the largest and least mechanical part of the - * extraction, for no behavioural gain. - */ -export function setLoggerSink(next: Logger) { - sink = next; -} - -export const logger: Logger = { - info: (message, data) => sink.info(message, data), - warn: (message, data) => sink.warn(message, data), - error: (message, data) => sink.error(message, data), - debug: (message, data) => sink.debug(message, data), -}; + +/** + * The logging port. A correct implementation must: + * - never throw, for any input, including circular objects (callers log on failure paths, so a + * throwing logger converts a handled error into an unhandled one); + * - never block the caller on I/O; + * - scrub secrets before emitting, using `scrubString`/`redact` below rather than its own rules. + * Ordering between calls is not guaranteed and callers must not rely on it. + */ +export interface Logger { + info(message: string, data?: unknown): void; + warn(message: string, data?: unknown): void; + error(message: string, data?: unknown): void; + debug(message: string, data?: unknown): void; +} + +const SENSITIVE_KEYS = [ + 'api_key', + 'api-key', + 'apikey', + 'secret', + 'password', + 'token', + 'private_key', + 'private-key', + 'database_url', + 'authorization', + 'session', + 'cookie', +]; + +const JWT = /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*/g; +const BEARER = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi; + +export function scrubString(value: string): string { + return value.replace(JWT, '[REDACTED_JWT]').replace(BEARER, (m) => `${m.split(/\s+/)[0]} [REDACTED]`); +} + +export function redact(obj: any): any { + if (obj === null || obj === undefined) return obj; + if (typeof obj !== 'object') { + return typeof obj === 'string' ? scrubString(obj) : obj; + } + if (Array.isArray(obj)) return obj.map(redact); + if (obj instanceof Error) { + return { + name: obj.name, + message: scrubString(obj.message), + ...(obj.stack ? { stack: scrubString(obj.stack) } : {}), + }; + } + + const redacted: any = {}; + for (const [key, value] of Object.entries(obj)) { + const lowerKey = key.toLowerCase(); + if (SENSITIVE_KEYS.some((sk) => lowerKey.includes(sk))) { + redacted[key] = '[REDACTED]'; + } else { + redacted[key] = redact(value); + } + } + return redacted; +} + +export function formatLogRecord( + level: string, + message: string, + contexts: Array>, + data?: any, +): Record { + return { + timestamp: new Date().toISOString(), + level, + message: scrubString(message), + ...contexts.reduce>((merged, context) => Object.assign(merged, redact(context)), {}), + ...(data ? { data: redact(data) } : {}), + }; +} + +export const consoleLogger: Logger = { + info: (message, data) => console.log(JSON.stringify(formatLogRecord('info', message, [], data))), + warn: (message, data) => console.warn(JSON.stringify(formatLogRecord('warn', message, [], data))), + error: (message, data) => console.error(JSON.stringify(formatLogRecord('error', message, [], data))), + debug: (message, data) => console.log(JSON.stringify(formatLogRecord('debug', message, [], data))), +}; + +let sink: Logger = consoleLogger; + +/** + * Installs the host's logger. Called once at import scope by src/server/core/logger.ts, and by tests + * that want to capture output. + * + * This is the one piece of module-level mutable state in this package, and it is deliberate: `logger` + * below is used at import scope by fifteen modules here, several of them (model-output/*, rules/*, + * finding-gates.ts) pure functions with no runtime parameter to hang a port off. Threading a Logger + * argument through all of them would be by far the largest and least mechanical part of the + * extraction, for no behavioural gain. + */ +export function setLoggerSink(next: Logger) { + sink = next; +} + +export const logger: Logger = { + info: (message, data) => sink.info(message, data), + warn: (message, data) => sink.warn(message, data), + error: (message, data) => sink.error(message, data), + debug: (message, data) => sink.debug(message, data), +}; diff --git a/packages/core/src/model-output/batch.ts b/packages/core/src/model-output/batch.ts index 1fa5e459..ef110072 100644 --- a/packages/core/src/model-output/batch.ts +++ b/packages/core/src/model-output/batch.ts @@ -1,144 +1,144 @@ -import type { ClaimType } from '@codraoss/schema'; -import type { FileDiff } from '../diff'; -import { generatorFindingCap } from '../prompts/file-review'; -import { logger } from '../logger'; -import { buildBinAmbiguityIndex } from './evidence'; -import { type GroundedFileReview, groundParsedFindings, samePath } from './index'; -import { parseRawBatchPayload } from './json-batch'; - -export type BatchParseStats = { - unroutableEntries: number; - pathMismatchFindings: number; - ambiguousAcrossBin: number; - flatFallback: number; - overCap: number; - entriesReturned: number; -}; - -export type BatchReviewResult = { - reviews: Map; - missing: string[]; - stats: BatchParseStats; -}; - -type Ambiguity = { index: ReturnType; stats: { ambiguousAcrossBin: number } }; -type RawEntry = { findings: unknown[]; overall_correctness: string; overall_explanation: string }; - -const basename = (path: string) => path.split('/').pop() ?? path; -import { SEVERITY_ORDER } from '../constants'; - -function resolveEntryPath(reported: string, candidates: readonly FileDiff[], claimed: Set): FileDiff | null { - const unclaimed = (matches: readonly FileDiff[]) => matches.find((f) => !claimed.has(f.path)) ?? null; - - const exact = candidates.filter((f) => samePath(f.path, reported)); - const renamed = candidates.filter((f) => f.previousPath && samePath(f.previousPath, reported)); - if (exact.length > 0 || renamed.length > 0) return unclaimed(exact) ?? unclaimed(renamed); - - const stripped = reported.trim().replace(/^\.\//, '').replace(/^[ab]\//, '').replace(/^\//, ''); - const suffixed = candidates.filter((f) => f.path.endsWith(`/${stripped}`)); - if (suffixed.length === 1) return unclaimed(suffixed); - - const named = candidates.filter((f) => basename(f.path) === basename(stripped)); - return named.length === 1 ? unclaimed(named) : null; -} - -function groundEntry( - file: FileDiff, - entry: RawEntry, - deniedClaimTypes: readonly ClaimType[] | undefined, - ambiguity: Ambiguity, - confidenceScore: number | undefined, - stats: BatchParseStats, -): GroundedFileReview { - for (const finding of entry.findings as Array<{ code_location?: { absolute_file_path?: string } }>) { - const claimed = finding.code_location?.absolute_file_path?.trim(); - if (claimed && !samePath(claimed, file.path)) stats.pathMismatchFindings += 1; - } - - return groundParsedFindings( - { ...entry, findings: entry.findings as never, overall_confidence_score: confidenceScore }, - file, - { deniedClaimTypes, ambiguity: { index: ambiguity.index, filePath: file.path, stats: ambiguity.stats } }, - ); -} - -function trimOverCap(reviews: Map, cap: number, stats: BatchParseStats) { - for (const [path, review] of reviews) { - if (review.comments.length <= cap) continue; - - const ranked = review.comments.toSorted((a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)); - const dropped = ranked.slice(cap); - stats.overCap += dropped.length; - - const header = '### Additional Comments (Off-diff)'; - const bullets = dropped.map((c) => `- **[over-cap] ${c.title}:** ${c.body}`).join('\n'); - reviews.set(path, { - ...review, - comments: ranked.slice(0, cap), - fileSummary: review.fileSummary.includes(header) - ? `${review.fileSummary}\n${bullets}` - : `${review.fileSummary}\n\n${header}\n${bullets}`, - }); - } -} - -export function parseBatchReviewResponse( - raw: string, - files: readonly FileDiff[], - options?: { deniedClaimTypes?: readonly ClaimType[]; maxCommentsPerFile?: number }, -): BatchReviewResult { - const stats: BatchParseStats = { - unroutableEntries: 0, pathMismatchFindings: 0, ambiguousAcrossBin: 0, - flatFallback: 0, overCap: 0, entriesReturned: 0, - }; - const payload = parseRawBatchPayload(raw); - const reviews = new Map(); - const ambiguity: Ambiguity = { index: buildBinAmbiguityIndex(files), stats: { ambiguousAcrossBin: 0 } }; - const claimed = new Set(); - - const ground = (file: FileDiff, entry: RawEntry, confidence: number | undefined) => - reviews.set(file.path, groundEntry(file, entry, options?.deniedClaimTypes, ambiguity, confidence, stats)); - - if (payload.shape === 'flat') { - stats.flatFallback = 1; - stats.entriesReturned = 1; - - const byFile = new Map(); - for (const finding of payload.data.findings) { - const reported = finding.code_location.absolute_file_path?.trim(); - const target = !reported && files.length === 1 ? files[0] : resolveEntryPath(reported ?? '', files, claimed); - if (!target) { - stats.unroutableEntries += 1; - continue; - } - const bucket = byFile.get(target.path); - if (bucket) bucket.findings.push(finding); - else byFile.set(target.path, { file: target, findings: [finding] }); - } - - for (const { file, findings } of byFile.values()) { - ground(file, { - findings, - overall_correctness: payload.data.overall_correctness, - overall_explanation: payload.data.overall_explanation, - }, payload.data.overall_confidence_score); - } - } else { - stats.entriesReturned = payload.data.files.length; - for (const entry of payload.data.files) { - const file = resolveEntryPath(entry.absolute_file_path, files, claimed); - if (!file) { - stats.unroutableEntries += 1; - logger.warn('Batched review returned an entry for an unknown path', { reported: entry.absolute_file_path }); - continue; - } - claimed.add(file.path); - ground(file, entry, entry.overall_confidence_score ?? payload.data.overall_confidence_score); - } - } - - stats.ambiguousAcrossBin = ambiguity.stats.ambiguousAcrossBin; - if (options?.maxCommentsPerFile) trimOverCap(reviews, generatorFindingCap(options.maxCommentsPerFile), stats); - - return { reviews, missing: files.flatMap((f) => (reviews.has(f.path) ? [] : [f.path])), stats }; -} +import type { ClaimType } from '@codraoss/schema'; +import type { FileDiff } from '../diff'; +import { generatorFindingCap } from '../prompts/file-review'; +import { logger } from '../logger'; +import { buildBinAmbiguityIndex } from './evidence'; +import { type GroundedFileReview, groundParsedFindings, samePath } from './index'; +import { parseRawBatchPayload } from './json-batch'; + +export type BatchParseStats = { + unroutableEntries: number; + pathMismatchFindings: number; + ambiguousAcrossBin: number; + flatFallback: number; + overCap: number; + entriesReturned: number; +}; + +export type BatchReviewResult = { + reviews: Map; + missing: string[]; + stats: BatchParseStats; +}; + +type Ambiguity = { index: ReturnType; stats: { ambiguousAcrossBin: number } }; +type RawEntry = { findings: unknown[]; overall_correctness: string; overall_explanation: string }; + +const basename = (path: string) => path.split('/').pop() ?? path; +import { SEVERITY_ORDER } from '../constants'; + +function resolveEntryPath(reported: string, candidates: readonly FileDiff[], claimed: Set): FileDiff | null { + const unclaimed = (matches: readonly FileDiff[]) => matches.find((f) => !claimed.has(f.path)) ?? null; + + const exact = candidates.filter((f) => samePath(f.path, reported)); + const renamed = candidates.filter((f) => f.previousPath && samePath(f.previousPath, reported)); + if (exact.length > 0 || renamed.length > 0) return unclaimed(exact) ?? unclaimed(renamed); + + const stripped = reported.trim().replace(/^\.\//, '').replace(/^[ab]\//, '').replace(/^\//, ''); + const suffixed = candidates.filter((f) => f.path.endsWith(`/${stripped}`)); + if (suffixed.length === 1) return unclaimed(suffixed); + + const named = candidates.filter((f) => basename(f.path) === basename(stripped)); + return named.length === 1 ? unclaimed(named) : null; +} + +function groundEntry( + file: FileDiff, + entry: RawEntry, + deniedClaimTypes: readonly ClaimType[] | undefined, + ambiguity: Ambiguity, + confidenceScore: number | undefined, + stats: BatchParseStats, +): GroundedFileReview { + for (const finding of entry.findings as Array<{ code_location?: { absolute_file_path?: string } }>) { + const claimed = finding.code_location?.absolute_file_path?.trim(); + if (claimed && !samePath(claimed, file.path)) stats.pathMismatchFindings += 1; + } + + return groundParsedFindings( + { ...entry, findings: entry.findings as never, overall_confidence_score: confidenceScore }, + file, + { deniedClaimTypes, ambiguity: { index: ambiguity.index, filePath: file.path, stats: ambiguity.stats } }, + ); +} + +function trimOverCap(reviews: Map, cap: number, stats: BatchParseStats) { + for (const [path, review] of reviews) { + if (review.comments.length <= cap) continue; + + const ranked = review.comments.toSorted((a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)); + const dropped = ranked.slice(cap); + stats.overCap += dropped.length; + + const header = '### Additional Comments (Off-diff)'; + const bullets = dropped.map((c) => `- **[over-cap] ${c.title}:** ${c.body}`).join('\n'); + reviews.set(path, { + ...review, + comments: ranked.slice(0, cap), + fileSummary: review.fileSummary.includes(header) + ? `${review.fileSummary}\n${bullets}` + : `${review.fileSummary}\n\n${header}\n${bullets}`, + }); + } +} + +export function parseBatchReviewResponse( + raw: string, + files: readonly FileDiff[], + options?: { deniedClaimTypes?: readonly ClaimType[]; maxCommentsPerFile?: number }, +): BatchReviewResult { + const stats: BatchParseStats = { + unroutableEntries: 0, pathMismatchFindings: 0, ambiguousAcrossBin: 0, + flatFallback: 0, overCap: 0, entriesReturned: 0, + }; + const payload = parseRawBatchPayload(raw); + const reviews = new Map(); + const ambiguity: Ambiguity = { index: buildBinAmbiguityIndex(files), stats: { ambiguousAcrossBin: 0 } }; + const claimed = new Set(); + + const ground = (file: FileDiff, entry: RawEntry, confidence: number | undefined) => + reviews.set(file.path, groundEntry(file, entry, options?.deniedClaimTypes, ambiguity, confidence, stats)); + + if (payload.shape === 'flat') { + stats.flatFallback = 1; + stats.entriesReturned = 1; + + const byFile = new Map(); + for (const finding of payload.data.findings) { + const reported = finding.code_location.absolute_file_path?.trim(); + const target = !reported && files.length === 1 ? files[0] : resolveEntryPath(reported ?? '', files, claimed); + if (!target) { + stats.unroutableEntries += 1; + continue; + } + const bucket = byFile.get(target.path); + if (bucket) bucket.findings.push(finding); + else byFile.set(target.path, { file: target, findings: [finding] }); + } + + for (const { file, findings } of byFile.values()) { + ground(file, { + findings, + overall_correctness: payload.data.overall_correctness, + overall_explanation: payload.data.overall_explanation, + }, payload.data.overall_confidence_score); + } + } else { + stats.entriesReturned = payload.data.files.length; + for (const entry of payload.data.files) { + const file = resolveEntryPath(entry.absolute_file_path, files, claimed); + if (!file) { + stats.unroutableEntries += 1; + logger.warn('Batched review returned an entry for an unknown path', { reported: entry.absolute_file_path }); + continue; + } + claimed.add(file.path); + ground(file, entry, entry.overall_confidence_score ?? payload.data.overall_confidence_score); + } + } + + stats.ambiguousAcrossBin = ambiguity.stats.ambiguousAcrossBin; + if (options?.maxCommentsPerFile) trimOverCap(reviews, generatorFindingCap(options.maxCommentsPerFile), stats); + + return { reviews, missing: files.flatMap((f) => (reviews.has(f.path) ? [] : [f.path])), stats }; +} diff --git a/packages/core/src/model-output/dedupe.ts b/packages/core/src/model-output/dedupe.ts index a5804f0a..b2cefe8d 100644 --- a/packages/core/src/model-output/dedupe.ts +++ b/packages/core/src/model-output/dedupe.ts @@ -1,34 +1,34 @@ -import type { ParsedReviewComment } from '@codraoss/schema'; -import { normalizeFindingTitle } from '../fingerprint'; - -const SEVERITY_RANK: Record = { P0: 0, P1: 1, P2: 2, P3: 3, nit: 4 }; - -const NUL = String.fromCharCode(0); - -export function dedupeFindings(comments: ParsedReviewComment[]): ParsedReviewComment[] { - const best = new Map(); - for (const comment of comments) { - // Union, keyed per location. Never weight by agreement: 7 configs agreeing measured 7% correct, 1 config 20%. - const normalizedTitle = comment.source === 'rule' ? '' : normalizeFindingTitle(comment.title); - if (comment.source !== 'rule' && !normalizedTitle) { - best.set(`__unique__${best.size}`, comment); - continue; - } - - const key = comment.source === 'rule' - ? `rule${NUL}${comment.ruleId ?? ''}${NUL}${comment.path}${NUL}${comment.anchorHash ?? ''}` - : `llm${NUL}${comment.path}${NUL}${comment.anchorHash ?? comment.line ?? ''}${NUL}${normalizedTitle}`; - const existing = best.get(key); - if (!existing) { - best.set(key, comment); - continue; - } - const rank = SEVERITY_RANK[comment.severity] ?? 4; - const existingRank = SEVERITY_RANK[existing.severity] ?? 4; - const isBetter = - rank < existingRank || - (rank === existingRank && (comment.confidenceScore ?? 0) > (existing.confidenceScore ?? 0)); - if (isBetter) best.set(key, comment); - } - return Array.from(best.values()); -} +import type { ParsedReviewComment } from '@codraoss/schema'; +import { normalizeFindingTitle } from '../fingerprint'; + +const SEVERITY_RANK: Record = { P0: 0, P1: 1, P2: 2, P3: 3, nit: 4 }; + +const NUL = String.fromCharCode(0); + +export function dedupeFindings(comments: ParsedReviewComment[]): ParsedReviewComment[] { + const best = new Map(); + for (const comment of comments) { + // Union, keyed per location. Never weight by agreement: 7 configs agreeing measured 7% correct, 1 config 20%. + const normalizedTitle = comment.source === 'rule' ? '' : normalizeFindingTitle(comment.title); + if (comment.source !== 'rule' && !normalizedTitle) { + best.set(`__unique__${best.size}`, comment); + continue; + } + + const key = comment.source === 'rule' + ? `rule${NUL}${comment.ruleId ?? ''}${NUL}${comment.path}${NUL}${comment.anchorHash ?? ''}` + : `llm${NUL}${comment.path}${NUL}${comment.anchorHash ?? comment.line ?? ''}${NUL}${normalizedTitle}`; + const existing = best.get(key); + if (!existing) { + best.set(key, comment); + continue; + } + const rank = SEVERITY_RANK[comment.severity] ?? 4; + const existingRank = SEVERITY_RANK[existing.severity] ?? 4; + const isBetter = + rank < existingRank || + (rank === existingRank && (comment.confidenceScore ?? 0) > (existing.confidenceScore ?? 0)); + if (isBetter) best.set(key, comment); + } + return Array.from(best.values()); +} diff --git a/packages/core/src/model-output/evidence.ts b/packages/core/src/model-output/evidence.ts index 1f4f71a0..9a23bbd2 100644 --- a/packages/core/src/model-output/evidence.ts +++ b/packages/core/src/model-output/evidence.ts @@ -1,122 +1,122 @@ -import { foldEvidenceText } from '../fingerprint'; -import type { DiffLine, FileDiff } from '../diff'; - -import { MIN_DISCRIMINATING_EVIDENCE_CHARS } from '../constants'; - -/** `anchor` is where a quoting finding gets posted; `sourceKind` is the text's kind pre-re-anchoring. */ -export type IndexedLine = { anchor: DiffLine; sourceKind: DiffLine['kind'] }; - -export type EvidenceIndex = { - byContent: Map; - lines: { normalized: string; line: IndexedLine }[]; -}; - -export function buildEvidenceIndex(file: FileDiff): EvidenceIndex { - const byContent = new Map(); - const lines: { normalized: string; line: IndexedLine }[] = []; - - for (const hunk of file.hunks) { - const postable = hunk.lines.filter((line) => line.kind !== 'del' && line.newLineNumber !== undefined); - if (postable.length === 0) continue; - - hunk.lines.forEach((line, lineIndex) => { - const normalized = foldEvidenceText(line.content); - if (!normalized) return; - - let anchor = line; - if (line.kind === 'del' || line.newLineNumber === undefined) { - anchor = hunk.lines.slice(lineIndex + 1).find((l) => l.kind !== 'del' && l.newLineNumber !== undefined) - ?? hunk.lines.slice(0, lineIndex).reverse().find((l) => l.kind !== 'del' && l.newLineNumber !== undefined) - ?? postable[0]; - } - - const entry: IndexedLine = { anchor, sourceKind: line.kind }; - lines.push({ normalized, line: entry }); - const existing = byContent.get(normalized); - if (existing) existing.push(entry); - else byContent.set(normalized, [entry]); - }); - } - - return { byContent, lines }; -} - -export function foldFirstEvidenceLine(evidence: unknown): string | null { - if (typeof evidence !== 'string') return null; - return evidence.split('\n').map(foldEvidenceText).find((l) => l.length > 0) ?? null; -} - -export type BinAmbiguityIndex = Map; - -export function buildBinAmbiguityIndex(files: readonly FileDiff[]): BinAmbiguityIndex { - const filesPerLine = new Map>(); - - for (const file of files) { - for (const hunk of file.hunks) { - for (const line of hunk.lines) { - const normalized = foldEvidenceText(line.content); - if (normalized.length < MIN_DISCRIMINATING_EVIDENCE_CHARS) continue; - const paths = filesPerLine.get(normalized); - if (paths) paths.add(file.path); - else filesPerLine.set(normalized, new Set([file.path])); - } - } - } - - const index: BinAmbiguityIndex = new Map(); - for (const [normalized, paths] of filesPerLine) { - if (paths.size > 1) index.set(normalized, paths.size); - } - return index; -} - -export type EvidenceResolution = - | { status: 'absent' } - | { status: 'weak' } - // `touched` is false only when every occurrence of the quoted text is an untouched context line. - | { status: 'matched'; line: DiffLine; touched: boolean } - | { status: 'unmatched' }; - -export function resolveEvidence( - evidence: unknown, - index: EvidenceIndex, - reportedLine: number | undefined, -): EvidenceResolution { - if (typeof evidence !== 'string') return { status: 'absent' }; - - const firstLine = foldFirstEvidenceLine(evidence); - if (!firstLine) return { status: 'absent' }; - if (firstLine.length < MIN_DISCRIMINATING_EVIDENCE_CHARS) return { status: 'weak' }; - - // Judged over the whole candidate set, so one context occurrence cannot refuse a changed one. - const anyTouched = (candidates: IndexedLine[]) => candidates.some((c) => c.sourceKind !== 'context'); - - const nearest = (candidates: IndexedLine[]) => { - const preferred = candidates.some((c) => c.sourceKind !== 'context') - ? candidates.filter((c) => c.sourceKind !== 'context') - : candidates; - if (reportedLine === undefined) return preferred[0].anchor; - return preferred.reduce((best, candidate) => - Math.abs((candidate.anchor.newLineNumber ?? 0) - reportedLine) - < Math.abs((best.anchor.newLineNumber ?? 0) - reportedLine) - ? candidate - : best, - ).anchor; - }; - - const exact = index.byContent.get(firstLine); - if (exact && exact.length > 0) { - return { status: 'matched', line: nearest(exact), touched: anyTouched(exact) }; - } - - const contained = index.lines.flatMap(({ normalized, line }) => - normalized.length >= MIN_DISCRIMINATING_EVIDENCE_CHARS - && (normalized.includes(firstLine) || firstLine.includes(normalized)) - ? [line] - : []); - if (contained.length > 0) { - return { status: 'matched', line: nearest(contained), touched: anyTouched(contained) }; - } - - return { status: 'unmatched' }; -} +import { foldEvidenceText } from '../fingerprint'; +import type { DiffLine, FileDiff } from '../diff'; + +import { MIN_DISCRIMINATING_EVIDENCE_CHARS } from '../constants'; + +/** `anchor` is where a quoting finding gets posted; `sourceKind` is the text's kind pre-re-anchoring. */ +export type IndexedLine = { anchor: DiffLine; sourceKind: DiffLine['kind'] }; + +export type EvidenceIndex = { + byContent: Map; + lines: { normalized: string; line: IndexedLine }[]; +}; + +export function buildEvidenceIndex(file: FileDiff): EvidenceIndex { + const byContent = new Map(); + const lines: { normalized: string; line: IndexedLine }[] = []; + + for (const hunk of file.hunks) { + const postable = hunk.lines.filter((line) => line.kind !== 'del' && line.newLineNumber !== undefined); + if (postable.length === 0) continue; + + hunk.lines.forEach((line, lineIndex) => { + const normalized = foldEvidenceText(line.content); + if (!normalized) return; + + let anchor = line; + if (line.kind === 'del' || line.newLineNumber === undefined) { + anchor = hunk.lines.slice(lineIndex + 1).find((l) => l.kind !== 'del' && l.newLineNumber !== undefined) + ?? hunk.lines.slice(0, lineIndex).reverse().find((l) => l.kind !== 'del' && l.newLineNumber !== undefined) + ?? postable[0]; + } + + const entry: IndexedLine = { anchor, sourceKind: line.kind }; + lines.push({ normalized, line: entry }); + const existing = byContent.get(normalized); + if (existing) existing.push(entry); + else byContent.set(normalized, [entry]); + }); + } + + return { byContent, lines }; +} + +export function foldFirstEvidenceLine(evidence: unknown): string | null { + if (typeof evidence !== 'string') return null; + return evidence.split('\n').map(foldEvidenceText).find((l) => l.length > 0) ?? null; +} + +export type BinAmbiguityIndex = Map; + +export function buildBinAmbiguityIndex(files: readonly FileDiff[]): BinAmbiguityIndex { + const filesPerLine = new Map>(); + + for (const file of files) { + for (const hunk of file.hunks) { + for (const line of hunk.lines) { + const normalized = foldEvidenceText(line.content); + if (normalized.length < MIN_DISCRIMINATING_EVIDENCE_CHARS) continue; + const paths = filesPerLine.get(normalized); + if (paths) paths.add(file.path); + else filesPerLine.set(normalized, new Set([file.path])); + } + } + } + + const index: BinAmbiguityIndex = new Map(); + for (const [normalized, paths] of filesPerLine) { + if (paths.size > 1) index.set(normalized, paths.size); + } + return index; +} + +export type EvidenceResolution = + | { status: 'absent' } + | { status: 'weak' } + // `touched` is false only when every occurrence of the quoted text is an untouched context line. + | { status: 'matched'; line: DiffLine; touched: boolean } + | { status: 'unmatched' }; + +export function resolveEvidence( + evidence: unknown, + index: EvidenceIndex, + reportedLine: number | undefined, +): EvidenceResolution { + if (typeof evidence !== 'string') return { status: 'absent' }; + + const firstLine = foldFirstEvidenceLine(evidence); + if (!firstLine) return { status: 'absent' }; + if (firstLine.length < MIN_DISCRIMINATING_EVIDENCE_CHARS) return { status: 'weak' }; + + // Judged over the whole candidate set, so one context occurrence cannot refuse a changed one. + const anyTouched = (candidates: IndexedLine[]) => candidates.some((c) => c.sourceKind !== 'context'); + + const nearest = (candidates: IndexedLine[]) => { + const preferred = candidates.some((c) => c.sourceKind !== 'context') + ? candidates.filter((c) => c.sourceKind !== 'context') + : candidates; + if (reportedLine === undefined) return preferred[0].anchor; + return preferred.reduce((best, candidate) => + Math.abs((candidate.anchor.newLineNumber ?? 0) - reportedLine) + < Math.abs((best.anchor.newLineNumber ?? 0) - reportedLine) + ? candidate + : best, + ).anchor; + }; + + const exact = index.byContent.get(firstLine); + if (exact && exact.length > 0) { + return { status: 'matched', line: nearest(exact), touched: anyTouched(exact) }; + } + + const contained = index.lines.flatMap(({ normalized, line }) => + normalized.length >= MIN_DISCRIMINATING_EVIDENCE_CHARS + && (normalized.includes(firstLine) || firstLine.includes(normalized)) + ? [line] + : []); + if (contained.length > 0) { + return { status: 'matched', line: nearest(contained), touched: anyTouched(contained) }; + } + + return { status: 'unmatched' }; +} diff --git a/packages/core/src/model-output/index.ts b/packages/core/src/model-output/index.ts index 631ee913..811dc314 100644 --- a/packages/core/src/model-output/index.ts +++ b/packages/core/src/model-output/index.ts @@ -1,428 +1,428 @@ -import { - fileReviewModelOutputSchema, - parsedReviewCommentSchema, - toClaimType, - CLAIM_TYPE_CATEGORY, - type ClaimType, - type ParsedReviewComment, - reviewSeverities, -} from '@codraoss/schema'; -import { renderDiffSnippet } from '../prompts/verify'; -import { logger } from '../logger'; -import { z } from 'zod'; -import { findPositionForLine, getValidPositions, type DiffLine, type FileDiff } from '../diff'; -import { - buildAnchorHash, - buildFindingFingerprint, - buildFindingFingerprintV2, -} from '../fingerprint'; -import { - buildPresenceIndex, - checkAbsenceClaim, - isVersionClaimRefutedByPin, - looksLikeExternalVersionClaim, - refuteUndecidableClaim, -} from '../claim-checks'; -import { parseRawPayload } from './json'; -import { - type BinAmbiguityIndex, - type EvidenceIndex, - buildEvidenceIndex, - foldFirstEvidenceLine, - resolveEvidence, -} from './evidence'; - -export function samePath(a: string, b: string): boolean { - const strip = (p: string) => p.trim().replace(/^\.\//, '').replace(/^[ab]\//, '').replace(/^\//, ''); - return strip(a) === strip(b); -} - -export type BinAmbiguity = { - index: BinAmbiguityIndex; - filePath: string; - stats: { ambiguousAcrossBin: number }; -}; - -function withSuggestion(body: string, codeSuggestion?: string) { - if (!codeSuggestion) return body; - - const cleanSuggestion = codeSuggestion.replace(/```suggestion\n?|```/g, '').trim(); - - const cleanBody = body.split('```suggestion')[0].trim(); - - return `${cleanBody}\n\n\`\`\`suggestion\n${cleanSuggestion}\n\`\`\``; -} - -const CLAIM_TYPE_REPAIRS: ReadonlyArray<{ pattern: RegExp; claimType: ClaimType }> = [ - { pattern: /dependenc(?:y|ies)\s+array|exhaustive[- ]deps/i, claimType: 'react_hook_missing_deps' }, - { pattern: /redos|catastrophic backtrack|exponential backtrack/i, claimType: 'redos_regex' }, -]; - -function repairClaimType(claimType: ClaimType, title: string, body: string, onRepair: () => void): ClaimType { - if (claimType !== 'other') return claimType; - const text = `${title}\n${body}`; - - if (looksLikeExternalVersionClaim(title, body)) { - onRepair(); - return 'external_version_claim'; - } - - for (const { pattern, claimType: repaired } of CLAIM_TYPE_REPAIRS) { - if (pattern.test(text)) { - onRepair(); - return repaired; - } - } - return claimType; -} - -type RawFinding = z.infer['findings'][number]; - -type Withheld = { title: string; body: string; tag?: string }; - -function formatWithheld(w: Withheld): string { - return w.tag ? `- **[${w.tag}] ${w.title}:** ${w.body}` : `- **${w.title}:** ${w.body}`; -} - -function groundFindingInEvidence( - finding: RawFinding, - evidenceIndex: EvidenceIndex, - evidenceStats: { total: number; matched: number; unmatched: number; weak: number; absent: number; contextOnly: number }, - ambiguity?: BinAmbiguity, -): { diffLine: DiffLine } | { withheld: Withheld } { - const reportedLine = finding.code_location.line || finding.code_location.line_range?.start; - - evidenceStats.total += 1; - const evidence = resolveEvidence(finding.evidence, evidenceIndex, reportedLine); - if (evidence.status === 'matched') evidenceStats.matched += 1; - else if (evidence.status === 'unmatched') evidenceStats.unmatched += 1; - else if (evidence.status === 'weak') evidenceStats.weak += 1; - else if (evidence.status === 'absent') evidenceStats.absent += 1; - - if (evidence.status !== 'matched') { - return { withheld: { title: finding.title, body: finding.body, tag: `unverified:${evidence.status}` } }; - } - - // The evidence exists, but only on lines this pull request did not touch. That is a review of the - // repository, not of the change -- and it is the enforcement half of whole-file context: the prompt - // says the context block is not evidence, and this is what makes that true. Deletions count as - // touched: a finding about removed code is a finding about the change. - if (!evidence.touched) { - evidenceStats.contextOnly += 1; - return { withheld: { title: finding.title, body: finding.body, tag: 'unverified:context-only' } }; - } - - if (ambiguity) { - const firstLine = foldFirstEvidenceLine(finding.evidence); - const claimedPath = finding.code_location.absolute_file_path?.trim(); - const ambiguousAcrossBin = firstLine ? (ambiguity.index.get(firstLine) ?? 0) > 1 : false; - if (ambiguousAcrossBin && claimedPath && !samePath(claimedPath, ambiguity.filePath)) { - ambiguity.stats.ambiguousAcrossBin += 1; - return { - withheld: { - title: finding.title, - body: finding.body, - tag: 'unverified:ambiguous-across-bin', - }, - }; - } - } - - return { diffLine: evidence.line }; -} - -function anchorToDiffPosition( - file: FileDiff, - diffLine: DiffLine, - validPositions: Set, - finding: RawFinding, -): { line: number; position: number } | { withheld: Withheld } { - const line = diffLine.newLineNumber!; - const position = findPositionForLine(file, line); - - if (position === undefined || !validPositions.has(position)) { - return { withheld: { title: finding.title, body: finding.body } }; - } - - return { line, position }; -} - -function validateFindingShape(finding: RawFinding): { severity: typeof reviewSeverities[number]; title: string; body: string } { - const priorityMap: Record = { - 0: 'P0', - 1: 'P1', - 2: 'P2', - 3: 'P3', - 4: 'nit', - }; - const severity = finding.priority !== undefined - ? priorityMap[finding.priority] || 'P3' - : 'P3'; - - const cleanText = (text: string) => { - let current = text.trim(); - let prev = ''; - while (current !== prev) { - prev = current; - current = current - .replace(/^(?:[^\w\s]+|(?:QUALITY|SECURITY|BUG|PERFORMANCE|CORRECTNESS|P[0-3]|NIT)\b)+/giu, '') - .replace(/\n\s*/g, ' ') - .trim(); - } - return current; - }; - - const title = cleanText(finding.title); - let body = cleanText(finding.body); - - const bodyPrefix = cleanText(body.split('\n')[0]); - if (bodyPrefix.toLowerCase().startsWith(title.toLowerCase()) || title.toLowerCase().startsWith(bodyPrefix.toLowerCase())) { - body = cleanText(body.slice(body.split('\n')[0].length)); - } - - return { severity, title, body }; -} - -function applyClaimGate( - finding: RawFinding, - title: string, - body: string, - anchorContent: string, - deniedClaimTypes: Set, - claimTypeCounts: Record, - deniedClaimCounts: Record, -): { claimType: ClaimType } | { withheld: Withheld } { - const claimType = repairClaimType(toClaimType(finding.claim_type), title, body, () => { - claimTypeCounts.__repaired = (claimTypeCounts.__repaired ?? 0) + 1; - }); - - claimTypeCounts[claimType] = (claimTypeCounts[claimType] ?? 0) + 1; - - if (deniedClaimTypes.has(claimType)) { - deniedClaimCounts[claimType] = (deniedClaimCounts[claimType] ?? 0) + 1; - return { withheld: { title, body, tag: `claim-denied:${claimType}` } }; - } - - if (isVersionClaimRefutedByPin({ title, body, anchorContent })) { - deniedClaimCounts.version_claim_on_pinned_sha = (deniedClaimCounts.version_claim_on_pinned_sha ?? 0) + 1; - return { withheld: { title, body, tag: 'refuted:pinned-sha' } }; - } - - const undecidable = refuteUndecidableClaim({ title, body }); - if (undecidable) { - const key = `undecidable_${undecidable.replace('-', '_')}`; - deniedClaimCounts[key] = (deniedClaimCounts[key] ?? 0) + 1; - return { withheld: { title, body, tag: `refuted:${undecidable}` } }; - } - - return { claimType }; -} - -function buildParsedComment(params: { - file: FileDiff; - line: number; - position: number; - severity: typeof reviewSeverities[number]; - title: string; - body: string; - claimType: ClaimType; - anchorContent: string; - finding: RawFinding; -}): ParsedReviewComment { - const { file, line, position, severity, title, body, claimType, anchorContent, finding } = params; - - const confidenceScore = typeof finding.confidence_score === 'number' - ? finding.confidence_score - : 0; - - const codeSuggestion = typeof finding.code_suggestion === 'string' && finding.code_suggestion.trim() - ? finding.code_suggestion - : undefined; - - return parsedReviewCommentSchema.parse({ - path: file.path, - line, - position, - severity, - category: CLAIM_TYPE_CATEGORY[claimType], - claimType, - contextSnippet: renderDiffSnippet(file, line) || undefined, - title, - body: withSuggestion(body, codeSuggestion), - codeSuggestion, - confidenceScore, - evidence: typeof finding.evidence === 'string' && finding.evidence.trim() ? finding.evidence.trim() : undefined, - fingerprint: buildFindingFingerprint(file.path, title), - anchorHash: anchorContent ? buildAnchorHash(anchorContent) : undefined, - fingerprintV2: buildFindingFingerprintV2( - file.path, - claimType, - anchorContent ? buildAnchorHash(anchorContent) : undefined, - ) ?? undefined, - }); -} - -export type FileReviewPayload = z.infer; - -export type GroundingOptions = { - deniedClaimTypes?: readonly ClaimType[]; - ambiguity?: BinAmbiguity; - // The file's validated post-change content, when `full_file_context` fetched one. Only widens the - // absence check: an identifier the diff never showed is still present in the file, and a claim that - // it is missing is refutable by looking. Evidence stays diff-anchored regardless. - fileContent?: string | null; -}; - -export type GroundedFileReview = { - comments: ParsedReviewComment[]; - verdict: 'approve' | 'comment'; - fileSummary: string; - overallCorrectness?: string; - confidenceScore?: number; - evidenceStats: { total: number; matched: number; unmatched: number; weak: number; absent: number; contextOnly: number }; - claimTypeCounts: Record; - deniedClaimCounts: Record; - absenceCheckStats: { absenceShaped: number; identifierExtracted: number; refuted: number }; -}; - -export function groundParsedFindings( - parsed: FileReviewPayload, - file: FileDiff, - options?: GroundingOptions, -): GroundedFileReview { - const validPositions = getValidPositions(file); - const evidenceIndex = buildEvidenceIndex(file); - const evidenceStats = { total: 0, matched: 0, unmatched: 0, weak: 0, absent: 0, contextOnly: 0 }; - const claimTypeCounts: Record = {}; - const deniedClaimCounts: Record = {}; - const deniedClaimTypes = new Set(options?.deniedClaimTypes ?? []); - const presenceIndex = buildPresenceIndex(file, options?.fileContent); - const absenceCheckStats = { absenceShaped: 0, identifierExtracted: 0, refuted: 0 }; - const orphanedComments: string[] = []; - - const comments = (parsed.findings || []) - .map((finding): ParsedReviewComment | null => { - const grounded = groundFindingInEvidence(finding, evidenceIndex, evidenceStats, options?.ambiguity); - if ('withheld' in grounded) { - orphanedComments.push(formatWithheld(grounded.withheld)); - return null; - } - - const anchored = anchorToDiffPosition(file, grounded.diffLine, validPositions, finding); - if ('withheld' in anchored) { - orphanedComments.push(formatWithheld(anchored.withheld)); - return null; - } - - const { severity, title, body } = validateFindingShape(finding); - - const anchorContent = grounded.diffLine.content - ?? file.hunks.flatMap((h) => h.lines).find((l) => l.newLineNumber === anchored.line)?.content - ?? ''; - - const gated = applyClaimGate(finding, title, body, anchorContent, deniedClaimTypes, claimTypeCounts, deniedClaimCounts); - if ('withheld' in gated) { - orphanedComments.push(formatWithheld(gated.withheld)); - return null; - } - - // "X is missing / was removed / is never awaited", answered by looking. This verdict was computed - // and then thrown away for a long time -- the finding was posted regardless -- so the machinery - // was there and simply had no teeth. - const absence = checkAbsenceClaim({ title, body, anchorLine: anchored.line, index: presenceIndex }); - if (absence.status === 'refuted') { - absenceCheckStats.absenceShaped += 1; - absenceCheckStats.identifierExtracted += 1; - absenceCheckStats.refuted += 1; - - // Not at P0. The matcher is a literal search over stripped source: it cannot tell a call from a - // definition, or a live path from a dead one. Silencing a wrong nit is cheap; silencing a - // correct P0 is not, and `claim-checks.ts` is written to be sound in exactly this direction. - if (severity !== 'P0') { - logger.info(`Refuted an absence claim in ${file.path}`, { - identifier: absence.identifier, - foundAtLine: absence.line, - title, - }); - orphanedComments.push(formatWithheld({ - title, - body, - tag: `refuted:absence:${absence.identifier}`, - })); - return null; - } - } else if (absence.reason !== 'not_absence_shaped') { - absenceCheckStats.absenceShaped += 1; - if (absence.reason !== 'no_identifier' && absence.reason !== 'ambiguous_identifier') { - absenceCheckStats.identifierExtracted += 1; - } - } - - - try { - return buildParsedComment({ - file, - line: anchored.line, - position: anchored.position, - severity, - title, - body, - claimType: gated.claimType, - anchorContent, - finding, - }); - } catch (error) { - if (!(error instanceof z.ZodError)) throw error; - - orphanedComments.push(formatWithheld({ - title: finding.title, - body: finding.body, - tag: 'unverified:unassemblable', - })); - logger.warn('Dropped a finding that could not be assembled', { - path: file.path, - title: finding.title, - error: error.message, - }); - return null; - } - }) - .filter((comment): comment is ParsedReviewComment => Boolean(comment)); - - const verdict = parsed.overall_correctness.toLowerCase().includes('patch is correct') ? 'approve' : 'comment'; - let fileSummary = parsed.overall_explanation; - - if (orphanedComments.length > 0) { - fileSummary += `\n\n### Additional Comments (Off-diff)\n${orphanedComments.join('\n')}`; - } - - return { - comments, - verdict: comments.length > 0 ? 'comment' : verdict, - fileSummary, - overallCorrectness: parsed.overall_correctness, - confidenceScore: parsed.overall_confidence_score, - evidenceStats, - claimTypeCounts, - deniedClaimCounts, - absenceCheckStats, - }; -} - -export function parseFileReviewResponse( - raw: string, - file: FileDiff, - options?: GroundingOptions, -): GroundedFileReview { - return groundParsedFindings(parseRawPayload(raw), file, options); -} - - -export { dedupeFindings } from './dedupe'; -export { isNonAnswerReview } from './non-answer'; -export { - NON_ANSWER_MAX_RESPONSE_CHARS, - NON_ANSWER_MIN_DIFF_LINES, -} from '../constants'; -export { parseRawBatchPayload, type RawBatchPayload } from './json-batch'; -export { parseBatchReviewResponse, type BatchParseStats, type BatchReviewResult } from './batch'; +import { + fileReviewModelOutputSchema, + parsedReviewCommentSchema, + toClaimType, + CLAIM_TYPE_CATEGORY, + type ClaimType, + type ParsedReviewComment, + reviewSeverities, +} from '@codraoss/schema'; +import { renderDiffSnippet } from '../prompts/verify'; +import { logger } from '../logger'; +import { z } from 'zod'; +import { findPositionForLine, getValidPositions, type DiffLine, type FileDiff } from '../diff'; +import { + buildAnchorHash, + buildFindingFingerprint, + buildFindingFingerprintV2, +} from '../fingerprint'; +import { + buildPresenceIndex, + checkAbsenceClaim, + isVersionClaimRefutedByPin, + looksLikeExternalVersionClaim, + refuteUndecidableClaim, +} from '../claim-checks'; +import { parseRawPayload } from './json'; +import { + type BinAmbiguityIndex, + type EvidenceIndex, + buildEvidenceIndex, + foldFirstEvidenceLine, + resolveEvidence, +} from './evidence'; + +export function samePath(a: string, b: string): boolean { + const strip = (p: string) => p.trim().replace(/^\.\//, '').replace(/^[ab]\//, '').replace(/^\//, ''); + return strip(a) === strip(b); +} + +export type BinAmbiguity = { + index: BinAmbiguityIndex; + filePath: string; + stats: { ambiguousAcrossBin: number }; +}; + +function withSuggestion(body: string, codeSuggestion?: string) { + if (!codeSuggestion) return body; + + const cleanSuggestion = codeSuggestion.replace(/```suggestion\n?|```/g, '').trim(); + + const cleanBody = body.split('```suggestion')[0].trim(); + + return `${cleanBody}\n\n\`\`\`suggestion\n${cleanSuggestion}\n\`\`\``; +} + +const CLAIM_TYPE_REPAIRS: ReadonlyArray<{ pattern: RegExp; claimType: ClaimType }> = [ + { pattern: /dependenc(?:y|ies)\s+array|exhaustive[- ]deps/i, claimType: 'react_hook_missing_deps' }, + { pattern: /redos|catastrophic backtrack|exponential backtrack/i, claimType: 'redos_regex' }, +]; + +function repairClaimType(claimType: ClaimType, title: string, body: string, onRepair: () => void): ClaimType { + if (claimType !== 'other') return claimType; + const text = `${title}\n${body}`; + + if (looksLikeExternalVersionClaim(title, body)) { + onRepair(); + return 'external_version_claim'; + } + + for (const { pattern, claimType: repaired } of CLAIM_TYPE_REPAIRS) { + if (pattern.test(text)) { + onRepair(); + return repaired; + } + } + return claimType; +} + +type RawFinding = z.infer['findings'][number]; + +type Withheld = { title: string; body: string; tag?: string }; + +function formatWithheld(w: Withheld): string { + return w.tag ? `- **[${w.tag}] ${w.title}:** ${w.body}` : `- **${w.title}:** ${w.body}`; +} + +function groundFindingInEvidence( + finding: RawFinding, + evidenceIndex: EvidenceIndex, + evidenceStats: { total: number; matched: number; unmatched: number; weak: number; absent: number; contextOnly: number }, + ambiguity?: BinAmbiguity, +): { diffLine: DiffLine } | { withheld: Withheld } { + const reportedLine = finding.code_location.line || finding.code_location.line_range?.start; + + evidenceStats.total += 1; + const evidence = resolveEvidence(finding.evidence, evidenceIndex, reportedLine); + if (evidence.status === 'matched') evidenceStats.matched += 1; + else if (evidence.status === 'unmatched') evidenceStats.unmatched += 1; + else if (evidence.status === 'weak') evidenceStats.weak += 1; + else if (evidence.status === 'absent') evidenceStats.absent += 1; + + if (evidence.status !== 'matched') { + return { withheld: { title: finding.title, body: finding.body, tag: `unverified:${evidence.status}` } }; + } + + // The evidence exists, but only on lines this pull request did not touch. That is a review of the + // repository, not of the change -- and it is the enforcement half of whole-file context: the prompt + // says the context block is not evidence, and this is what makes that true. Deletions count as + // touched: a finding about removed code is a finding about the change. + if (!evidence.touched) { + evidenceStats.contextOnly += 1; + return { withheld: { title: finding.title, body: finding.body, tag: 'unverified:context-only' } }; + } + + if (ambiguity) { + const firstLine = foldFirstEvidenceLine(finding.evidence); + const claimedPath = finding.code_location.absolute_file_path?.trim(); + const ambiguousAcrossBin = firstLine ? (ambiguity.index.get(firstLine) ?? 0) > 1 : false; + if (ambiguousAcrossBin && claimedPath && !samePath(claimedPath, ambiguity.filePath)) { + ambiguity.stats.ambiguousAcrossBin += 1; + return { + withheld: { + title: finding.title, + body: finding.body, + tag: 'unverified:ambiguous-across-bin', + }, + }; + } + } + + return { diffLine: evidence.line }; +} + +function anchorToDiffPosition( + file: FileDiff, + diffLine: DiffLine, + validPositions: Set, + finding: RawFinding, +): { line: number; position: number } | { withheld: Withheld } { + const line = diffLine.newLineNumber!; + const position = findPositionForLine(file, line); + + if (position === undefined || !validPositions.has(position)) { + return { withheld: { title: finding.title, body: finding.body } }; + } + + return { line, position }; +} + +function validateFindingShape(finding: RawFinding): { severity: typeof reviewSeverities[number]; title: string; body: string } { + const priorityMap: Record = { + 0: 'P0', + 1: 'P1', + 2: 'P2', + 3: 'P3', + 4: 'nit', + }; + const severity = finding.priority !== undefined + ? priorityMap[finding.priority] || 'P3' + : 'P3'; + + const cleanText = (text: string) => { + let current = text.trim(); + let prev = ''; + while (current !== prev) { + prev = current; + current = current + .replace(/^(?:[^\w\s]+|(?:QUALITY|SECURITY|BUG|PERFORMANCE|CORRECTNESS|P[0-3]|NIT)\b)+/giu, '') + .replace(/\n\s*/g, ' ') + .trim(); + } + return current; + }; + + const title = cleanText(finding.title); + let body = cleanText(finding.body); + + const bodyPrefix = cleanText(body.split('\n')[0]); + if (bodyPrefix.toLowerCase().startsWith(title.toLowerCase()) || title.toLowerCase().startsWith(bodyPrefix.toLowerCase())) { + body = cleanText(body.slice(body.split('\n')[0].length)); + } + + return { severity, title, body }; +} + +function applyClaimGate( + finding: RawFinding, + title: string, + body: string, + anchorContent: string, + deniedClaimTypes: Set, + claimTypeCounts: Record, + deniedClaimCounts: Record, +): { claimType: ClaimType } | { withheld: Withheld } { + const claimType = repairClaimType(toClaimType(finding.claim_type), title, body, () => { + claimTypeCounts.__repaired = (claimTypeCounts.__repaired ?? 0) + 1; + }); + + claimTypeCounts[claimType] = (claimTypeCounts[claimType] ?? 0) + 1; + + if (deniedClaimTypes.has(claimType)) { + deniedClaimCounts[claimType] = (deniedClaimCounts[claimType] ?? 0) + 1; + return { withheld: { title, body, tag: `claim-denied:${claimType}` } }; + } + + if (isVersionClaimRefutedByPin({ title, body, anchorContent })) { + deniedClaimCounts.version_claim_on_pinned_sha = (deniedClaimCounts.version_claim_on_pinned_sha ?? 0) + 1; + return { withheld: { title, body, tag: 'refuted:pinned-sha' } }; + } + + const undecidable = refuteUndecidableClaim({ title, body }); + if (undecidable) { + const key = `undecidable_${undecidable.replace('-', '_')}`; + deniedClaimCounts[key] = (deniedClaimCounts[key] ?? 0) + 1; + return { withheld: { title, body, tag: `refuted:${undecidable}` } }; + } + + return { claimType }; +} + +function buildParsedComment(params: { + file: FileDiff; + line: number; + position: number; + severity: typeof reviewSeverities[number]; + title: string; + body: string; + claimType: ClaimType; + anchorContent: string; + finding: RawFinding; +}): ParsedReviewComment { + const { file, line, position, severity, title, body, claimType, anchorContent, finding } = params; + + const confidenceScore = typeof finding.confidence_score === 'number' + ? finding.confidence_score + : 0; + + const codeSuggestion = typeof finding.code_suggestion === 'string' && finding.code_suggestion.trim() + ? finding.code_suggestion + : undefined; + + return parsedReviewCommentSchema.parse({ + path: file.path, + line, + position, + severity, + category: CLAIM_TYPE_CATEGORY[claimType], + claimType, + contextSnippet: renderDiffSnippet(file, line) || undefined, + title, + body: withSuggestion(body, codeSuggestion), + codeSuggestion, + confidenceScore, + evidence: typeof finding.evidence === 'string' && finding.evidence.trim() ? finding.evidence.trim() : undefined, + fingerprint: buildFindingFingerprint(file.path, title), + anchorHash: anchorContent ? buildAnchorHash(anchorContent) : undefined, + fingerprintV2: buildFindingFingerprintV2( + file.path, + claimType, + anchorContent ? buildAnchorHash(anchorContent) : undefined, + ) ?? undefined, + }); +} + +export type FileReviewPayload = z.infer; + +export type GroundingOptions = { + deniedClaimTypes?: readonly ClaimType[]; + ambiguity?: BinAmbiguity; + // The file's validated post-change content, when `full_file_context` fetched one. Only widens the + // absence check: an identifier the diff never showed is still present in the file, and a claim that + // it is missing is refutable by looking. Evidence stays diff-anchored regardless. + fileContent?: string | null; +}; + +export type GroundedFileReview = { + comments: ParsedReviewComment[]; + verdict: 'approve' | 'comment'; + fileSummary: string; + overallCorrectness?: string; + confidenceScore?: number; + evidenceStats: { total: number; matched: number; unmatched: number; weak: number; absent: number; contextOnly: number }; + claimTypeCounts: Record; + deniedClaimCounts: Record; + absenceCheckStats: { absenceShaped: number; identifierExtracted: number; refuted: number }; +}; + +export function groundParsedFindings( + parsed: FileReviewPayload, + file: FileDiff, + options?: GroundingOptions, +): GroundedFileReview { + const validPositions = getValidPositions(file); + const evidenceIndex = buildEvidenceIndex(file); + const evidenceStats = { total: 0, matched: 0, unmatched: 0, weak: 0, absent: 0, contextOnly: 0 }; + const claimTypeCounts: Record = {}; + const deniedClaimCounts: Record = {}; + const deniedClaimTypes = new Set(options?.deniedClaimTypes ?? []); + const presenceIndex = buildPresenceIndex(file, options?.fileContent); + const absenceCheckStats = { absenceShaped: 0, identifierExtracted: 0, refuted: 0 }; + const orphanedComments: string[] = []; + + const comments = (parsed.findings || []) + .map((finding): ParsedReviewComment | null => { + const grounded = groundFindingInEvidence(finding, evidenceIndex, evidenceStats, options?.ambiguity); + if ('withheld' in grounded) { + orphanedComments.push(formatWithheld(grounded.withheld)); + return null; + } + + const anchored = anchorToDiffPosition(file, grounded.diffLine, validPositions, finding); + if ('withheld' in anchored) { + orphanedComments.push(formatWithheld(anchored.withheld)); + return null; + } + + const { severity, title, body } = validateFindingShape(finding); + + const anchorContent = grounded.diffLine.content + ?? file.hunks.flatMap((h) => h.lines).find((l) => l.newLineNumber === anchored.line)?.content + ?? ''; + + const gated = applyClaimGate(finding, title, body, anchorContent, deniedClaimTypes, claimTypeCounts, deniedClaimCounts); + if ('withheld' in gated) { + orphanedComments.push(formatWithheld(gated.withheld)); + return null; + } + + // "X is missing / was removed / is never awaited", answered by looking. This verdict was computed + // and then thrown away for a long time -- the finding was posted regardless -- so the machinery + // was there and simply had no teeth. + const absence = checkAbsenceClaim({ title, body, anchorLine: anchored.line, index: presenceIndex }); + if (absence.status === 'refuted') { + absenceCheckStats.absenceShaped += 1; + absenceCheckStats.identifierExtracted += 1; + absenceCheckStats.refuted += 1; + + // Not at P0. The matcher is a literal search over stripped source: it cannot tell a call from a + // definition, or a live path from a dead one. Silencing a wrong nit is cheap; silencing a + // correct P0 is not, and `claim-checks.ts` is written to be sound in exactly this direction. + if (severity !== 'P0') { + logger.info(`Refuted an absence claim in ${file.path}`, { + identifier: absence.identifier, + foundAtLine: absence.line, + title, + }); + orphanedComments.push(formatWithheld({ + title, + body, + tag: `refuted:absence:${absence.identifier}`, + })); + return null; + } + } else if (absence.reason !== 'not_absence_shaped') { + absenceCheckStats.absenceShaped += 1; + if (absence.reason !== 'no_identifier' && absence.reason !== 'ambiguous_identifier') { + absenceCheckStats.identifierExtracted += 1; + } + } + + + try { + return buildParsedComment({ + file, + line: anchored.line, + position: anchored.position, + severity, + title, + body, + claimType: gated.claimType, + anchorContent, + finding, + }); + } catch (error) { + if (!(error instanceof z.ZodError)) throw error; + + orphanedComments.push(formatWithheld({ + title: finding.title, + body: finding.body, + tag: 'unverified:unassemblable', + })); + logger.warn('Dropped a finding that could not be assembled', { + path: file.path, + title: finding.title, + error: error.message, + }); + return null; + } + }) + .filter((comment): comment is ParsedReviewComment => Boolean(comment)); + + const verdict = parsed.overall_correctness.toLowerCase().includes('patch is correct') ? 'approve' : 'comment'; + let fileSummary = parsed.overall_explanation; + + if (orphanedComments.length > 0) { + fileSummary += `\n\n### Additional Comments (Off-diff)\n${orphanedComments.join('\n')}`; + } + + return { + comments, + verdict: comments.length > 0 ? 'comment' : verdict, + fileSummary, + overallCorrectness: parsed.overall_correctness, + confidenceScore: parsed.overall_confidence_score, + evidenceStats, + claimTypeCounts, + deniedClaimCounts, + absenceCheckStats, + }; +} + +export function parseFileReviewResponse( + raw: string, + file: FileDiff, + options?: GroundingOptions, +): GroundedFileReview { + return groundParsedFindings(parseRawPayload(raw), file, options); +} + + +export { dedupeFindings } from './dedupe'; +export { isNonAnswerReview } from './non-answer'; +export { + NON_ANSWER_MAX_RESPONSE_CHARS, + NON_ANSWER_MIN_DIFF_LINES, +} from '../constants'; +export { parseRawBatchPayload, type RawBatchPayload } from './json-batch'; +export { parseBatchReviewResponse, type BatchParseStats, type BatchReviewResult } from './batch'; diff --git a/packages/core/src/model-output/json-batch.ts b/packages/core/src/model-output/json-batch.ts index 013b448d..144f2884 100644 --- a/packages/core/src/model-output/json-batch.ts +++ b/packages/core/src/model-output/json-batch.ts @@ -1,137 +1,137 @@ -import { batchReviewModelOutputSchema, fileReviewModelOutputSchema } from '@codraoss/schema'; -import { jsonrepair } from 'jsonrepair'; -import { z } from 'zod'; -import { logger } from '../logger'; -import { - extractJson, - hasReviewKeys, - normalizeFinding, - parseRawPayload, - preprocessJson, - stripNulls, - truncateJsonForLog, -} from './json'; - -function readEntryPath(entry: Record): string | null { - for (const key of ['absolute_file_path', 'path', 'file', 'file_path', 'filename'] as const) { - const value = entry[key]; - if (typeof value === 'string' && value.trim()) return value.trim(); - } - return null; -} - -function normalizeConfidence(value: unknown): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) return undefined; - if (value > 1) return Math.min(value / 10, 1); - if (value < 0) return 0; - return value; -} - -function normalizeBatchFileEntry(entry: unknown, fallbackPath?: string): unknown | null { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; - const e = entry as Record; - const path = readEntryPath(e) ?? fallbackPath; - if (!path) return null; - - if (!Array.isArray(e.findings)) return null; - - return { - absolute_file_path: path, - findings: e.findings.flatMap((finding) => { - const normalized = normalizeFinding(finding); - return normalized ? [normalized] : []; - }), - overall_correctness: typeof e.overall_correctness === 'string' && e.overall_correctness ? e.overall_correctness : undefined, - overall_explanation: typeof e.overall_explanation === 'string' && e.overall_explanation ? e.overall_explanation : undefined, - overall_confidence_score: normalizeConfidence(e.overall_confidence_score), - }; -} - -function collectBatchEntries(parsedJson: unknown): unknown[] | null { - const root = parsedJson && typeof parsedJson === 'object' ? (parsedJson as Record) : null; - const files = root?.files ?? (Array.isArray(parsedJson) ? parsedJson : undefined); - - if (Array.isArray(files)) { - return files.flatMap((entry) => { - const normalized = normalizeBatchFileEntry(entry); - return normalized ? [normalized] : []; - }); - } - if (files && typeof files === 'object') { - return Object.entries(files as Record).flatMap(([path, entry]) => { - const normalized = normalizeBatchFileEntry(entry, path); - return normalized ? [normalized] : []; - }); - } - return null; -} - -export type RawBatchPayload = - | { shape: 'nested'; data: z.infer } - | { shape: 'flat'; data: z.infer }; - -export function parseRawBatchPayload(raw: string): RawBatchPayload { - let extracted: string; - try { - extracted = extractJson(raw, 'files'); - if (!hasReviewKeys(extracted)) { - throw new Error('Model response did not contain review JSON keys.'); - } - } catch (e) { - logger.error('Failed to extract JSON from batched model response', { - rawLength: raw.length, - rawPrefix: raw.slice(0, 500), - error: e instanceof Error ? e.message : String(e), - }); - throw new Error('Could not find JSON root in batched model response.', { cause: e }); - } - - let preprocessed: string; - try { - preprocessed = preprocessJson(extracted); - } catch (e) { - logger.warn('JSON preprocessing partially failed, continuing...', { extracted, error: e }); - preprocessed = extracted; - } - - let repaired = preprocessed; - try { - repaired = jsonrepair(preprocessed); - } catch (e) { - logger.warn('jsonrepair failed to fix batched model output, using preprocessed text', { preprocessed: truncateJsonForLog(preprocessed), error: e }); - } - - let parsedJson: unknown; - try { - parsedJson = stripNulls(JSON.parse(repaired)); - } catch (e) { - logger.error('Critical JSON parse error after extraction and repair', { repaired: truncateJsonForLog(repaired), error: e }); - throw new Error(`Invalid JSON format: ${e instanceof Error ? e.message : 'Unknown error'}`, { cause: e }); - } - - const entries = collectBatchEntries(parsedJson); - const root = parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson) - ? (parsedJson as Record) - : {}; - - if (!entries?.length) { - if (Array.isArray(root.findings)) return { shape: 'flat', data: parseRawPayload(raw) }; - logger.error('Batched model response contained no recognisable file entries', { - parsedJson: truncateJsonForLog(JSON.stringify(parsedJson ?? null)), - }); - throw new Error('Batched response contained no recognisable file entries.'); - } - - try { - return { - shape: 'nested', - data: batchReviewModelOutputSchema.parse({ - files: entries, - overall_confidence_score: normalizeConfidence(root.overall_confidence_score) ?? 0.5, - }), - }; - } catch (e) { - logger.error('Batched model response failed schema validation', { parsedJson, error: e }); - throw new Error(`Batched response schema mismatch: ${e instanceof Error ? e.message : 'Check logs'}`, { cause: e }); - } -} +import { batchReviewModelOutputSchema, fileReviewModelOutputSchema } from '@codraoss/schema'; +import { jsonrepair } from 'jsonrepair'; +import { z } from 'zod'; +import { logger } from '../logger'; +import { + extractJson, + hasReviewKeys, + normalizeFinding, + parseRawPayload, + preprocessJson, + stripNulls, + truncateJsonForLog, +} from './json'; + +function readEntryPath(entry: Record): string | null { + for (const key of ['absolute_file_path', 'path', 'file', 'file_path', 'filename'] as const) { + const value = entry[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return null; +} + +function normalizeConfidence(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined; + if (value > 1) return Math.min(value / 10, 1); + if (value < 0) return 0; + return value; +} + +function normalizeBatchFileEntry(entry: unknown, fallbackPath?: string): unknown | null { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; + const e = entry as Record; + const path = readEntryPath(e) ?? fallbackPath; + if (!path) return null; + + if (!Array.isArray(e.findings)) return null; + + return { + absolute_file_path: path, + findings: e.findings.flatMap((finding) => { + const normalized = normalizeFinding(finding); + return normalized ? [normalized] : []; + }), + overall_correctness: typeof e.overall_correctness === 'string' && e.overall_correctness ? e.overall_correctness : undefined, + overall_explanation: typeof e.overall_explanation === 'string' && e.overall_explanation ? e.overall_explanation : undefined, + overall_confidence_score: normalizeConfidence(e.overall_confidence_score), + }; +} + +function collectBatchEntries(parsedJson: unknown): unknown[] | null { + const root = parsedJson && typeof parsedJson === 'object' ? (parsedJson as Record) : null; + const files = root?.files ?? (Array.isArray(parsedJson) ? parsedJson : undefined); + + if (Array.isArray(files)) { + return files.flatMap((entry) => { + const normalized = normalizeBatchFileEntry(entry); + return normalized ? [normalized] : []; + }); + } + if (files && typeof files === 'object') { + return Object.entries(files as Record).flatMap(([path, entry]) => { + const normalized = normalizeBatchFileEntry(entry, path); + return normalized ? [normalized] : []; + }); + } + return null; +} + +export type RawBatchPayload = + | { shape: 'nested'; data: z.infer } + | { shape: 'flat'; data: z.infer }; + +export function parseRawBatchPayload(raw: string): RawBatchPayload { + let extracted: string; + try { + extracted = extractJson(raw, 'files'); + if (!hasReviewKeys(extracted)) { + throw new Error('Model response did not contain review JSON keys.'); + } + } catch (e) { + logger.error('Failed to extract JSON from batched model response', { + rawLength: raw.length, + rawPrefix: raw.slice(0, 500), + error: e instanceof Error ? e.message : String(e), + }); + throw new Error('Could not find JSON root in batched model response.', { cause: e }); + } + + let preprocessed: string; + try { + preprocessed = preprocessJson(extracted); + } catch (e) { + logger.warn('JSON preprocessing partially failed, continuing...', { extracted, error: e }); + preprocessed = extracted; + } + + let repaired = preprocessed; + try { + repaired = jsonrepair(preprocessed); + } catch (e) { + logger.warn('jsonrepair failed to fix batched model output, using preprocessed text', { preprocessed: truncateJsonForLog(preprocessed), error: e }); + } + + let parsedJson: unknown; + try { + parsedJson = stripNulls(JSON.parse(repaired)); + } catch (e) { + logger.error('Critical JSON parse error after extraction and repair', { repaired: truncateJsonForLog(repaired), error: e }); + throw new Error(`Invalid JSON format: ${e instanceof Error ? e.message : 'Unknown error'}`, { cause: e }); + } + + const entries = collectBatchEntries(parsedJson); + const root = parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson) + ? (parsedJson as Record) + : {}; + + if (!entries?.length) { + if (Array.isArray(root.findings)) return { shape: 'flat', data: parseRawPayload(raw) }; + logger.error('Batched model response contained no recognisable file entries', { + parsedJson: truncateJsonForLog(JSON.stringify(parsedJson ?? null)), + }); + throw new Error('Batched response contained no recognisable file entries.'); + } + + try { + return { + shape: 'nested', + data: batchReviewModelOutputSchema.parse({ + files: entries, + overall_confidence_score: normalizeConfidence(root.overall_confidence_score) ?? 0.5, + }), + }; + } catch (e) { + logger.error('Batched model response failed schema validation', { parsedJson, error: e }); + throw new Error(`Batched response schema mismatch: ${e instanceof Error ? e.message : 'Check logs'}`, { cause: e }); + } +} diff --git a/packages/core/src/model-output/json.ts b/packages/core/src/model-output/json.ts index bec5d0e7..34359a41 100644 --- a/packages/core/src/model-output/json.ts +++ b/packages/core/src/model-output/json.ts @@ -1,375 +1,375 @@ -import { fileReviewModelOutputSchema } from '@codraoss/schema'; -import { jsonrepair } from 'jsonrepair'; -import { z } from 'zod'; -import { logger } from '../logger'; - -import { MAX_LOGGED_JSON_CHARS } from '../constants'; - -export function truncateJsonForLog(value: string) { - if (value.length <= MAX_LOGGED_JSON_CHARS) return value; - return `${value.slice(0, MAX_LOGGED_JSON_CHARS)}... [truncated ${value.length - MAX_LOGGED_JSON_CHARS} chars]`; -} - -export function hasReviewKeys(input: string) { - return /"(files|findings|overall_explanation|overall_correctness|overall_confidence_score|summary)"\s*:/.test(input); -} - -function scanBalanced(raw: string, startIdx: number, open: string, close: string): string | null { - let stack = 0; - let inString = false; - let escape = false; - - for (let i = startIdx; i < raw.length; i++) { - const char = raw[i]; - - if (escape) { - escape = false; - continue; - } - if (char === '\\') { - escape = true; - continue; - } - if (char === '"') { - inString = !inString; - continue; - } - if (inString) continue; - - if (char === open) stack++; - else if (char === close) { - stack--; - if (stack === 0) return raw.slice(startIdx, i + 1); - } - } - - return null; -} - -export function extractJson(raw: string, anchorKey: 'findings' | 'files' = 'findings') { - const jsonBlocks = Array.from(raw.matchAll(/```json([\s\S]*?)```/gi)); - if (jsonBlocks.length > 0) { - return jsonBlocks[jsonBlocks.length - 1][1].trim(); - } - - const genericBlocks = Array.from(raw.matchAll(/```(?:[\w+-]+)?([\s\S]*?)```/gi)); - if (genericBlocks.length > 0) { - const candidates = genericBlocks.filter(b => b[1].includes('{') && b[1].includes('}') && hasReviewKeys(b[1])); - if (candidates.length > 0) { - const content = candidates[candidates.length - 1][1].trim(); - const start = content.indexOf('{'); - const end = content.lastIndexOf('}'); - if (start !== -1 && end !== -1 && end > start) { - return content.slice(start, end + 1); - } - return content; - } - } - - const arrayStart = raw.indexOf('['); - if (arrayStart !== -1 && raw.slice(0, arrayStart).trim() === '') { - const matched = scanBalanced(raw, arrayStart, '[', ']'); - if (matched && hasReviewKeys(matched)) return matched; - } - - const anchorIdx = anchorKey === 'files' ? raw.indexOf('"files"') : -1; - const findingsIdx = anchorIdx !== -1 ? anchorIdx : raw.indexOf('"findings"'); - const summaryIdx = raw.indexOf('"summary"'); - const targetIdx = findingsIdx !== -1 ? findingsIdx : (summaryIdx !== -1 ? summaryIdx : -1); - - let firstBrace = -1; - if (targetIdx !== -1) { - firstBrace = raw.lastIndexOf('{', targetIdx); - } - - if (firstBrace === -1) { - const allBraces = Array.from(raw.matchAll(/\{/g)); - let bestIdx = -1; - let bestScore = -1; - - for (const match of allBraces) { - const idx = match.index!; - const excerpt = raw.slice(idx, idx + 200); - let score = 0; - - if (excerpt.includes('"files"')) score += 100; - if (excerpt.includes('"findings"')) score += 100; - if (excerpt.includes('"summary"')) score += 50; - if (excerpt.includes('"overall_explanation"')) score += 50; - - if (excerpt.includes('" : ') || excerpt.includes('":')) score += 10; - if (excerpt.includes('"[')) score += 5; - - if (excerpt.includes(': number;') || excerpt.includes(': string;')) score -= 80; - if (excerpt.includes('export ') || excerpt.includes('function ')) score -= 80; - if (excerpt.includes('interface ') || excerpt.includes('type ')) score -= 80; - if (excerpt.includes(' + ')) score -= 20; // Looks like a diff hunk - - if (score > bestScore) { - bestScore = score; - bestIdx = idx; - } - } - - if (bestIdx !== -1 && bestScore > 0) { - firstBrace = bestIdx; - } - } - - if (firstBrace === -1) { - const start = raw.indexOf('{'); - if (start !== -1) { - const excerpt = raw.slice(start, start + 50); - if (excerpt.includes('"') && excerpt.includes(':')) { - firstBrace = start; - } - } - } - - if (firstBrace !== -1) { - let stack = 0; - let inString = false; - let escape = false; - - for (let i = firstBrace; i < raw.length; i++) { - const char = raw[i]; - - if (escape) { - escape = false; - continue; - } - - if (char === '\\') { - escape = true; - continue; - } - - if (char === '"') { - inString = !inString; - continue; - } - - if (!inString) { - if (char === '{') stack++; - else if (char === '}') { - stack--; - if (stack === 0) { - return raw.slice(firstBrace, i + 1); - } - } - } - } - - const partial = raw.slice(firstBrace).trim(); - let closing = ''; - if (inString) closing += '"'; - closing += '}'.repeat(Math.max(1, stack)); - return `${partial}${closing}`; - } - - return raw.trim(); -} - -export function preprocessJson(json: string): string { - let result = ''; - let inString = false; - let escape = false; - - for (let i = 0; i < json.length; i++) { - const char = json[i]; - - if (escape) { - result += char; - escape = false; - continue; - } - - if (char === '\\') { - result += char; - escape = true; - continue; - } - - if (char === '"') { - inString = !inString; - result += char; - continue; - } - - if (inString) { - if (char === '\n') { - result += '\\n'; - } else if (char === '\r') { - result += '\\r'; - } else { - result += char; - } - } else { - result += char; - } - } - - return result; -} - -/** - * Deletes every `null`-valued key, recursively, before Zod sees the payload. - * - * The model output schemas mark optional fields `.optional()`, which accepts an ABSENT key and rejects - * an explicit `null` -- and these models routinely emit `"code_suggestion": null` for a finding that - * carries no suggestion. On the batched path that single null failed - * `batchReviewModelOutputSchema.parse`, so `parseBatchReviewResponse` threw and the response for EVERY - * file in the bin was discarded, then reported as an unreadable answer and failed over to the next - * model. Measured on this repository's own review: 37 of 88 rejected payloads were otherwise complete - * and readable. - * - * Stripping rather than widening each field is deliberate: absent and null mean the same thing to every - * one of these schemas, one pass covers the fields nobody has thought of yet, and no downstream type - * has to learn about `null`. - */ -export function stripNulls(value: T): T { - if (Array.isArray(value)) return value.map(stripNulls) as unknown as T; - if (value === null || typeof value !== 'object') return value; - - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - if (entry === null) continue; - out[key] = stripNulls(entry); - } - return out as T; -} - -function isPlaceholderString(value: unknown) { - return typeof value === 'string' && /^<[^>]+>$/.test(value.trim()); -} - -function coerceReviewNumber(value: unknown) { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string' && !isPlaceholderString(value)) { - const parsed = Number(value); - if (Number.isFinite(parsed)) return parsed; - } - return undefined; -} - -export function normalizeFinding(finding: unknown) { - if (!finding || typeof finding !== 'object') return null; - const f = finding as Record; - if (isPlaceholderString(f.title) || isPlaceholderString(f.body) || isPlaceholderString(f.evidence)) return null; - - const location = f.code_location && typeof f.code_location === 'object' ? (f.code_location as Record) : {}; - const line = coerceReviewNumber(location.line); - const start = coerceReviewNumber(location.line_range && typeof location.line_range === 'object' ? (location.line_range as Record).start : undefined); - const end = coerceReviewNumber(location.line_range && typeof location.line_range === 'object' ? (location.line_range as Record).end : undefined); - const priority = coerceReviewNumber(f.priority); - - const codeLocation: Record = { - absolute_file_path: location.absolute_file_path || f.path || '', - }; - if (line !== undefined) { - codeLocation.line = Math.trunc(line as number); - } - if (start !== undefined || end !== undefined) { - codeLocation.line_range = { - start: Math.trunc((start as number) ?? (end as number)!), - end: Math.trunc((end as number) ?? (start as number)!), - }; - } - - return { - ...f, - title: (f.title ? String(f.title) : '').trim().slice(0, 100).replace(/[\uD800-\uDBFF]$/, '') || 'Code finding', - priority: priority === undefined ? undefined : Math.max(0, Math.min(4, Math.trunc(priority as number))), - code_location: codeLocation, - confidence_score: typeof f.confidence_score === 'number' - ? Math.max(0, Math.min(1, f.confidence_score > 1 ? f.confidence_score / 10 : f.confidence_score)) - : undefined, - }; -} - -export function parseRawPayload(raw: string): z.infer { - let extracted: string; - try { - extracted = extractJson(raw); - if (!hasReviewKeys(extracted)) { - throw new Error('Model response did not contain review JSON keys.'); - } - } catch (e) { - logger.error('Failed to extract JSON from model response', { - rawLength: raw.length, - rawPrefix: raw.slice(0, 500), - error: e instanceof Error ? e.message : String(e), - }); - throw new Error('Could not find JSON root in model response.', { cause: e }); - } - - let preprocessed: string; - try { - preprocessed = preprocessJson(extracted); - } catch (e) { - logger.warn('JSON preprocessing partially failed, continuing...', { extracted, error: e }); - preprocessed = extracted; - } - - let repaired = preprocessed; - try { - repaired = jsonrepair(preprocessed); - } catch (e) { - logger.warn('jsonrepair failed to fix model output, using preprocessed text', { preprocessed: truncateJsonForLog(preprocessed), error: e }); - } - - let parsedJson: unknown; - try { - parsedJson = stripNulls(JSON.parse(repaired)); - } catch (e) { - logger.error('Critical JSON parse error after extraction and repair', { repaired: truncateJsonForLog(repaired), error: e }); - throw new Error(`Invalid JSON format: ${e instanceof Error ? e.message : 'Unknown error'}`, { cause: e }); - } - - try { - const findReviewObject = (arr: unknown[]): unknown | null => { - const best = arr.find(i => i && typeof i === 'object' && Array.isArray((i as Record).findings) && typeof (i as Record).summary === 'string'); - if (best) return best; - - const good = arr.find(i => i && typeof i === 'object' && Array.isArray((i as Record).findings)); - if (good) return good; - - return arr.find(i => - i && typeof i === 'object' && - ('findings' in i || 'overall_explanation' in i || 'summary' in i || 'overall_correctness' in i) - ); - }; - - let data = Array.isArray(parsedJson) ? (findReviewObject(parsedJson) || parsedJson[0] || {}) : parsedJson; - - if (data && typeof data === 'object') { - const obj = data as Record; - if (!obj.findings) obj.findings = []; - if (!obj.overall_explanation) obj.overall_explanation = 'No explanation provided.'; - if (!obj.overall_correctness) obj.overall_correctness = 'Uncertain'; - - if (typeof obj.overall_confidence_score === 'number') { - if (obj.overall_confidence_score > 1) { - obj.overall_confidence_score = Math.min(obj.overall_confidence_score / 10, 1); - } else if (obj.overall_confidence_score < 0) { - obj.overall_confidence_score = 0; - } - } else { - obj.overall_confidence_score = 0.5; - } - - if (Array.isArray(obj.findings)) { - obj.findings = obj.findings.flatMap((finding: unknown) => { - const normalized = normalizeFinding(finding); - return normalized ? [normalized] : []; - }); - } - data = obj; - } - - return fileReviewModelOutputSchema.parse(data); - } catch (e) { - logger.error('Model response failed schema validation', { parsedJson, error: e }); - throw new Error(`Response schema mismatch: ${e instanceof Error ? e.message : 'Check logs'}`, { cause: e }); - } -} +import { fileReviewModelOutputSchema } from '@codraoss/schema'; +import { jsonrepair } from 'jsonrepair'; +import { z } from 'zod'; +import { logger } from '../logger'; + +import { MAX_LOGGED_JSON_CHARS } from '../constants'; + +export function truncateJsonForLog(value: string) { + if (value.length <= MAX_LOGGED_JSON_CHARS) return value; + return `${value.slice(0, MAX_LOGGED_JSON_CHARS)}... [truncated ${value.length - MAX_LOGGED_JSON_CHARS} chars]`; +} + +export function hasReviewKeys(input: string) { + return /"(files|findings|overall_explanation|overall_correctness|overall_confidence_score|summary)"\s*:/.test(input); +} + +function scanBalanced(raw: string, startIdx: number, open: string, close: string): string | null { + let stack = 0; + let inString = false; + let escape = false; + + for (let i = startIdx; i < raw.length; i++) { + const char = raw[i]; + + if (escape) { + escape = false; + continue; + } + if (char === '\\') { + escape = true; + continue; + } + if (char === '"') { + inString = !inString; + continue; + } + if (inString) continue; + + if (char === open) stack++; + else if (char === close) { + stack--; + if (stack === 0) return raw.slice(startIdx, i + 1); + } + } + + return null; +} + +export function extractJson(raw: string, anchorKey: 'findings' | 'files' = 'findings') { + const jsonBlocks = Array.from(raw.matchAll(/```json([\s\S]*?)```/gi)); + if (jsonBlocks.length > 0) { + return jsonBlocks[jsonBlocks.length - 1][1].trim(); + } + + const genericBlocks = Array.from(raw.matchAll(/```(?:[\w+-]+)?([\s\S]*?)```/gi)); + if (genericBlocks.length > 0) { + const candidates = genericBlocks.filter(b => b[1].includes('{') && b[1].includes('}') && hasReviewKeys(b[1])); + if (candidates.length > 0) { + const content = candidates[candidates.length - 1][1].trim(); + const start = content.indexOf('{'); + const end = content.lastIndexOf('}'); + if (start !== -1 && end !== -1 && end > start) { + return content.slice(start, end + 1); + } + return content; + } + } + + const arrayStart = raw.indexOf('['); + if (arrayStart !== -1 && raw.slice(0, arrayStart).trim() === '') { + const matched = scanBalanced(raw, arrayStart, '[', ']'); + if (matched && hasReviewKeys(matched)) return matched; + } + + const anchorIdx = anchorKey === 'files' ? raw.indexOf('"files"') : -1; + const findingsIdx = anchorIdx !== -1 ? anchorIdx : raw.indexOf('"findings"'); + const summaryIdx = raw.indexOf('"summary"'); + const targetIdx = findingsIdx !== -1 ? findingsIdx : (summaryIdx !== -1 ? summaryIdx : -1); + + let firstBrace = -1; + if (targetIdx !== -1) { + firstBrace = raw.lastIndexOf('{', targetIdx); + } + + if (firstBrace === -1) { + const allBraces = Array.from(raw.matchAll(/\{/g)); + let bestIdx = -1; + let bestScore = -1; + + for (const match of allBraces) { + const idx = match.index!; + const excerpt = raw.slice(idx, idx + 200); + let score = 0; + + if (excerpt.includes('"files"')) score += 100; + if (excerpt.includes('"findings"')) score += 100; + if (excerpt.includes('"summary"')) score += 50; + if (excerpt.includes('"overall_explanation"')) score += 50; + + if (excerpt.includes('" : ') || excerpt.includes('":')) score += 10; + if (excerpt.includes('"[')) score += 5; + + if (excerpt.includes(': number;') || excerpt.includes(': string;')) score -= 80; + if (excerpt.includes('export ') || excerpt.includes('function ')) score -= 80; + if (excerpt.includes('interface ') || excerpt.includes('type ')) score -= 80; + if (excerpt.includes(' + ')) score -= 20; // Looks like a diff hunk + + if (score > bestScore) { + bestScore = score; + bestIdx = idx; + } + } + + if (bestIdx !== -1 && bestScore > 0) { + firstBrace = bestIdx; + } + } + + if (firstBrace === -1) { + const start = raw.indexOf('{'); + if (start !== -1) { + const excerpt = raw.slice(start, start + 50); + if (excerpt.includes('"') && excerpt.includes(':')) { + firstBrace = start; + } + } + } + + if (firstBrace !== -1) { + let stack = 0; + let inString = false; + let escape = false; + + for (let i = firstBrace; i < raw.length; i++) { + const char = raw[i]; + + if (escape) { + escape = false; + continue; + } + + if (char === '\\') { + escape = true; + continue; + } + + if (char === '"') { + inString = !inString; + continue; + } + + if (!inString) { + if (char === '{') stack++; + else if (char === '}') { + stack--; + if (stack === 0) { + return raw.slice(firstBrace, i + 1); + } + } + } + } + + const partial = raw.slice(firstBrace).trim(); + let closing = ''; + if (inString) closing += '"'; + closing += '}'.repeat(Math.max(1, stack)); + return `${partial}${closing}`; + } + + return raw.trim(); +} + +export function preprocessJson(json: string): string { + let result = ''; + let inString = false; + let escape = false; + + for (let i = 0; i < json.length; i++) { + const char = json[i]; + + if (escape) { + result += char; + escape = false; + continue; + } + + if (char === '\\') { + result += char; + escape = true; + continue; + } + + if (char === '"') { + inString = !inString; + result += char; + continue; + } + + if (inString) { + if (char === '\n') { + result += '\\n'; + } else if (char === '\r') { + result += '\\r'; + } else { + result += char; + } + } else { + result += char; + } + } + + return result; +} + +/** + * Deletes every `null`-valued key, recursively, before Zod sees the payload. + * + * The model output schemas mark optional fields `.optional()`, which accepts an ABSENT key and rejects + * an explicit `null` -- and these models routinely emit `"code_suggestion": null` for a finding that + * carries no suggestion. On the batched path that single null failed + * `batchReviewModelOutputSchema.parse`, so `parseBatchReviewResponse` threw and the response for EVERY + * file in the bin was discarded, then reported as an unreadable answer and failed over to the next + * model. Measured on this repository's own review: 37 of 88 rejected payloads were otherwise complete + * and readable. + * + * Stripping rather than widening each field is deliberate: absent and null mean the same thing to every + * one of these schemas, one pass covers the fields nobody has thought of yet, and no downstream type + * has to learn about `null`. + */ +export function stripNulls(value: T): T { + if (Array.isArray(value)) return value.map(stripNulls) as unknown as T; + if (value === null || typeof value !== 'object') return value; + + const out: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + if (entry === null) continue; + out[key] = stripNulls(entry); + } + return out as T; +} + +function isPlaceholderString(value: unknown) { + return typeof value === 'string' && /^<[^>]+>$/.test(value.trim()); +} + +function coerceReviewNumber(value: unknown) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && !isPlaceholderString(value)) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +export function normalizeFinding(finding: unknown) { + if (!finding || typeof finding !== 'object') return null; + const f = finding as Record; + if (isPlaceholderString(f.title) || isPlaceholderString(f.body) || isPlaceholderString(f.evidence)) return null; + + const location = f.code_location && typeof f.code_location === 'object' ? (f.code_location as Record) : {}; + const line = coerceReviewNumber(location.line); + const start = coerceReviewNumber(location.line_range && typeof location.line_range === 'object' ? (location.line_range as Record).start : undefined); + const end = coerceReviewNumber(location.line_range && typeof location.line_range === 'object' ? (location.line_range as Record).end : undefined); + const priority = coerceReviewNumber(f.priority); + + const codeLocation: Record = { + absolute_file_path: location.absolute_file_path || f.path || '', + }; + if (line !== undefined) { + codeLocation.line = Math.trunc(line as number); + } + if (start !== undefined || end !== undefined) { + codeLocation.line_range = { + start: Math.trunc((start as number) ?? (end as number)!), + end: Math.trunc((end as number) ?? (start as number)!), + }; + } + + return { + ...f, + title: (f.title ? String(f.title) : '').trim().slice(0, 100).replace(/[\uD800-\uDBFF]$/, '') || 'Code finding', + priority: priority === undefined ? undefined : Math.max(0, Math.min(4, Math.trunc(priority as number))), + code_location: codeLocation, + confidence_score: typeof f.confidence_score === 'number' + ? Math.max(0, Math.min(1, f.confidence_score > 1 ? f.confidence_score / 10 : f.confidence_score)) + : undefined, + }; +} + +export function parseRawPayload(raw: string): z.infer { + let extracted: string; + try { + extracted = extractJson(raw); + if (!hasReviewKeys(extracted)) { + throw new Error('Model response did not contain review JSON keys.'); + } + } catch (e) { + logger.error('Failed to extract JSON from model response', { + rawLength: raw.length, + rawPrefix: raw.slice(0, 500), + error: e instanceof Error ? e.message : String(e), + }); + throw new Error('Could not find JSON root in model response.', { cause: e }); + } + + let preprocessed: string; + try { + preprocessed = preprocessJson(extracted); + } catch (e) { + logger.warn('JSON preprocessing partially failed, continuing...', { extracted, error: e }); + preprocessed = extracted; + } + + let repaired = preprocessed; + try { + repaired = jsonrepair(preprocessed); + } catch (e) { + logger.warn('jsonrepair failed to fix model output, using preprocessed text', { preprocessed: truncateJsonForLog(preprocessed), error: e }); + } + + let parsedJson: unknown; + try { + parsedJson = stripNulls(JSON.parse(repaired)); + } catch (e) { + logger.error('Critical JSON parse error after extraction and repair', { repaired: truncateJsonForLog(repaired), error: e }); + throw new Error(`Invalid JSON format: ${e instanceof Error ? e.message : 'Unknown error'}`, { cause: e }); + } + + try { + const findReviewObject = (arr: unknown[]): unknown | null => { + const best = arr.find(i => i && typeof i === 'object' && Array.isArray((i as Record).findings) && typeof (i as Record).summary === 'string'); + if (best) return best; + + const good = arr.find(i => i && typeof i === 'object' && Array.isArray((i as Record).findings)); + if (good) return good; + + return arr.find(i => + i && typeof i === 'object' && + ('findings' in i || 'overall_explanation' in i || 'summary' in i || 'overall_correctness' in i) + ); + }; + + let data = Array.isArray(parsedJson) ? (findReviewObject(parsedJson) || parsedJson[0] || {}) : parsedJson; + + if (data && typeof data === 'object') { + const obj = data as Record; + if (!obj.findings) obj.findings = []; + if (!obj.overall_explanation) obj.overall_explanation = 'No explanation provided.'; + if (!obj.overall_correctness) obj.overall_correctness = 'Uncertain'; + + if (typeof obj.overall_confidence_score === 'number') { + if (obj.overall_confidence_score > 1) { + obj.overall_confidence_score = Math.min(obj.overall_confidence_score / 10, 1); + } else if (obj.overall_confidence_score < 0) { + obj.overall_confidence_score = 0; + } + } else { + obj.overall_confidence_score = 0.5; + } + + if (Array.isArray(obj.findings)) { + obj.findings = obj.findings.flatMap((finding: unknown) => { + const normalized = normalizeFinding(finding); + return normalized ? [normalized] : []; + }); + } + data = obj; + } + + return fileReviewModelOutputSchema.parse(data); + } catch (e) { + logger.error('Model response failed schema validation', { parsedJson, error: e }); + throw new Error(`Response schema mismatch: ${e instanceof Error ? e.message : 'Check logs'}`, { cause: e }); + } +} diff --git a/packages/core/src/model-output/non-answer.ts b/packages/core/src/model-output/non-answer.ts index 82319a74..f1bcc5e2 100644 --- a/packages/core/src/model-output/non-answer.ts +++ b/packages/core/src/model-output/non-answer.ts @@ -1,23 +1,23 @@ - -import type { FileDiff } from '../diff'; - -import { - NON_ANSWER_MAX_RESPONSE_CHARS, - NON_ANSWER_MIN_DIFF_LINES, -} from '../constants'; - -/** - * True when a review response is a non-answer: a substantive diff dismissed in a sentence with no - * findings. Deliberately conservative -- it must never fire on a small diff, and never when the model - * actually engaged, because the cost of a false positive is an escalation that spends real quota. - */ -export function isNonAnswerReview(input: { - rawText: string; - file: Pick; - findingCount: number; - minDiffLines?: number; -}): boolean { - if (input.findingCount > 0) return false; - if (input.file.lineCount < (input.minDiffLines ?? NON_ANSWER_MIN_DIFF_LINES)) return false; - return input.rawText.trim().length < NON_ANSWER_MAX_RESPONSE_CHARS; -} + +import type { FileDiff } from '../diff'; + +import { + NON_ANSWER_MAX_RESPONSE_CHARS, + NON_ANSWER_MIN_DIFF_LINES, +} from '../constants'; + +/** + * True when a review response is a non-answer: a substantive diff dismissed in a sentence with no + * findings. Deliberately conservative -- it must never fire on a small diff, and never when the model + * actually engaged, because the cost of a false positive is an escalation that spends real quota. + */ +export function isNonAnswerReview(input: { + rawText: string; + file: Pick; + findingCount: number; + minDiffLines?: number; +}): boolean { + if (input.findingCount > 0) return false; + if (input.file.lineCount < (input.minDiffLines ?? NON_ANSWER_MIN_DIFF_LINES)) return false; + return input.rawText.trim().length < NON_ANSWER_MAX_RESPONSE_CHARS; +} diff --git a/packages/core/src/prompts/file-review.ts b/packages/core/src/prompts/file-review.ts index 17211b60..25cb3d74 100644 --- a/packages/core/src/prompts/file-review.ts +++ b/packages/core/src/prompts/file-review.ts @@ -1,403 +1,403 @@ -import { claimTypes, type RepoConfig } from '@codraoss/schema'; -import type { FileDiff } from '../diff'; -import type { ModelResponseSchema } from '../ports/model'; -import { getLanguageForFile } from './languages'; -import { - INTENT_CHECK_INSTRUCTION, - renderFileContext, - renderIntentBlock, -} from './review-context'; -import { - EXEMPLAR_BLOCK_CHARS, -} from '../constants'; - -export { changelogExcerptFromDiff, wantsFileContext } from './review-context'; - -// Pre-review_breadth fallback: generator was allowed ~2x the posted cap. -export function generatorFindingCap(maxComments: number): number { - return Math.max(1, maxComments * 2); -} - -/** Internal candidate cap upstream of posting; falls back for job snapshots queued before this field existed. */ -export function reviewBreadth(config: Pick & { review_breadth?: number }): number { - return config.review_breadth ?? generatorFindingCap(config.max_comments); -} - -function findingItemSchema() { - return { - type: 'object', - additionalProperties: false, - required: ['evidence', 'code_location', 'claim_type', 'title', 'body', 'priority'], - properties: { - evidence: { type: 'string' }, - code_location: { - type: 'object', - additionalProperties: false, - properties: { - absolute_file_path: { type: 'string' }, - line: { type: 'integer', minimum: 1 }, - line_range: { - type: 'object', - additionalProperties: false, - required: ['start', 'end'], - properties: { - start: { type: 'integer', minimum: 1 }, - end: { type: 'integer', minimum: 1 }, - }, - }, - }, - anyOf: [ - { required: ['line'] }, - { required: ['line_range'] }, - ], - }, - claim_type: { type: 'string', enum: [...claimTypes] }, - title: { type: 'string', maxLength: 100 }, - body: { type: 'string' }, - priority: { type: 'integer', minimum: 0, maximum: 4 }, - code_suggestion: { type: 'string' }, - }, - }; -} - -export function buildReviewResponseSchema(findingCap: number): ModelResponseSchema { - return { - name: 'codra_file_review', - schema: { - type: 'object', - additionalProperties: false, - required: ['findings', 'overall_explanation', 'overall_correctness', 'overall_confidence_score'], - properties: { - findings: { - type: 'array', - maxItems: Math.max(1, findingCap), - items: findingItemSchema(), - }, - overall_explanation: { type: 'string' }, - overall_correctness: { type: 'string', enum: ['patch is correct', 'patch is incorrect'] }, - overall_confidence_score: { type: 'number', minimum: 0, maximum: 1 }, - }, - }, - }; -} - -export function buildBatchReviewResponseSchema(findingCap: number, fileCount: number): ModelResponseSchema { - return { - name: 'codra_batch_review', - schema: { - type: 'object', - additionalProperties: false, - required: ['files', 'overall_confidence_score'], - properties: { - files: { - type: 'array', - maxItems: fileCount, - items: { - type: 'object', - additionalProperties: false, - required: ['absolute_file_path', 'findings', 'overall_explanation', 'overall_correctness'], - properties: { - absolute_file_path: { type: 'string' }, - findings: { type: 'array', items: findingItemSchema() }, - overall_explanation: { type: 'string' }, - overall_correctness: { type: 'string', enum: ['patch is correct', 'patch is incorrect'] }, - }, - }, - }, - overall_confidence_score: { type: 'number', minimum: 0, maximum: 1 }, - }, - }, - }; -} - -const SINGLE_FILE_SCHEMA_FORMAT = `{ - "findings": [ - { - "evidence": "", - "code_location": { - "line": number, - "line_range": { "start": number, "end": number } - }, - "claim_type": "", - "title": "", - "body": "", - "priority": 0 | 1 | 2 | 3 | 4, - "code_suggestion": "Optional replacement code" - } - ], - "overall_explanation": "Summary", - "overall_correctness": "patch is correct" | "patch is incorrect", - "overall_confidence_score": number (0 to 1) -}`; - -const MULTI_FILE_SCHEMA_FORMAT = `{ - "files": [ - { - "absolute_file_path": "", - "findings": [ - { - "evidence": "", - "code_location": { - "absolute_file_path": "", - "line": number, - "line_range": { "start": number, "end": number } - }, - "claim_type": "", - "title": "", - "body": "", - "priority": 0 | 1 | 2 | 3 | 4, - "code_suggestion": "Optional replacement code" - } - ], - "overall_explanation": "Summary for THIS file", - "overall_correctness": "patch is correct" | "patch is incorrect" - } - ], - "overall_confidence_score": number (0 to 1) -}`; - -export function buildFileReviewSystemPromptBase(opts?: { multiFile?: boolean; fileContext?: boolean }): string { - const multi = opts?.multiFile === true; - - const singleFileScope = opts?.fileContext === true - ? '- You can see the diff below and, after it, the full content of that one file. You cannot see the rest of the repository. Findings must still be about lines the diff CHANGED; the file content is there to tell you what the surrounding code does, not to be reviewed.' - : '- You can see ONLY the diff below, not the whole file or the rest of the repository.'; - - const contextScope = multi - ? `- You can see ONLY the diffs below, not the whole files or the rest of the repository. -- Each file below is INDEPENDENT. A finding about one file must be grounded in a line from THAT file's diff, and must be reported inside that file's entry. Never carry a claim from one file to another, and never assume two files interact unless both diffs show it.` - : singleFileScope; - - const evidenceSource = multi - ? `the single line of code the finding is about, copied VERBATIM from that file's diff below.` - : 'the single line of code the finding is about, copied VERBATIM from the diff below.'; - - const capRule = multi - ? '4. Return at most {{MAX_COMMENTS}} findings PER FILE, most severe first. Keep each body under 160 words.' - : '4. Return at most {{MAX_COMMENTS}} findings, most severe first. Keep each body under 160 words.'; - - const emptyRule = multi - ? `5. Return exactly one entry per file listed below, in the same order, and never omit a file. Review each file's diff with the same care you would give it if it were the only file in front of you. An empty findings array is a positive claim that this diff introduces no defect, so return one only when that is true. Do not pad, and do not withhold.` - : '5. If the diff genuinely introduces no defect, return an empty findings array and a short explanation. Do not pad, and do not withhold.'; - - return `You are a world-class software engineer performing a precise, high-signal code review. -Your goal is to find REAL defects (bugs, security vulnerabilities, and performance problems) introduced by the diff. Every finding must be grounded in a line you can quote from the diff. - -### CONTEXT EXTENDS (read carefully, this prevents false positives): -${contextScope} -- You cannot see which files import this one. Never predict that a change breaks callers, importers, "other modules" or "external files" -- a removed \`export\`, a renamed symbol or a changed signature may have no consumers at all, and you have no way to check. The same applies in reverse to a function whose body is not shown: do not assume what it does with its errors or its return value. -- Assume every third-party package is at the version this project pins, and that its API is whatever that version provides. Never claim a library "does not expose", "does not provide" or "does not support" something; your training data predates the installed version. -- Assume the language, runtime and build target are whatever the project already uses successfully. A syntax or standard-library method appearing in the diff is available in this project by construction -- the code around it already compiles and ships. Do not raise compatibility, polyfill, transpilation, engine-version or server-side-rendering concerns unless the diff itself shows the incompatibility. -- Two async facts that are frequently misread. \`return somePromise()\` inside an \`async\` function IS awaited by whoever awaits that function; it is equivalent to \`return await\` except inside \`try\`/\`finally\`, so it is not a missing await and not a floating promise. And \`void someAsyncCall()\` is deliberate fire-and-forget: if the called function handles its own errors, there is no unhandled rejection to report. - -### WHAT TO REPORT: -- Report anything a senior engineer reviewing this diff would want to investigate: a bug, a security hole, a performance problem, a resource leak, an unhandled failure, a broken invariant. -- You do not need to be certain. A finding you can ground in a quoted line is worth raising; every finding is independently checked against the diff afterwards, and a wrong one is discarded at no cost to you. A defect you decline to mention is simply lost. - -### EVIDENCE (mandatory, a finding without it cannot be posted): -- Every finding MUST include "evidence": ${evidenceSource} -- Copy the code exactly as it appears. Do NOT include the two line-number columns or the +/- marker, do NOT paraphrase, reformat, shorten, or invent code. -- If you cannot quote a specific line from the diff that exhibits the problem, you do not have a finding. Omit it. - -### CLAIM TYPE (required, pick the one that fits, or "other"): -${claimTypes.join(', ')} -- This is a label for the KIND of defect. It does not license the claim: only report a type if the - diff actually shows it. Picking a type the code cannot exhibit makes the finding easy to discard. -- If nothing fits, use "other". Do not stretch a label to fit. -- NEVER claim that a package, action, tag or version "does not exist", or that a config key is invalid. You cannot know what was released after your training data, and a step pinned to a commit SHA resolves by that SHA regardless of the version written beside it. Such claims are discarded. -- Label honestly. The type you choose does not affect whether a finding is accepted; an inaccurate label only makes a real defect harder to act on. - -### OUTPUT RULES: -1. Output MUST be valid JSON, EXACTLY ONE object matching the schema below. -2. DO NOT output any conversational text, source code, or diff hunks before or after the JSON. -3. Prioritize by severity: 0 = P0 critical, 1 = P1 high, 2 = P2 medium, 3 = P3 low, 4 = nit (cosmetic/trivial). Set priority honestly; do not inflate. Use 4 for anything a reviewer would prefix with "nit:". - A finding that rests on a condition you cannot check from the diff -- "if this runs on an older engine", "if another module imports this", "depending on the caller" -- is at most priority 3, never 0 or 1, however serious the consequence would be if the condition held. Certainty about the consequence is not certainty about the premise. -${capRule} -${emptyRule} - -### SCHEMA FORMAT: -${multi ? MULTI_FILE_SCHEMA_FORMAT : SINGLE_FILE_SCHEMA_FORMAT} - -Identify security risks such as XSS, SQLi, CSRF, insecure randomness, and data leaks that the diff actually introduces.`; -} - -export const fileReviewSystemPromptBase = buildFileReviewSystemPromptBase(); - -export function buildFileReviewSystemPrompt( - config: RepoConfig['review'], - languagePersona?: string, - opts?: { multiFile?: boolean; fileContext?: boolean }, -) { - const persona = languagePersona ? ` as ${languagePersona}` : ''; - const prompt = buildFileReviewSystemPromptBase(opts) - .replace('{{MAX_COMMENTS}}', reviewBreadth(config).toString()); - return `You are a world-class professional senior code reviewer${persona}. ${prompt}`; -} - -export type RejectedExemplar = { title: string; claimType?: string | null }; - - - -function renderExemplars(exemplars: readonly RejectedExemplar[] | undefined): string | null { - if (!exemplars?.length) return null; - - const lines: string[] = []; - let used = 0; - for (const exemplar of exemplars) { - const line = `- ${exemplar.title}${exemplar.claimType ? ` (${exemplar.claimType})` : ''}`; - if (used + line.length > EXEMPLAR_BLOCK_CHARS) break; - lines.push(line); - used += line.length; - } - if (lines.length === 0) return null; - - const heading = 'Findings a reviewer on THIS repository has already rejected. Do not report things like these:'; - return [heading, ...lines].join('\n'); -} -function renderCustomRules(config: RepoConfig['review']): string { - const rules = config.custom_rules.length > 0 ? config.custom_rules.map((rule) => `- ${rule}`).join('\n') : '- None'; - return `Custom rules:\n${rules}`; -} - -function renderLanguageGuidelines(path: string): string { - const languageInfo = getLanguageForFile(path); - const guidelineHeader = 'Specific Guidelines (check the diff against each of these)'; - return languageInfo - ? `Language: ${languageInfo.language}\n${guidelineHeader}:\n${languageInfo.guidelines.map(g => `- ${g}`).join('\n')}` - : 'Language: Generic\nSpecific Guidelines: Follow general best practices.'; -} - -export function buildFileReviewPrompts(input: { - file: FileDiff; - fileContext?: string | null; - prTitle: string | null; - prDescription: string | null; - changelogExcerpt?: string | null; - config: RepoConfig['review']; - rejectedExemplars?: readonly RejectedExemplar[]; -}) { - const languageInfo = getLanguageForFile(input.file.path); - const rules = input.config.custom_rules.length > 0 ? input.config.custom_rules.map((rule) => `- ${rule}`).join('\n') : '- None'; - const intentBlock = renderIntentBlock(input); - const fileContext = input.fileContext ? renderFileContext(input.file, input.fileContext) : null; - - const systemPrompt = buildFileReviewSystemPrompt(input.config, languageInfo?.persona, { - fileContext: fileContext !== null, - }); - const languageGuidelines = renderLanguageGuidelines(input.file.path); - - const exemplars = renderExemplars(input.rejectedExemplars); - - const userPrompt = [ - intentBlock, - ...(exemplars ? [exemplars] : []), - `File path: ${input.file.path}`, - languageGuidelines, - `Custom rules:\n${rules}`, - 'Review ONLY the diff shown below. You cannot see the rest of the file or repository - do not report something as undefined, unimported, unused, or missing just because it is not in the diff. If the diff note says it was truncated, do not infer issues from omitted lines.', - 'Line numbers: every diff line below is prefixed with two columns - the OLD file line number, then the NEW file line number. Always report `line` (and `line_range`) using the NEW (second, right-hand) number, and only ever cite a line that appears in the diff. For a removed line, cite the nearest NEW line number shown next to it.', - `Evidence: every finding must carry an \`evidence\` string containing the exact code of the line it is about, copied character-for-character from the UNIFIED DIFF below. Strip the two leading line-number columns and the +/-/space marker - quote only the code itself. A finding whose evidence does not appear in the diff will be discarded${fileContext ? ', and the full-file context below is NOT the diff -- a line quoted from it counts as no evidence at all' : ''}.`, - INTENT_CHECK_INSTRUCTION, - 'Prioritize correctness, security, and production-impacting bugs. Raise anything you can ground in a quoted line; avoid subjective style feedback.', - '', - `## Output JSON Schema (STRICTLY REQUIRED)`, - `{ - "findings": [ - { - "evidence": "", - "code_location": { - "absolute_file_path": "${input.file.path}", - "line": , - "line_range": {"start": , "end": } - }, - "claim_type": "<${claimTypes.join(' | ')}>", - "title": "", - "body": "", - "priority": <0|1|2|3|4>, - "code_suggestion": "string" - } - ], - "overall_correctness": "patch is correct" | "patch is incorrect", - "overall_explanation": "Summary", - "overall_confidence_score": -}`, - '', - 'Unified diff:', - renderFileDiff(input.file), - ...(fileContext ? ['', fileContext] : []), - ].join('\n'); - - return { systemPrompt, userPrompt }; -} - -function packFileHeader(file: FileDiff, index: number, total: number): string { - return `===== FILE ${index + 1} of ${total}: ${file.path} =====`; -} - -export function buildBatchReviewPrompts(input: { - files: readonly FileDiff[]; - prTitle: string | null; - prDescription: string | null; - changelogExcerpt?: string | null; - config: RepoConfig['review']; - rejectedExemplars?: readonly RejectedExemplar[]; -}) { - const files = input.files; - - const languages = new Set(files.map((file) => getLanguageForFile(file.path))); - const uniformLanguage = languages.size === 1 ? [...languages][0] : undefined; - - const systemPrompt = buildFileReviewSystemPrompt(input.config, uniformLanguage?.persona, { multiFile: true }); - - const intentBlock = renderIntentBlock(input); - const exemplars = renderExemplars(input.rejectedExemplars); - const pathList = files.map((file) => `- ${file.path}`).join('\n'); - - const fileBlocks = files.flatMap((file, index) => [ - '', - packFileHeader(file, index, files.length), - ...(uniformLanguage ? [] : [renderLanguageGuidelines(file.path)]), - 'Unified diff:', - renderFileDiff(file), - ]); - - const userPrompt = [ - intentBlock, - ...(exemplars ? [exemplars] : []), - `You are reviewing ${files.length} files in ONE response. Return exactly ${files.length} entries in "files", one per path, in this order:\n${pathList}`, - ...(uniformLanguage ? [renderLanguageGuidelines(files[0].path)] : []), - renderCustomRules(input.config), - 'Review ONLY the diffs shown below. You cannot see the rest of any file or the repository - do not report something as undefined, unimported, unused, or missing just because it is not in a diff. If a diff note says it was truncated, do not infer issues from omitted lines.', - 'File scoping: each finding belongs to exactly ONE file. Put it inside that file\'s entry, set that file\'s path in `absolute_file_path`, and quote evidence from that file\'s diff only. Never report a finding about one file inside another file\'s entry, and never quote a line from a different file.', - 'Line numbers: every diff line below is prefixed with two columns - the OLD file line number, then the NEW file line number. Always report `line` (and `line_range`) using the NEW (second, right-hand) number, and only ever cite a line that appears in that file\'s diff. For a removed line, cite the nearest NEW line number shown next to it.', - 'Evidence: every finding must carry an `evidence` string containing the exact code of the line it is about, copied character-for-character from its own file\'s diff below. Strip the two leading line-number columns and the +/-/space marker - quote only the code itself. A finding whose evidence does not appear in that file\'s diff will be discarded.', - INTENT_CHECK_INSTRUCTION, - 'Prioritize correctness, security, and production-impacting bugs. Raise anything you can ground in a quoted line; avoid subjective style feedback.', - '', - `## Output JSON Schema (STRICTLY REQUIRED)`, - MULTI_FILE_SCHEMA_FORMAT, - ...fileBlocks, - ].join('\n'); - - return { systemPrompt, userPrompt }; -} - -export function renderFileDiff(file: FileDiff) { - const lines = [`diff --git a/${file.previousPath ?? file.path} b/${file.path}`]; - for (const hunk of file.hunks) { - lines.push(hunk.header); - for (const line of hunk.lines) { - const prefix = line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '; - const left = line.oldLineNumber ?? ''; - const right = line.newLineNumber ?? ''; - lines.push(`${String(left).padStart(4, ' ')} ${String(right).padStart(4, ' ')} ${prefix}${line.content}`); - } - } - - if (file.isTruncated) { - lines.push(''); - lines.push(`[NOTE: This diff has been truncated from ${file.originalLineCount} lines to ${file.lineCount} lines for brevity.]`); - } - - return lines.join('\n'); -} +import { claimTypes, type RepoConfig } from '@codraoss/schema'; +import type { FileDiff } from '../diff'; +import type { ModelResponseSchema } from '../ports/model'; +import { getLanguageForFile } from './languages'; +import { + INTENT_CHECK_INSTRUCTION, + renderFileContext, + renderIntentBlock, +} from './review-context'; +import { + EXEMPLAR_BLOCK_CHARS, +} from '../constants'; + +export { changelogExcerptFromDiff, wantsFileContext } from './review-context'; + +// Pre-review_breadth fallback: generator was allowed ~2x the posted cap. +export function generatorFindingCap(maxComments: number): number { + return Math.max(1, maxComments * 2); +} + +/** Internal candidate cap upstream of posting; falls back for job snapshots queued before this field existed. */ +export function reviewBreadth(config: Pick & { review_breadth?: number }): number { + return config.review_breadth ?? generatorFindingCap(config.max_comments); +} + +function findingItemSchema() { + return { + type: 'object', + additionalProperties: false, + required: ['evidence', 'code_location', 'claim_type', 'title', 'body', 'priority'], + properties: { + evidence: { type: 'string' }, + code_location: { + type: 'object', + additionalProperties: false, + properties: { + absolute_file_path: { type: 'string' }, + line: { type: 'integer', minimum: 1 }, + line_range: { + type: 'object', + additionalProperties: false, + required: ['start', 'end'], + properties: { + start: { type: 'integer', minimum: 1 }, + end: { type: 'integer', minimum: 1 }, + }, + }, + }, + anyOf: [ + { required: ['line'] }, + { required: ['line_range'] }, + ], + }, + claim_type: { type: 'string', enum: [...claimTypes] }, + title: { type: 'string', maxLength: 100 }, + body: { type: 'string' }, + priority: { type: 'integer', minimum: 0, maximum: 4 }, + code_suggestion: { type: 'string' }, + }, + }; +} + +export function buildReviewResponseSchema(findingCap: number): ModelResponseSchema { + return { + name: 'codra_file_review', + schema: { + type: 'object', + additionalProperties: false, + required: ['findings', 'overall_explanation', 'overall_correctness', 'overall_confidence_score'], + properties: { + findings: { + type: 'array', + maxItems: Math.max(1, findingCap), + items: findingItemSchema(), + }, + overall_explanation: { type: 'string' }, + overall_correctness: { type: 'string', enum: ['patch is correct', 'patch is incorrect'] }, + overall_confidence_score: { type: 'number', minimum: 0, maximum: 1 }, + }, + }, + }; +} + +export function buildBatchReviewResponseSchema(findingCap: number, fileCount: number): ModelResponseSchema { + return { + name: 'codra_batch_review', + schema: { + type: 'object', + additionalProperties: false, + required: ['files', 'overall_confidence_score'], + properties: { + files: { + type: 'array', + maxItems: fileCount, + items: { + type: 'object', + additionalProperties: false, + required: ['absolute_file_path', 'findings', 'overall_explanation', 'overall_correctness'], + properties: { + absolute_file_path: { type: 'string' }, + findings: { type: 'array', items: findingItemSchema() }, + overall_explanation: { type: 'string' }, + overall_correctness: { type: 'string', enum: ['patch is correct', 'patch is incorrect'] }, + }, + }, + }, + overall_confidence_score: { type: 'number', minimum: 0, maximum: 1 }, + }, + }, + }; +} + +const SINGLE_FILE_SCHEMA_FORMAT = `{ + "findings": [ + { + "evidence": "", + "code_location": { + "line": number, + "line_range": { "start": number, "end": number } + }, + "claim_type": "", + "title": "", + "body": "", + "priority": 0 | 1 | 2 | 3 | 4, + "code_suggestion": "Optional replacement code" + } + ], + "overall_explanation": "Summary", + "overall_correctness": "patch is correct" | "patch is incorrect", + "overall_confidence_score": number (0 to 1) +}`; + +const MULTI_FILE_SCHEMA_FORMAT = `{ + "files": [ + { + "absolute_file_path": "", + "findings": [ + { + "evidence": "", + "code_location": { + "absolute_file_path": "", + "line": number, + "line_range": { "start": number, "end": number } + }, + "claim_type": "", + "title": "", + "body": "", + "priority": 0 | 1 | 2 | 3 | 4, + "code_suggestion": "Optional replacement code" + } + ], + "overall_explanation": "Summary for THIS file", + "overall_correctness": "patch is correct" | "patch is incorrect" + } + ], + "overall_confidence_score": number (0 to 1) +}`; + +export function buildFileReviewSystemPromptBase(opts?: { multiFile?: boolean; fileContext?: boolean }): string { + const multi = opts?.multiFile === true; + + const singleFileScope = opts?.fileContext === true + ? '- You can see the diff below and, after it, the full content of that one file. You cannot see the rest of the repository. Findings must still be about lines the diff CHANGED; the file content is there to tell you what the surrounding code does, not to be reviewed.' + : '- You can see ONLY the diff below, not the whole file or the rest of the repository.'; + + const contextScope = multi + ? `- You can see ONLY the diffs below, not the whole files or the rest of the repository. +- Each file below is INDEPENDENT. A finding about one file must be grounded in a line from THAT file's diff, and must be reported inside that file's entry. Never carry a claim from one file to another, and never assume two files interact unless both diffs show it.` + : singleFileScope; + + const evidenceSource = multi + ? `the single line of code the finding is about, copied VERBATIM from that file's diff below.` + : 'the single line of code the finding is about, copied VERBATIM from the diff below.'; + + const capRule = multi + ? '4. Return at most {{MAX_COMMENTS}} findings PER FILE, most severe first. Keep each body under 160 words.' + : '4. Return at most {{MAX_COMMENTS}} findings, most severe first. Keep each body under 160 words.'; + + const emptyRule = multi + ? `5. Return exactly one entry per file listed below, in the same order, and never omit a file. Review each file's diff with the same care you would give it if it were the only file in front of you. An empty findings array is a positive claim that this diff introduces no defect, so return one only when that is true. Do not pad, and do not withhold.` + : '5. If the diff genuinely introduces no defect, return an empty findings array and a short explanation. Do not pad, and do not withhold.'; + + return `You are a world-class software engineer performing a precise, high-signal code review. +Your goal is to find REAL defects (bugs, security vulnerabilities, and performance problems) introduced by the diff. Every finding must be grounded in a line you can quote from the diff. + +### CONTEXT EXTENDS (read carefully, this prevents false positives): +${contextScope} +- You cannot see which files import this one. Never predict that a change breaks callers, importers, "other modules" or "external files" -- a removed \`export\`, a renamed symbol or a changed signature may have no consumers at all, and you have no way to check. The same applies in reverse to a function whose body is not shown: do not assume what it does with its errors or its return value. +- Assume every third-party package is at the version this project pins, and that its API is whatever that version provides. Never claim a library "does not expose", "does not provide" or "does not support" something; your training data predates the installed version. +- Assume the language, runtime and build target are whatever the project already uses successfully. A syntax or standard-library method appearing in the diff is available in this project by construction -- the code around it already compiles and ships. Do not raise compatibility, polyfill, transpilation, engine-version or server-side-rendering concerns unless the diff itself shows the incompatibility. +- Two async facts that are frequently misread. \`return somePromise()\` inside an \`async\` function IS awaited by whoever awaits that function; it is equivalent to \`return await\` except inside \`try\`/\`finally\`, so it is not a missing await and not a floating promise. And \`void someAsyncCall()\` is deliberate fire-and-forget: if the called function handles its own errors, there is no unhandled rejection to report. + +### WHAT TO REPORT: +- Report anything a senior engineer reviewing this diff would want to investigate: a bug, a security hole, a performance problem, a resource leak, an unhandled failure, a broken invariant. +- You do not need to be certain. A finding you can ground in a quoted line is worth raising; every finding is independently checked against the diff afterwards, and a wrong one is discarded at no cost to you. A defect you decline to mention is simply lost. + +### EVIDENCE (mandatory, a finding without it cannot be posted): +- Every finding MUST include "evidence": ${evidenceSource} +- Copy the code exactly as it appears. Do NOT include the two line-number columns or the +/- marker, do NOT paraphrase, reformat, shorten, or invent code. +- If you cannot quote a specific line from the diff that exhibits the problem, you do not have a finding. Omit it. + +### CLAIM TYPE (required, pick the one that fits, or "other"): +${claimTypes.join(', ')} +- This is a label for the KIND of defect. It does not license the claim: only report a type if the + diff actually shows it. Picking a type the code cannot exhibit makes the finding easy to discard. +- If nothing fits, use "other". Do not stretch a label to fit. +- NEVER claim that a package, action, tag or version "does not exist", or that a config key is invalid. You cannot know what was released after your training data, and a step pinned to a commit SHA resolves by that SHA regardless of the version written beside it. Such claims are discarded. +- Label honestly. The type you choose does not affect whether a finding is accepted; an inaccurate label only makes a real defect harder to act on. + +### OUTPUT RULES: +1. Output MUST be valid JSON, EXACTLY ONE object matching the schema below. +2. DO NOT output any conversational text, source code, or diff hunks before or after the JSON. +3. Prioritize by severity: 0 = P0 critical, 1 = P1 high, 2 = P2 medium, 3 = P3 low, 4 = nit (cosmetic/trivial). Set priority honestly; do not inflate. Use 4 for anything a reviewer would prefix with "nit:". + A finding that rests on a condition you cannot check from the diff -- "if this runs on an older engine", "if another module imports this", "depending on the caller" -- is at most priority 3, never 0 or 1, however serious the consequence would be if the condition held. Certainty about the consequence is not certainty about the premise. +${capRule} +${emptyRule} + +### SCHEMA FORMAT: +${multi ? MULTI_FILE_SCHEMA_FORMAT : SINGLE_FILE_SCHEMA_FORMAT} + +Identify security risks such as XSS, SQLi, CSRF, insecure randomness, and data leaks that the diff actually introduces.`; +} + +export const fileReviewSystemPromptBase = buildFileReviewSystemPromptBase(); + +export function buildFileReviewSystemPrompt( + config: RepoConfig['review'], + languagePersona?: string, + opts?: { multiFile?: boolean; fileContext?: boolean }, +) { + const persona = languagePersona ? ` as ${languagePersona}` : ''; + const prompt = buildFileReviewSystemPromptBase(opts) + .replace('{{MAX_COMMENTS}}', reviewBreadth(config).toString()); + return `You are a world-class professional senior code reviewer${persona}. ${prompt}`; +} + +export type RejectedExemplar = { title: string; claimType?: string | null }; + + + +function renderExemplars(exemplars: readonly RejectedExemplar[] | undefined): string | null { + if (!exemplars?.length) return null; + + const lines: string[] = []; + let used = 0; + for (const exemplar of exemplars) { + const line = `- ${exemplar.title}${exemplar.claimType ? ` (${exemplar.claimType})` : ''}`; + if (used + line.length > EXEMPLAR_BLOCK_CHARS) break; + lines.push(line); + used += line.length; + } + if (lines.length === 0) return null; + + const heading = 'Findings a reviewer on THIS repository has already rejected. Do not report things like these:'; + return [heading, ...lines].join('\n'); +} +function renderCustomRules(config: RepoConfig['review']): string { + const rules = config.custom_rules.length > 0 ? config.custom_rules.map((rule) => `- ${rule}`).join('\n') : '- None'; + return `Custom rules:\n${rules}`; +} + +function renderLanguageGuidelines(path: string): string { + const languageInfo = getLanguageForFile(path); + const guidelineHeader = 'Specific Guidelines (check the diff against each of these)'; + return languageInfo + ? `Language: ${languageInfo.language}\n${guidelineHeader}:\n${languageInfo.guidelines.map(g => `- ${g}`).join('\n')}` + : 'Language: Generic\nSpecific Guidelines: Follow general best practices.'; +} + +export function buildFileReviewPrompts(input: { + file: FileDiff; + fileContext?: string | null; + prTitle: string | null; + prDescription: string | null; + changelogExcerpt?: string | null; + config: RepoConfig['review']; + rejectedExemplars?: readonly RejectedExemplar[]; +}) { + const languageInfo = getLanguageForFile(input.file.path); + const rules = input.config.custom_rules.length > 0 ? input.config.custom_rules.map((rule) => `- ${rule}`).join('\n') : '- None'; + const intentBlock = renderIntentBlock(input); + const fileContext = input.fileContext ? renderFileContext(input.file, input.fileContext) : null; + + const systemPrompt = buildFileReviewSystemPrompt(input.config, languageInfo?.persona, { + fileContext: fileContext !== null, + }); + const languageGuidelines = renderLanguageGuidelines(input.file.path); + + const exemplars = renderExemplars(input.rejectedExemplars); + + const userPrompt = [ + intentBlock, + ...(exemplars ? [exemplars] : []), + `File path: ${input.file.path}`, + languageGuidelines, + `Custom rules:\n${rules}`, + 'Review ONLY the diff shown below. You cannot see the rest of the file or repository - do not report something as undefined, unimported, unused, or missing just because it is not in the diff. If the diff note says it was truncated, do not infer issues from omitted lines.', + 'Line numbers: every diff line below is prefixed with two columns - the OLD file line number, then the NEW file line number. Always report `line` (and `line_range`) using the NEW (second, right-hand) number, and only ever cite a line that appears in the diff. For a removed line, cite the nearest NEW line number shown next to it.', + `Evidence: every finding must carry an \`evidence\` string containing the exact code of the line it is about, copied character-for-character from the UNIFIED DIFF below. Strip the two leading line-number columns and the +/-/space marker - quote only the code itself. A finding whose evidence does not appear in the diff will be discarded${fileContext ? ', and the full-file context below is NOT the diff -- a line quoted from it counts as no evidence at all' : ''}.`, + INTENT_CHECK_INSTRUCTION, + 'Prioritize correctness, security, and production-impacting bugs. Raise anything you can ground in a quoted line; avoid subjective style feedback.', + '', + `## Output JSON Schema (STRICTLY REQUIRED)`, + `{ + "findings": [ + { + "evidence": "", + "code_location": { + "absolute_file_path": "${input.file.path}", + "line": , + "line_range": {"start": , "end": } + }, + "claim_type": "<${claimTypes.join(' | ')}>", + "title": "", + "body": "", + "priority": <0|1|2|3|4>, + "code_suggestion": "string" + } + ], + "overall_correctness": "patch is correct" | "patch is incorrect", + "overall_explanation": "Summary", + "overall_confidence_score": +}`, + '', + 'Unified diff:', + renderFileDiff(input.file), + ...(fileContext ? ['', fileContext] : []), + ].join('\n'); + + return { systemPrompt, userPrompt }; +} + +function packFileHeader(file: FileDiff, index: number, total: number): string { + return `===== FILE ${index + 1} of ${total}: ${file.path} =====`; +} + +export function buildBatchReviewPrompts(input: { + files: readonly FileDiff[]; + prTitle: string | null; + prDescription: string | null; + changelogExcerpt?: string | null; + config: RepoConfig['review']; + rejectedExemplars?: readonly RejectedExemplar[]; +}) { + const files = input.files; + + const languages = new Set(files.map((file) => getLanguageForFile(file.path))); + const uniformLanguage = languages.size === 1 ? [...languages][0] : undefined; + + const systemPrompt = buildFileReviewSystemPrompt(input.config, uniformLanguage?.persona, { multiFile: true }); + + const intentBlock = renderIntentBlock(input); + const exemplars = renderExemplars(input.rejectedExemplars); + const pathList = files.map((file) => `- ${file.path}`).join('\n'); + + const fileBlocks = files.flatMap((file, index) => [ + '', + packFileHeader(file, index, files.length), + ...(uniformLanguage ? [] : [renderLanguageGuidelines(file.path)]), + 'Unified diff:', + renderFileDiff(file), + ]); + + const userPrompt = [ + intentBlock, + ...(exemplars ? [exemplars] : []), + `You are reviewing ${files.length} files in ONE response. Return exactly ${files.length} entries in "files", one per path, in this order:\n${pathList}`, + ...(uniformLanguage ? [renderLanguageGuidelines(files[0].path)] : []), + renderCustomRules(input.config), + 'Review ONLY the diffs shown below. You cannot see the rest of any file or the repository - do not report something as undefined, unimported, unused, or missing just because it is not in a diff. If a diff note says it was truncated, do not infer issues from omitted lines.', + 'File scoping: each finding belongs to exactly ONE file. Put it inside that file\'s entry, set that file\'s path in `absolute_file_path`, and quote evidence from that file\'s diff only. Never report a finding about one file inside another file\'s entry, and never quote a line from a different file.', + 'Line numbers: every diff line below is prefixed with two columns - the OLD file line number, then the NEW file line number. Always report `line` (and `line_range`) using the NEW (second, right-hand) number, and only ever cite a line that appears in that file\'s diff. For a removed line, cite the nearest NEW line number shown next to it.', + 'Evidence: every finding must carry an `evidence` string containing the exact code of the line it is about, copied character-for-character from its own file\'s diff below. Strip the two leading line-number columns and the +/-/space marker - quote only the code itself. A finding whose evidence does not appear in that file\'s diff will be discarded.', + INTENT_CHECK_INSTRUCTION, + 'Prioritize correctness, security, and production-impacting bugs. Raise anything you can ground in a quoted line; avoid subjective style feedback.', + '', + `## Output JSON Schema (STRICTLY REQUIRED)`, + MULTI_FILE_SCHEMA_FORMAT, + ...fileBlocks, + ].join('\n'); + + return { systemPrompt, userPrompt }; +} + +export function renderFileDiff(file: FileDiff) { + const lines = [`diff --git a/${file.previousPath ?? file.path} b/${file.path}`]; + for (const hunk of file.hunks) { + lines.push(hunk.header); + for (const line of hunk.lines) { + const prefix = line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '; + const left = line.oldLineNumber ?? ''; + const right = line.newLineNumber ?? ''; + lines.push(`${String(left).padStart(4, ' ')} ${String(right).padStart(4, ' ')} ${prefix}${line.content}`); + } + } + + if (file.isTruncated) { + lines.push(''); + lines.push(`[NOTE: This diff has been truncated from ${file.originalLineCount} lines to ${file.lineCount} lines for brevity.]`); + } + + return lines.join('\n'); +} diff --git a/packages/core/src/prompts/languages.ts b/packages/core/src/prompts/languages.ts index d4ae8ddb..6746334f 100644 --- a/packages/core/src/prompts/languages.ts +++ b/packages/core/src/prompts/languages.ts @@ -1,96 +1,96 @@ -export type LanguageGuideline = { - language: string; - extensions: string[]; - guidelines: string[]; - persona?: string; -}; - -const LANGUAGE_GUIDELINES: LanguageGuideline[] = [ - { - language: 'TypeScript/JavaScript', - persona: 'an expert TypeScript engineer focused on correctness and safe async code', - extensions: ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'], - guidelines: [ - 'Flag unhandled promise rejections, missing await, or async errors that can crash or silently drop work.', - 'Flag resource leaks that cause real bugs (uncleared timers/intervals/listeners on a path that runs repeatedly).', - 'Flag security pitfalls such as eval() on untrusted input or ReDoS-prone regexes.', - 'Flag runtime-breaking null/undefined access introduced by the diff.', - ], - }, - { - language: 'Python', - persona: 'a Python engineer focused on correctness', - extensions: ['py'], - guidelines: [ - 'Flag mutable default arguments that cause shared-state bugs.', - 'Flag bare "except:" that swallows errors and hides failures.', - 'Flag incorrect exception handling or resource handling (files/sockets not closed).', - ], - }, - { - language: 'CSS/SCSS/Less', - persona: 'a frontend engineer', - extensions: ['css', 'scss', 'sass', 'less'], - guidelines: [ - 'Flag only rules that break layout or rendering; do not report stylistic preferences.', - ], - }, - { - language: 'SQL', - persona: 'a database engineer focused on query safety and correctness', - extensions: ['sql'], - guidelines: [ - 'Flag SQL injection risks (unparameterized/interpolated user input).', - 'Flag destructive or non-atomic migrations that risk data loss.', - ], - }, - { - language: 'Markdown', - persona: 'a technical writer', - extensions: ['md', 'mdx'], - guidelines: [ - 'Flag only broken links/images or factually incorrect content; do not report style or grammar nits.', - ], - }, - { - language: 'HTML', - persona: 'a web engineer', - extensions: ['html', 'htm'], - guidelines: [ - 'Flag only markup that is broken or functionally inaccessible; do not report SEO or style preferences.', - ], - }, - { - language: 'JSON/Config', - persona: 'a DevOps engineer', - extensions: ['json', 'jsonc', 'yaml', 'yml', 'toml'], - guidelines: [ - 'Flag invalid syntax/schema or hardcoded secrets; do not report naming-convention preferences.', - ], - }, -]; - -export function getLanguageForFile(path: string): LanguageGuideline | undefined { - const ext = path.split('.').pop()?.toLowerCase(); - if (!ext) return undefined; - - const matches = LANGUAGE_GUIDELINES.filter((g) => g.extensions.includes(ext)); - - if (matches.length === 0) return undefined; - - if (matches.length > 1) { - return matches.reduce((best, candidate) => - candidate.extensions.length < best.extensions.length ? candidate : best, - ); - } - - return matches[0]; -} - -// Matched by filename convention, not extension; kept narrow to avoid false positives. -export function isChangelogPath(path: string): boolean { - const name = path.split('/').pop()?.toLowerCase() ?? ''; - const stem = name.replace(/\.(md|mdx|markdown|rst|txt)$/, ''); - return stem === 'changelog' || stem === 'changes' || stem === 'history' || stem === 'news' - || stem === 'release-notes' || stem === 'release_notes' || stem === 'releasenotes'; -} +export type LanguageGuideline = { + language: string; + extensions: string[]; + guidelines: string[]; + persona?: string; +}; + +const LANGUAGE_GUIDELINES: LanguageGuideline[] = [ + { + language: 'TypeScript/JavaScript', + persona: 'an expert TypeScript engineer focused on correctness and safe async code', + extensions: ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'], + guidelines: [ + 'Flag unhandled promise rejections, missing await, or async errors that can crash or silently drop work.', + 'Flag resource leaks that cause real bugs (uncleared timers/intervals/listeners on a path that runs repeatedly).', + 'Flag security pitfalls such as eval() on untrusted input or ReDoS-prone regexes.', + 'Flag runtime-breaking null/undefined access introduced by the diff.', + ], + }, + { + language: 'Python', + persona: 'a Python engineer focused on correctness', + extensions: ['py'], + guidelines: [ + 'Flag mutable default arguments that cause shared-state bugs.', + 'Flag bare "except:" that swallows errors and hides failures.', + 'Flag incorrect exception handling or resource handling (files/sockets not closed).', + ], + }, + { + language: 'CSS/SCSS/Less', + persona: 'a frontend engineer', + extensions: ['css', 'scss', 'sass', 'less'], + guidelines: [ + 'Flag only rules that break layout or rendering; do not report stylistic preferences.', + ], + }, + { + language: 'SQL', + persona: 'a database engineer focused on query safety and correctness', + extensions: ['sql'], + guidelines: [ + 'Flag SQL injection risks (unparameterized/interpolated user input).', + 'Flag destructive or non-atomic migrations that risk data loss.', + ], + }, + { + language: 'Markdown', + persona: 'a technical writer', + extensions: ['md', 'mdx'], + guidelines: [ + 'Flag only broken links/images or factually incorrect content; do not report style or grammar nits.', + ], + }, + { + language: 'HTML', + persona: 'a web engineer', + extensions: ['html', 'htm'], + guidelines: [ + 'Flag only markup that is broken or functionally inaccessible; do not report SEO or style preferences.', + ], + }, + { + language: 'JSON/Config', + persona: 'a DevOps engineer', + extensions: ['json', 'jsonc', 'yaml', 'yml', 'toml'], + guidelines: [ + 'Flag invalid syntax/schema or hardcoded secrets; do not report naming-convention preferences.', + ], + }, +]; + +export function getLanguageForFile(path: string): LanguageGuideline | undefined { + const ext = path.split('.').pop()?.toLowerCase(); + if (!ext) return undefined; + + const matches = LANGUAGE_GUIDELINES.filter((g) => g.extensions.includes(ext)); + + if (matches.length === 0) return undefined; + + if (matches.length > 1) { + return matches.reduce((best, candidate) => + candidate.extensions.length < best.extensions.length ? candidate : best, + ); + } + + return matches[0]; +} + +// Matched by filename convention, not extension; kept narrow to avoid false positives. +export function isChangelogPath(path: string): boolean { + const name = path.split('/').pop()?.toLowerCase() ?? ''; + const stem = name.replace(/\.(md|mdx|markdown|rst|txt)$/, ''); + return stem === 'changelog' || stem === 'changes' || stem === 'history' || stem === 'news' + || stem === 'release-notes' || stem === 'release_notes' || stem === 'releasenotes'; +} diff --git a/packages/core/src/prompts/verify.ts b/packages/core/src/prompts/verify.ts index 36ad71fa..17ef5ee9 100644 --- a/packages/core/src/prompts/verify.ts +++ b/packages/core/src/prompts/verify.ts @@ -1,158 +1,158 @@ -import { z } from 'zod'; -import { jsonrepair } from 'jsonrepair'; -import type { FileDiff } from '../diff'; - -export type VerifyCandidate = { - index: number; - path: string; - line: number | null; - title: string; - body: string; - snippet: string; - evidence?: string | null; -}; - -const verifyResultSchema = z.object({ - results: z - .array( - z.object({ - index: z.number().int(), - reason: z.string().optional(), - // decidable" -- only an explicit `false` costs a finding. See the note on the prompt below. - decidable: z.boolean().optional(), - verdict: z.enum(['keep', 'drop']), - confidence: z.number().min(0).max(1).optional(), - }), - ) - .default([]), -}); - -export type VerifyResult = z.infer['results'][number]; - -export const VERIFY_RESPONSE_SCHEMA = { - name: 'codra_verify_findings', - schema: { - type: 'object', - additionalProperties: false, - required: ['results'], - properties: { - results: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: ['index', 'reason', 'decidable', 'verdict'], - properties: { - index: { type: 'integer', minimum: 0 }, - reason: { type: 'string', maxLength: 300 }, - decidable: { type: 'boolean' }, - verdict: { type: 'string', enum: ['keep', 'drop'] }, - confidence: { type: 'number', minimum: 0, maximum: 1 }, - }, - }, - }, - }, - }, -} as const; - -export const VERIFY_SYSTEM_PROMPT = `You are a meticulous senior engineer checking whether each candidate code-review finding is actually supported by the code it points at. - -For EACH finding you are given the claim and a SHORT WINDOW of diff context around the line it was anchored to. That window is all you have: you cannot see the rest of the file, any other file, the project's dependencies and their versions, its build target, or its runtime. - -Answer two questions per finding, in this order. - -1. "decidable": can this claim be settled from the window you were given? - - true - the window contains everything needed to say whether the claim holds. - - false - settling it would need something outside the window: which files import this one, what a function defined elsewhere does, which version of a dependency is installed, what engine or renderer the code runs on, or how a caller uses the result. - Watch for claims that assert a CONSEQUENCE somewhere you cannot see: "this breaks importers", "this throws on older runtimes", "this fails during server rendering", "the caller will not await this". The anchored line can be exactly as quoted and the consequence still be unverifiable - confirming that the quote is real is NOT confirming the claim. - When "decidable" is false, say in "reason" what you would have to look at, e.g. "would need the importers of this module". - - Two rules, because both have been got wrong on real reviews: - - a) A claim of the form "if X() fails / rejects / throws, this is unhandled" is NOT decidable unless the - BODY of X is inside your window. A function whose body you cannot see may well handle its own - errors, in which case there is nothing to report. Seeing the CALL is not seeing the body. Mark it - not decidable and say you would need that function's implementation. - - b) Read the diff markers before you agree that something was removed or changed. A line prefixed "-" - is the OLD code and a line prefixed "+" is the NEW code. A claim that says "X was replaced by Y" is - false if the diff shows Y being replaced by X, and a claim that a safeguard was "removed" is false - if the "+" line still carries an equivalent one under a different name. State the direction in your - reason: "the + line adds strict validation, so the claim is backwards". - -2. "verdict": - - "keep": the code in the window genuinely exhibits the problem the claim describes. - - "drop": the claim is not supported by the code shown - it describes something that isn't there, it is speculative, it is a subjective style preference, or it is not decidable from this window. - A claim you marked not decidable is always a "drop". - -Judge the CLAIM against the CODE. Do not defer to the claim's confidence or phrasing; a well-written claim about code that doesn't do what it says is still a drop. -Be strict: when in doubt, "drop". It is better to drop a borderline finding than to keep a wrong one. - -Output MUST be valid JSON, exactly one object, no prose before or after: -{ - "results": [ - { "index": , "reason": "", "decidable": true | false, "verdict": "keep" | "drop", "confidence": } - ] -} -Include exactly one result object for every finding index provided, and use the same index numbers you were given.`; - -export function buildVerifyPrompt(candidates: VerifyCandidate[]): string { - const blocks = candidates.map((c) => { - const location = c.line != null ? `${c.path}:${c.line}` : c.path; - return [ - `### Finding index ${c.index}`, - `Location: ${location}`, - `Title: ${c.title}`, - `Claim: ${c.body}`, - ...(c.evidence ? [`Code the claim cites: ${c.evidence}`] : []), - 'Relevant diff:', - c.snippet || '(no diff context available for this location)', - ].join('\n'); - }); - - return [ - 'Validate each finding below against its diff context. Return a verdict for every index.', - '', - blocks.join('\n\n'), - ].join('\n'); -} - -export function renderDiffSnippet(file: FileDiff | undefined, line: number | undefined, radius = 12): string { - if (!file) return ''; - const flat = file.hunks.flatMap((hunk) => hunk.lines); - if (flat.length === 0) return ''; - - if (line == null) return ''; - - const byNewLine = flat.findIndex((l) => l.newLineNumber === line); - const anchor = byNewLine !== -1 ? byNewLine : flat.findIndex((l) => l.oldLineNumber === line); - if (anchor === -1) return ''; - - const start = Math.max(0, anchor - radius); - const end = Math.min(flat.length, anchor + radius + 1); - - return flat - .slice(start, end) - .map((l) => { - const prefix = l.kind === 'add' ? '+' : l.kind === 'del' ? '-' : ' '; - const gutter = String(l.newLineNumber ?? l.oldLineNumber ?? '').padStart(4, ' '); - return `${gutter} ${prefix}${l.content}`; - }) - .join('\n'); -} - -export function parseVerifyResponse(raw: string): VerifyResult[] { - const trimmed = raw.trim(); - const start = trimmed.indexOf('{'); - const end = trimmed.lastIndexOf('}'); - const candidate = start !== -1 && end !== -1 && end > start ? trimmed.slice(start, end + 1) : trimmed; - - let json: unknown; - try { - json = JSON.parse(candidate); - } catch { - json = JSON.parse(jsonrepair(candidate)); - } - - return verifyResultSchema.parse(json).results; -} +import { z } from 'zod'; +import { jsonrepair } from 'jsonrepair'; +import type { FileDiff } from '../diff'; + +export type VerifyCandidate = { + index: number; + path: string; + line: number | null; + title: string; + body: string; + snippet: string; + evidence?: string | null; +}; + +const verifyResultSchema = z.object({ + results: z + .array( + z.object({ + index: z.number().int(), + reason: z.string().optional(), + // decidable" -- only an explicit `false` costs a finding. See the note on the prompt below. + decidable: z.boolean().optional(), + verdict: z.enum(['keep', 'drop']), + confidence: z.number().min(0).max(1).optional(), + }), + ) + .default([]), +}); + +export type VerifyResult = z.infer['results'][number]; + +export const VERIFY_RESPONSE_SCHEMA = { + name: 'codra_verify_findings', + schema: { + type: 'object', + additionalProperties: false, + required: ['results'], + properties: { + results: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['index', 'reason', 'decidable', 'verdict'], + properties: { + index: { type: 'integer', minimum: 0 }, + reason: { type: 'string', maxLength: 300 }, + decidable: { type: 'boolean' }, + verdict: { type: 'string', enum: ['keep', 'drop'] }, + confidence: { type: 'number', minimum: 0, maximum: 1 }, + }, + }, + }, + }, + }, +} as const; + +export const VERIFY_SYSTEM_PROMPT = `You are a meticulous senior engineer checking whether each candidate code-review finding is actually supported by the code it points at. + +For EACH finding you are given the claim and a SHORT WINDOW of diff context around the line it was anchored to. That window is all you have: you cannot see the rest of the file, any other file, the project's dependencies and their versions, its build target, or its runtime. + +Answer two questions per finding, in this order. + +1. "decidable": can this claim be settled from the window you were given? + - true - the window contains everything needed to say whether the claim holds. + - false - settling it would need something outside the window: which files import this one, what a function defined elsewhere does, which version of a dependency is installed, what engine or renderer the code runs on, or how a caller uses the result. + Watch for claims that assert a CONSEQUENCE somewhere you cannot see: "this breaks importers", "this throws on older runtimes", "this fails during server rendering", "the caller will not await this". The anchored line can be exactly as quoted and the consequence still be unverifiable - confirming that the quote is real is NOT confirming the claim. + When "decidable" is false, say in "reason" what you would have to look at, e.g. "would need the importers of this module". + + Two rules, because both have been got wrong on real reviews: + + a) A claim of the form "if X() fails / rejects / throws, this is unhandled" is NOT decidable unless the + BODY of X is inside your window. A function whose body you cannot see may well handle its own + errors, in which case there is nothing to report. Seeing the CALL is not seeing the body. Mark it + not decidable and say you would need that function's implementation. + + b) Read the diff markers before you agree that something was removed or changed. A line prefixed "-" + is the OLD code and a line prefixed "+" is the NEW code. A claim that says "X was replaced by Y" is + false if the diff shows Y being replaced by X, and a claim that a safeguard was "removed" is false + if the "+" line still carries an equivalent one under a different name. State the direction in your + reason: "the + line adds strict validation, so the claim is backwards". + +2. "verdict": + - "keep": the code in the window genuinely exhibits the problem the claim describes. + - "drop": the claim is not supported by the code shown - it describes something that isn't there, it is speculative, it is a subjective style preference, or it is not decidable from this window. + A claim you marked not decidable is always a "drop". + +Judge the CLAIM against the CODE. Do not defer to the claim's confidence or phrasing; a well-written claim about code that doesn't do what it says is still a drop. +Be strict: when in doubt, "drop". It is better to drop a borderline finding than to keep a wrong one. + +Output MUST be valid JSON, exactly one object, no prose before or after: +{ + "results": [ + { "index": , "reason": "", "decidable": true | false, "verdict": "keep" | "drop", "confidence": } + ] +} +Include exactly one result object for every finding index provided, and use the same index numbers you were given.`; + +export function buildVerifyPrompt(candidates: VerifyCandidate[]): string { + const blocks = candidates.map((c) => { + const location = c.line != null ? `${c.path}:${c.line}` : c.path; + return [ + `### Finding index ${c.index}`, + `Location: ${location}`, + `Title: ${c.title}`, + `Claim: ${c.body}`, + ...(c.evidence ? [`Code the claim cites: ${c.evidence}`] : []), + 'Relevant diff:', + c.snippet || '(no diff context available for this location)', + ].join('\n'); + }); + + return [ + 'Validate each finding below against its diff context. Return a verdict for every index.', + '', + blocks.join('\n\n'), + ].join('\n'); +} + +export function renderDiffSnippet(file: FileDiff | undefined, line: number | undefined, radius = 12): string { + if (!file) return ''; + const flat = file.hunks.flatMap((hunk) => hunk.lines); + if (flat.length === 0) return ''; + + if (line == null) return ''; + + const byNewLine = flat.findIndex((l) => l.newLineNumber === line); + const anchor = byNewLine !== -1 ? byNewLine : flat.findIndex((l) => l.oldLineNumber === line); + if (anchor === -1) return ''; + + const start = Math.max(0, anchor - radius); + const end = Math.min(flat.length, anchor + radius + 1); + + return flat + .slice(start, end) + .map((l) => { + const prefix = l.kind === 'add' ? '+' : l.kind === 'del' ? '-' : ' '; + const gutter = String(l.newLineNumber ?? l.oldLineNumber ?? '').padStart(4, ' '); + return `${gutter} ${prefix}${l.content}`; + }) + .join('\n'); +} + +export function parseVerifyResponse(raw: string): VerifyResult[] { + const trimmed = raw.trim(); + const start = trimmed.indexOf('{'); + const end = trimmed.lastIndexOf('}'); + const candidate = start !== -1 && end !== -1 && end > start ? trimmed.slice(start, end + 1) : trimmed; + + let json: unknown; + try { + json = JSON.parse(candidate); + } catch { + json = JSON.parse(jsonrepair(candidate)); + } + + return verifyResultSchema.parse(json).results; +} diff --git a/packages/core/src/review/bin-runner.ts b/packages/core/src/review/bin-runner.ts index 4b6530b4..c0206a20 100644 --- a/packages/core/src/review/bin-runner.ts +++ b/packages/core/src/review/bin-runner.ts @@ -1,230 +1,230 @@ -import { logger } from '../logger'; -import type { RepoConfig } from '@codraoss/schema'; -import type { FileDiff } from '../diff'; -import { renderFileDiff, type RejectedExemplar } from '../prompts/file-review'; -import type { BulkFileReviewInput, PullRequestRecord, ReviewModel, ReviewRuntime } from '../ports'; -import { type PersistedReviewJob } from './phase-control'; -import { FRESH_INVOCATION_YIELD_SECONDS, MAX_RETRYABLE_FILE_REVIEW_FAILURES, MISSING_FILE_ERROR } from '../constants'; -import { isSubrequestBudgetError, retryableModelFailureDelaySeconds } from './retry-policy'; -import { scanRuleChannel } from './file-runner'; - - - -export function proportionalSplit(total: number, weights: number[]): number[] { - if (weights.length === 0) return []; - - const sum = weights.reduce((a, b) => a + b, 0); - const parts = sum <= 0 - ? weights.map(() => Math.floor(total / weights.length)) - : weights.map((w) => Math.floor((total * w) / sum)); - - const assigned = parts.reduce((a, b) => a + b, 0); - if (assigned < total) { - const largest = weights.indexOf(Math.max(...weights)); - parts[largest === -1 ? 0 : largest] += total - assigned; - } - return parts; -} - -export async function reviewAndPersistBin( - env: ReviewRuntime, - job: PersistedReviewJob, - files: FileDiff[], - pr: PullRequestRecord, - config: RepoConfig, - totalLineCount: number, - model: ReviewModel, - resolveFailureModelProvider: () => Promise, - rejectedExemplars: readonly RejectedExemplar[] = [], - changelogExcerpt: string | null = null, -): Promise { - const startedAt = env.clock.now(); - - const ruleScans = new Map(files.map((file) => [file.path, scanRuleChannel(file, config)])); - - const persisted = new Set(); - let terminalCount = 0; - - const failedRow = (file: FileDiff, errorMessage: string, modelProvider?: string | null): BulkFileReviewInput => ({ - filePath: file.path, - fileStatus: 'failed', - modelUsed: config.model?.main ?? 'unconfigured', - modelProvider: modelProvider ?? null, - diffLineCount: file.lineCount, - rawAiOutput: null, - parsedComments: ruleScans.get(file.path)?.comments ?? [], - inputTokens: null, - outputTokens: null, - durationMs: env.clock.now() - startedAt, - verdict: null, - fileSummary: null, - errorMessage, - batchSize: files.length, - }); - - try { - const response = await model.reviewFiles({ - files, - prTitle: pr.title ?? null, - prDescription: pr.body ?? null, - changelogExcerpt, - config, - totalLineCount, - rejectedExemplars, - }); - - const reviewed = files.filter((file) => response.batch.reviews.has(file.path)); - const weights = reviewed.map((file) => renderFileDiff(file).length); - const inputSplit = proportionalSplit(response.inputTokens, weights); - const outputSplit = proportionalSplit(response.outputTokens, weights); - const durationMs = env.clock.now() - startedAt; - - const rows: BulkFileReviewInput[] = reviewed.map((file, index) => { - const parsed = response.batch.reviews.get(file.path)!; - const rules = ruleScans.get(file.path)!; - return { - filePath: file.path, - fileStatus: 'done', - modelUsed: response.modelUsed, - modelProvider: response.provider, - diffLineCount: file.lineCount, - rawAiOutput: response.rawText, - parsedComments: [...parsed.comments, ...rules.comments], - inputTokens: inputSplit[index], - outputTokens: outputSplit[index], - durationMs, - verdict: parsed.verdict, - fileSummary: parsed.fileSummary, - overallCorrectness: parsed.overallCorrectness, - confidenceScore: parsed.confidenceScore, - errorMessage: null, - withheldCounts: { - evidence: (parsed.evidenceStats?.unmatched ?? 0) - + (parsed.evidenceStats?.absent ?? 0) - + (parsed.evidenceStats?.weak ?? 0), - claimDenied: Object.values(parsed.deniedClaimCounts ?? {}).reduce((sum, n) => sum + n, 0), - contextOnly: parsed.evidenceStats?.contextOnly ?? 0, - absenceRefuted: parsed.absenceCheckStats?.refuted ?? 0, - }, - // The whole bin shared one call, so every member inherits its degradation. - degraded: response.degraded ?? null, - batchSize: files.length, - }; - }); - - if (rows.length > 0) { - await env.fileReviews.bulkUpsertFileReviews(job.id, rows); - for (const row of rows) persisted.add(row.filePath); - terminalCount += rows.length; - } - - if (response.batch.missing.length > 0) { - const counts = await env.fileReviews.bulkRecordRetryableFileReviewFailures(job.id, response.batch.missing.map((path) => ({ - filePath: path, - modelUsed: response.modelUsed, - diffLineCount: files.find((f) => f.path === path)?.lineCount ?? 0, - errorMessage: MISSING_FILE_ERROR, - }))); - for (const count of counts) persisted.add(count.filePath); - - const exhausted = counts.filter((c) => c.transientErrorCount >= MAX_RETRYABLE_FILE_REVIEW_FAILURES); - if (exhausted.length > 0) { - await env.fileReviews.bulkUpsertFileReviews(job.id, exhausted.map((c) => failedRow( - files.find((f) => f.path === c.filePath)!, - `Review skipped after the model omitted this file ${c.transientErrorCount} times.`, - ))); - terminalCount += exhausted.length; - } - } - - logger.info('Batched file review parsed', { - jobId: job.id, - model: response.modelUsed, - binSize: files.length, - binPaths: files.map((f) => f.path), - binDiffLines: files.reduce((sum, f) => sum + f.lineCount, 0), - durationMs, - inputTokens: response.inputTokens, - outputTokens: response.outputTokens, - keptPerFile: rows.map((r) => ({ path: r.filePath, kept: r.parsedComments.length })), - entriesReturned: response.batch.stats.entriesReturned, - missingFiles: response.batch.missing, - unroutableEntries: response.batch.stats.unroutableEntries, - pathMismatchFindings: response.batch.stats.pathMismatchFindings, - ambiguousAcrossBin: response.batch.stats.ambiguousAcrossBin, - flatFallback: response.batch.stats.flatFallback, - overCap: response.batch.stats.overCap, - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown batched review error'; - const modelId = config.model?.main ?? 'unconfigured'; - const modelProvider = await resolveFailureModelProvider(); - - if (isSubrequestBudgetError(error)) { - logger.warn('Batched review deferred; subrequest budget will retry in a fresh invocation', { - jobId: job.id, - paths: files.map((f) => f.path), - error: errorMessage, - }); - Object.defineProperty(error, 'retryAfterSeconds', { value: FRESH_INVOCATION_YIELD_SECONDS, configurable: true }); - throw error; - } - - const outstanding = files.filter((file) => !persisted.has(file.path)); - - if (outstanding.length === 0) { - logger.warn('Batched review hit an error after every file was persisted; keeping the committed rows', { - jobId: job.id, - paths: files.map((f) => f.path), - error: errorMessage, - }); - return terminalCount; - } - - if (env.modelErrors.isRetryableModelError(error)) { - const advancedTo = env.modelErrors.nextChainIndexOf(error); - const counts = await env.fileReviews.bulkRecordRetryableFileReviewFailures(job.id, outstanding.map((file) => ({ - filePath: file.path, - modelUsed: modelId, - diffLineCount: file.lineCount, - errorMessage, - })), { countsAsAttempt: advancedTo === null }); - - const exhausted = counts.filter((c) => c.transientErrorCount >= MAX_RETRYABLE_FILE_REVIEW_FAILURES); - if (exhausted.length > 0) { - await env.fileReviews.bulkUpsertFileReviews(job.id, exhausted.map((c) => failedRow( - files.find((f) => f.path === c.filePath)!, - `Review skipped after ${c.transientErrorCount} repeated model provider outages.`, - modelProvider, - ))); - terminalCount += exhausted.length; - logger.error('Files in a batched review failed permanently after transient retries', { - jobId: job.id, - paths: exhausted.map((c) => c.filePath), - error: errorMessage, - }); - } - - const stillRetrying = counts.filter((c) => c.transientErrorCount < MAX_RETRYABLE_FILE_REVIEW_FAILURES); - if (stillRetrying.length === 0) return terminalCount; - - logger.warn('Batched review deferred; transient model/provider failure will retry later', { - jobId: job.id, - paths: stillRetrying.map((c) => c.filePath), - error: errorMessage, - }); - Object.defineProperty(error, 'retryAfterSeconds', { - value: retryableModelFailureDelaySeconds(Math.max(...stillRetrying.map((c) => c.transientErrorCount))), - configurable: true, - }); - throw error; - } - - logger.error('Batched review failed', { jobId: job.id, paths: outstanding.map((f) => f.path), error }); - - await env.fileReviews.bulkUpsertFileReviews(job.id, outstanding.map((file) => failedRow(file, errorMessage, modelProvider))); - terminalCount += outstanding.length; - } - - return terminalCount; -} +import { logger } from '../logger'; +import type { RepoConfig } from '@codraoss/schema'; +import type { FileDiff } from '../diff'; +import { renderFileDiff, type RejectedExemplar } from '../prompts/file-review'; +import type { BulkFileReviewInput, PullRequestRecord, ReviewModel, ReviewRuntime } from '../ports'; +import { type PersistedReviewJob } from './phase-control'; +import { FRESH_INVOCATION_YIELD_SECONDS, MAX_RETRYABLE_FILE_REVIEW_FAILURES, MISSING_FILE_ERROR } from '../constants'; +import { isSubrequestBudgetError, retryableModelFailureDelaySeconds } from './retry-policy'; +import { scanRuleChannel } from './file-runner'; + + + +export function proportionalSplit(total: number, weights: number[]): number[] { + if (weights.length === 0) return []; + + const sum = weights.reduce((a, b) => a + b, 0); + const parts = sum <= 0 + ? weights.map(() => Math.floor(total / weights.length)) + : weights.map((w) => Math.floor((total * w) / sum)); + + const assigned = parts.reduce((a, b) => a + b, 0); + if (assigned < total) { + const largest = weights.indexOf(Math.max(...weights)); + parts[largest === -1 ? 0 : largest] += total - assigned; + } + return parts; +} + +export async function reviewAndPersistBin( + env: ReviewRuntime, + job: PersistedReviewJob, + files: FileDiff[], + pr: PullRequestRecord, + config: RepoConfig, + totalLineCount: number, + model: ReviewModel, + resolveFailureModelProvider: () => Promise, + rejectedExemplars: readonly RejectedExemplar[] = [], + changelogExcerpt: string | null = null, +): Promise { + const startedAt = env.clock.now(); + + const ruleScans = new Map(files.map((file) => [file.path, scanRuleChannel(file, config)])); + + const persisted = new Set(); + let terminalCount = 0; + + const failedRow = (file: FileDiff, errorMessage: string, modelProvider?: string | null): BulkFileReviewInput => ({ + filePath: file.path, + fileStatus: 'failed', + modelUsed: config.model?.main ?? 'unconfigured', + modelProvider: modelProvider ?? null, + diffLineCount: file.lineCount, + rawAiOutput: null, + parsedComments: ruleScans.get(file.path)?.comments ?? [], + inputTokens: null, + outputTokens: null, + durationMs: env.clock.now() - startedAt, + verdict: null, + fileSummary: null, + errorMessage, + batchSize: files.length, + }); + + try { + const response = await model.reviewFiles({ + files, + prTitle: pr.title ?? null, + prDescription: pr.body ?? null, + changelogExcerpt, + config, + totalLineCount, + rejectedExemplars, + }); + + const reviewed = files.filter((file) => response.batch.reviews.has(file.path)); + const weights = reviewed.map((file) => renderFileDiff(file).length); + const inputSplit = proportionalSplit(response.inputTokens, weights); + const outputSplit = proportionalSplit(response.outputTokens, weights); + const durationMs = env.clock.now() - startedAt; + + const rows: BulkFileReviewInput[] = reviewed.map((file, index) => { + const parsed = response.batch.reviews.get(file.path)!; + const rules = ruleScans.get(file.path)!; + return { + filePath: file.path, + fileStatus: 'done', + modelUsed: response.modelUsed, + modelProvider: response.provider, + diffLineCount: file.lineCount, + rawAiOutput: response.rawText, + parsedComments: [...parsed.comments, ...rules.comments], + inputTokens: inputSplit[index], + outputTokens: outputSplit[index], + durationMs, + verdict: parsed.verdict, + fileSummary: parsed.fileSummary, + overallCorrectness: parsed.overallCorrectness, + confidenceScore: parsed.confidenceScore, + errorMessage: null, + withheldCounts: { + evidence: (parsed.evidenceStats?.unmatched ?? 0) + + (parsed.evidenceStats?.absent ?? 0) + + (parsed.evidenceStats?.weak ?? 0), + claimDenied: Object.values(parsed.deniedClaimCounts ?? {}).reduce((sum, n) => sum + n, 0), + contextOnly: parsed.evidenceStats?.contextOnly ?? 0, + absenceRefuted: parsed.absenceCheckStats?.refuted ?? 0, + }, + // The whole bin shared one call, so every member inherits its degradation. + degraded: response.degraded ?? null, + batchSize: files.length, + }; + }); + + if (rows.length > 0) { + await env.fileReviews.bulkUpsertFileReviews(job.id, rows); + for (const row of rows) persisted.add(row.filePath); + terminalCount += rows.length; + } + + if (response.batch.missing.length > 0) { + const counts = await env.fileReviews.bulkRecordRetryableFileReviewFailures(job.id, response.batch.missing.map((path) => ({ + filePath: path, + modelUsed: response.modelUsed, + diffLineCount: files.find((f) => f.path === path)?.lineCount ?? 0, + errorMessage: MISSING_FILE_ERROR, + }))); + for (const count of counts) persisted.add(count.filePath); + + const exhausted = counts.filter((c) => c.transientErrorCount >= MAX_RETRYABLE_FILE_REVIEW_FAILURES); + if (exhausted.length > 0) { + await env.fileReviews.bulkUpsertFileReviews(job.id, exhausted.map((c) => failedRow( + files.find((f) => f.path === c.filePath)!, + `Review skipped after the model omitted this file ${c.transientErrorCount} times.`, + ))); + terminalCount += exhausted.length; + } + } + + logger.info('Batched file review parsed', { + jobId: job.id, + model: response.modelUsed, + binSize: files.length, + binPaths: files.map((f) => f.path), + binDiffLines: files.reduce((sum, f) => sum + f.lineCount, 0), + durationMs, + inputTokens: response.inputTokens, + outputTokens: response.outputTokens, + keptPerFile: rows.map((r) => ({ path: r.filePath, kept: r.parsedComments.length })), + entriesReturned: response.batch.stats.entriesReturned, + missingFiles: response.batch.missing, + unroutableEntries: response.batch.stats.unroutableEntries, + pathMismatchFindings: response.batch.stats.pathMismatchFindings, + ambiguousAcrossBin: response.batch.stats.ambiguousAcrossBin, + flatFallback: response.batch.stats.flatFallback, + overCap: response.batch.stats.overCap, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown batched review error'; + const modelId = config.model?.main ?? 'unconfigured'; + const modelProvider = await resolveFailureModelProvider(); + + if (isSubrequestBudgetError(error)) { + logger.warn('Batched review deferred; subrequest budget will retry in a fresh invocation', { + jobId: job.id, + paths: files.map((f) => f.path), + error: errorMessage, + }); + Object.defineProperty(error, 'retryAfterSeconds', { value: FRESH_INVOCATION_YIELD_SECONDS, configurable: true }); + throw error; + } + + const outstanding = files.filter((file) => !persisted.has(file.path)); + + if (outstanding.length === 0) { + logger.warn('Batched review hit an error after every file was persisted; keeping the committed rows', { + jobId: job.id, + paths: files.map((f) => f.path), + error: errorMessage, + }); + return terminalCount; + } + + if (env.modelErrors.isRetryableModelError(error)) { + const advancedTo = env.modelErrors.nextChainIndexOf(error); + const counts = await env.fileReviews.bulkRecordRetryableFileReviewFailures(job.id, outstanding.map((file) => ({ + filePath: file.path, + modelUsed: modelId, + diffLineCount: file.lineCount, + errorMessage, + })), { countsAsAttempt: advancedTo === null }); + + const exhausted = counts.filter((c) => c.transientErrorCount >= MAX_RETRYABLE_FILE_REVIEW_FAILURES); + if (exhausted.length > 0) { + await env.fileReviews.bulkUpsertFileReviews(job.id, exhausted.map((c) => failedRow( + files.find((f) => f.path === c.filePath)!, + `Review skipped after ${c.transientErrorCount} repeated model provider outages.`, + modelProvider, + ))); + terminalCount += exhausted.length; + logger.error('Files in a batched review failed permanently after transient retries', { + jobId: job.id, + paths: exhausted.map((c) => c.filePath), + error: errorMessage, + }); + } + + const stillRetrying = counts.filter((c) => c.transientErrorCount < MAX_RETRYABLE_FILE_REVIEW_FAILURES); + if (stillRetrying.length === 0) return terminalCount; + + logger.warn('Batched review deferred; transient model/provider failure will retry later', { + jobId: job.id, + paths: stillRetrying.map((c) => c.filePath), + error: errorMessage, + }); + Object.defineProperty(error, 'retryAfterSeconds', { + value: retryableModelFailureDelaySeconds(Math.max(...stillRetrying.map((c) => c.transientErrorCount))), + configurable: true, + }); + throw error; + } + + logger.error('Batched review failed', { jobId: job.id, paths: outstanding.map((f) => f.path), error }); + + await env.fileReviews.bulkUpsertFileReviews(job.id, outstanding.map((file) => failedRow(file, errorMessage, modelProvider))); + terminalCount += outstanding.length; + } + + return terminalCount; +} diff --git a/packages/core/src/review/budget.ts b/packages/core/src/review/budget.ts index f8e01364..f92f12fe 100644 --- a/packages/core/src/review/budget.ts +++ b/packages/core/src/review/budget.ts @@ -1,31 +1,31 @@ - -import { FILE_FIXED_SUBREQUESTS, MAX_MODEL_ATTEMPTS_ESTIMATE } from '../constants'; - -export function budgetAwareFileLimit( - remainingSafeBudget: number, - configuredChunkFileLimit: number, - modelChainLength = 1, - fetchesFileContent = false, - runsSecondaryReviewer = false, -) { - const budgetLimit = Math.floor( - remainingSafeBudget / estimatedSubrequestsPerFile(modelChainLength, fetchesFileContent, runsSecondaryReviewer), - ); - // Never zero while any budget remains: a file limit of 0 defers every file forever, and a job that - // can afford one file at a time should make progress one file at a time. - return Math.max(remainingSafeBudget > 0 ? 1 : 0, Math.min(configuredChunkFileLimit, budgetLimit)); -} - -export function estimatedSubrequestsPerFile( - modelChainLength: number, - fetchesFileContent = false, - runsSecondaryReviewer = false, -) { - const modelAttempts = Math.max(1, Math.min(modelChainLength, MAX_MODEL_ATTEMPTS_ESTIMATE)); - // A second reviewer walks its own chain, so it doubles the model half of the estimate but not the - // fixed per-file cost. Roughly halving files per invocation is the correct answer, not a problem: - // the continuation loop already carries the rest of the job into the next invocation. - return FILE_FIXED_SUBREQUESTS - + modelAttempts * (runsSecondaryReviewer ? 2 : 1) - + (fetchesFileContent ? 1 : 0); -} + +import { FILE_FIXED_SUBREQUESTS, MAX_MODEL_ATTEMPTS_ESTIMATE } from '../constants'; + +export function budgetAwareFileLimit( + remainingSafeBudget: number, + configuredChunkFileLimit: number, + modelChainLength = 1, + fetchesFileContent = false, + runsSecondaryReviewer = false, +) { + const budgetLimit = Math.floor( + remainingSafeBudget / estimatedSubrequestsPerFile(modelChainLength, fetchesFileContent, runsSecondaryReviewer), + ); + // Never zero while any budget remains: a file limit of 0 defers every file forever, and a job that + // can afford one file at a time should make progress one file at a time. + return Math.max(remainingSafeBudget > 0 ? 1 : 0, Math.min(configuredChunkFileLimit, budgetLimit)); +} + +export function estimatedSubrequestsPerFile( + modelChainLength: number, + fetchesFileContent = false, + runsSecondaryReviewer = false, +) { + const modelAttempts = Math.max(1, Math.min(modelChainLength, MAX_MODEL_ATTEMPTS_ESTIMATE)); + // A second reviewer walks its own chain, so it doubles the model half of the estimate but not the + // fixed per-file cost. Roughly halving files per invocation is the correct answer, not a problem: + // the continuation loop already carries the rest of the job into the next invocation. + return FILE_FIXED_SUBREQUESTS + + modelAttempts * (runsSecondaryReviewer ? 2 : 1) + + (fetchesFileContent ? 1 : 0); +} diff --git a/packages/core/src/review/diff-cache.ts b/packages/core/src/review/diff-cache.ts index 9c996d25..39289321 100644 --- a/packages/core/src/review/diff-cache.ts +++ b/packages/core/src/review/diff-cache.ts @@ -1,50 +1,50 @@ -import { reviewMaxFilesRange, type RepoConfig } from '@codraoss/schema'; -import { filterReviewableFiles, parseUnifiedDiff, type FileDiff } from '../diff'; -import type { ReviewGitProvider, ReviewRuntime } from '../ports'; -import { logger } from '../logger'; - -import { DIFF_CACHE_TTL_SECONDS } from '../constants'; - -export function diffCacheKey(jobId: string) { - return `diff:${jobId}`; -} - -export async function getDiffFiles( - env: Pick, - job: { id: string; owner: string; repo: string; prNumber: number }, - github: Pick, - config: RepoConfig, - maxFiles: number = reviewMaxFilesRange.default, -): Promise<{ files: FileDiff[]; skipped: number }> { - const cacheKey = diffCacheKey(job.id); - let rawDiff = await env.kv.get(cacheKey); - - if (!rawDiff) { - rawDiff = await github.getPullRequestDiff(job.owner, job.repo, job.prNumber); - try { - await env.kv.put(cacheKey, rawDiff, { expirationTtl: DIFF_CACHE_TTL_SECONDS }); - } catch (error) { - logger.warn(`Failed to cache PR diff for job ${job.id}; it will be re-fetched on the next phase`, error instanceof Error ? error : new Error(String(error))); - } - } - - return filterReviewableFiles(parseUnifiedDiff(rawDiff, config.review), config.review, maxFiles); -} - -export async function getOrFetchRawDiffForCompletedJob( - env: Pick, - job: { id: string; owner: string; repo: string; baseSha: string; commitSha: string }, - github: Pick, -): Promise { - const cacheKey = diffCacheKey(job.id); - const cached = await env.kv.get(cacheKey); - if (cached) return cached; - - const rawDiff = await github.getCompareDiff(job.owner, job.repo, job.baseSha, job.commitSha); - try { - await env.kv.put(cacheKey, rawDiff, { expirationTtl: DIFF_CACHE_TTL_SECONDS }); - } catch (error) { - logger.warn(`Failed to cache reconstructed diff for job ${job.id}`, error instanceof Error ? error : new Error(String(error))); - } - return rawDiff; -} +import { reviewMaxFilesRange, type RepoConfig } from '@codraoss/schema'; +import { filterReviewableFiles, parseUnifiedDiff, type FileDiff } from '../diff'; +import type { ReviewGitProvider, ReviewRuntime } from '../ports'; +import { logger } from '../logger'; + +import { DIFF_CACHE_TTL_SECONDS } from '../constants'; + +export function diffCacheKey(jobId: string) { + return `diff:${jobId}`; +} + +export async function getDiffFiles( + env: Pick, + job: { id: string; owner: string; repo: string; prNumber: number }, + github: Pick, + config: RepoConfig, + maxFiles: number = reviewMaxFilesRange.default, +): Promise<{ files: FileDiff[]; skipped: number }> { + const cacheKey = diffCacheKey(job.id); + let rawDiff = await env.kv.get(cacheKey); + + if (!rawDiff) { + rawDiff = await github.getPullRequestDiff(job.owner, job.repo, job.prNumber); + try { + await env.kv.put(cacheKey, rawDiff, { expirationTtl: DIFF_CACHE_TTL_SECONDS }); + } catch (error) { + logger.warn(`Failed to cache PR diff for job ${job.id}; it will be re-fetched on the next phase`, error instanceof Error ? error : new Error(String(error))); + } + } + + return filterReviewableFiles(parseUnifiedDiff(rawDiff, config.review), config.review, maxFiles); +} + +export async function getOrFetchRawDiffForCompletedJob( + env: Pick, + job: { id: string; owner: string; repo: string; baseSha: string; commitSha: string }, + github: Pick, +): Promise { + const cacheKey = diffCacheKey(job.id); + const cached = await env.kv.get(cacheKey); + if (cached) return cached; + + const rawDiff = await github.getCompareDiff(job.owner, job.repo, job.baseSha, job.commitSha); + try { + await env.kv.put(cacheKey, rawDiff, { expirationTtl: DIFF_CACHE_TTL_SECONDS }); + } catch (error) { + logger.warn(`Failed to cache reconstructed diff for job ${job.id}`, error instanceof Error ? error : new Error(String(error))); + } + return rawDiff; +} diff --git a/packages/core/src/review/file-runner.ts b/packages/core/src/review/file-runner.ts index 0753610f..0677f6d5 100644 --- a/packages/core/src/review/file-runner.ts +++ b/packages/core/src/review/file-runner.ts @@ -1,329 +1,329 @@ -import { logger } from '../logger'; -import { type ParsedReviewComment, type RepoConfig } from '@codraoss/schema'; -import { parseUnifiedDiff, type FileDiff } from '../diff'; -import { ruleHitsToComments, scanFileForRuleHits, type RuleScanStats } from '../rules/detect'; -import type { RejectedExemplar } from '../prompts/file-review'; -import type { PullRequestRecord, ReviewModel, ReviewRuntime } from '../ports'; -import { type PersistedReviewJob } from './phase-control'; -import { FRESH_INVOCATION_YIELD_SECONDS, MAX_RETRYABLE_FILE_REVIEW_FAILURES } from '../constants'; -import { isSubrequestBudgetError, retryableModelFailureDelaySeconds } from './retry-policy'; - -export async function persistCompletedReview( - env: Pick, - job: PersistedReviewJob, - file: ReturnType[number], - response: { - modelUsed: string; - provider: string; - inputTokens: number; - outputTokens: number; - rawText: string; - userPrompt: string; - parsed: { - comments: ParsedReviewComment[]; - verdict: 'approve' | 'comment'; - fileSummary: string; - overallCorrectness?: string; - confidenceScore?: number; - }; - }, -) { - await env.fileReviews.upsertFileReview(job.id, { - filePath: file.path, - fileStatus: 'done', - modelUsed: response.modelUsed, - modelProvider: response.provider, - diffLineCount: file.lineCount, - diffInput: null, - rawAiOutput: response.rawText, - parsedComments: response.parsed.comments, - inputTokens: response.inputTokens, - outputTokens: response.outputTokens, - durationMs: null, - verdict: response.parsed.verdict, - fileSummary: response.parsed.fileSummary, - overallCorrectness: response.parsed.overallCorrectness, - confidenceScore: response.parsed.confidenceScore, - errorMessage: null, - asyncRequestId: null, - asyncModel: null, - }); -} - -export async function persistFailedFileReview( - env: Pick, - jobId: string, - input: { - filePath: string; - modelUsed: string; - modelProvider?: string | null; - diffLineCount: number; - durationMs?: number | null; - errorMessage: string; - clearAsync?: boolean; - parsedComments?: ParsedReviewComment[]; - }, -) { - await env.fileReviews.upsertFileReview(jobId, { - filePath: input.filePath, - fileStatus: 'failed', - modelUsed: input.modelUsed, - modelProvider: input.modelProvider ?? null, - diffLineCount: input.diffLineCount, - diffInput: null, - rawAiOutput: null, - parsedComments: input.parsedComments ?? [], - inputTokens: null, - outputTokens: null, - durationMs: input.durationMs ?? null, - verdict: null, - fileSummary: null, - errorMessage: input.errorMessage, - ...(input.clearAsync ? { asyncRequestId: null, asyncModel: null } : {}), - }); -} - -export function scanRuleChannel( - file: FileDiff, - config: RepoConfig, -): { comments: ParsedReviewComment[]; stats: RuleScanStats | null } { - const rules = config.review.rules; - if (!rules?.enabled) return { comments: [], stats: null }; - - try { - const result = scanFileForRuleHits(file, { - disabledRuleIds: rules.disabled_rule_ids, - shadowRuleIds: rules.shadow_rule_ids, - deniedClaimTypes: config.review.deny_claim_types, - }); - return { comments: ruleHitsToComments(file, result), stats: result.stats }; - } catch (error) { - logger.warn(`Rule scan failed for ${file.path}; continuing with LLM findings only`, { - error: error instanceof Error ? error.message : String(error), - }); - return { comments: [], stats: null }; - } -} - -/** - * Marks who found what, for display only. - * - * Explicitly NOT for scoring. In the measured corpus, claims found by seven configurations were right - * 7% of the time against 20% for claims found by one -- so the fact that both reviewers found - * something is not a reason to trust it more, and this field must never become a weight. - */ -function tagReviewer(comments: ParsedReviewComment[], reviewerModel: string): ParsedReviewComment[] { - return comments.map((comment) => ({ ...comment, reviewerModel })); -} - -/** Never throws: the primary review already succeeded, and a second opinion is not worth losing it. */ -async function runSecondaryReview( - model: ReviewModel, - params: Parameters[0], - secondary: { model: string; fallbacks: string[] }, - path: string, -) { - try { - // `selectModel` reads `config.model`, so swapping it is the whole mechanism -- no second runner, - // no second chain type. `size_overrides` are deliberately not carried: the secondary is one - // deliberate choice, not a size ladder. - return await model.reviewFile({ - ...params, - config: { - ...params.config, - model: { ...params.config.model, main: secondary.model, fallbacks: secondary.fallbacks }, - }, - }); - } catch (error) { - logger.warn(`Secondary reviewer failed for ${path}; keeping the primary review`, { - model: secondary.model, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } -} - -export async function reviewAndPersistFile( - env: ReviewRuntime, - job: PersistedReviewJob, - file: ReturnType[number], - pr: PullRequestRecord, - config: RepoConfig, - totalLineCount: number, - model: ReviewModel, - resolveFailureModelProvider: () => Promise, - previousReview?: { transient_error_count: number }, - rejectedExemplars: readonly RejectedExemplar[] = [], - changelogExcerpt: string | null = null, - fileContext: string | null = null, -) { - const startedAt = env.clock.now(); - const compactPrompt = (previousReview?.transient_error_count ?? 0) > 0; - - const ruleScan = scanRuleChannel(file, config); - - try { - const reviewParams = { - file, - fileContext, - prTitle: pr.title ?? null, - prDescription: pr.body ?? null, - changelogExcerpt, - config, - totalLineCount, - compactPrompt, - rejectedExemplars, - }; - - const response = await model.reviewFile(reviewParams); - - // A second, independent reviewer over the same file. Its findings are UNIONED with the primary's: - // the measured gain from two reviewers is entirely coverage, and nothing here counts agreement. - // - // Best-effort by construction. The primary's result already exists, so a failing secondary must - // never cost the file -- it logs and the review stands on the primary alone. Skipped when - // `compactPrompt` is set, because that flag means the last attempt was already too much. - const secondary = config.model?.secondary ?? null; - const secondaryReview = secondary && !compactPrompt - ? await runSecondaryReview(model, reviewParams, secondary, file.path) - : null; - - const llmComments = [ - ...tagReviewer(response.parsed.comments, response.modelUsed), - ...(secondaryReview ? tagReviewer(secondaryReview.parsed.comments, secondaryReview.modelUsed) : []), - ]; - - await env.fileReviews.upsertFileReview(job.id, { - filePath: file.path, - fileStatus: 'done', - modelUsed: response.modelUsed, - modelProvider: response.provider, - diffLineCount: file.lineCount, - diffInput: null, - rawAiOutput: response.rawText, - // One row per file, always: `file_reviews` is unique on (job_id, file_path), and review - // inheritance, resume and finalize all assume that. The two reviewers merge into it. - parsedComments: [...llmComments, ...ruleScan.comments], - inputTokens: response.inputTokens + (secondaryReview?.inputTokens ?? 0), - outputTokens: response.outputTokens + (secondaryReview?.outputTokens ?? 0), - durationMs: env.clock.now() - startedAt, - verdict: response.parsed.verdict, - fileSummary: response.parsed.fileSummary, - overallCorrectness: response.parsed.overallCorrectness, - confidenceScore: response.parsed.confidenceScore, - errorMessage: null, - withheldCounts: { - evidence: (response.parsed.evidenceStats?.unmatched ?? 0) - + (response.parsed.evidenceStats?.absent ?? 0) - + (response.parsed.evidenceStats?.weak ?? 0), - claimDenied: Object.values(response.parsed.deniedClaimCounts ?? {}).reduce((sum, n) => sum + n, 0), - // Findings about code the diff never touched. Counted apart from the evidence gate: those are - // findings whose quote could not be found at all, these are ones that were found in the wrong - // place, and only the second number says anything about how the reviewer is misreading a PR. - contextOnly: response.parsed.evidenceStats?.contextOnly ?? 0, - // "X is missing", answered by finding X. Counted so the gate's real hit rate is visible. - absenceRefuted: response.parsed.absenceCheckStats?.refuted ?? 0, - }, - // Only logged until now, which made "how often did a review run unconstrained or truncated?" - // unanswerable without reading the logs of every job one at a time. - degraded: response.degraded ?? null, - }); - - logger.info(`File review parsed: ${file.path}`, { - jobId: job.id, - model: response.modelUsed, - kept: response.parsed.comments.length, - evidence: response.parsed.evidenceStats, - claimTypes: response.parsed.claimTypeCounts, - deniedClaims: response.parsed.deniedClaimCounts, - absenceCheck: response.parsed.absenceCheckStats, - ruleChannel: ruleScan.stats, - degraded: response.degraded, - }); - - if (response.wasPromptTruncated) { - logger.warn(`Reviewed only part of ${file.path}; findings from the remainder are missing.`, { - jobId: job.id, - model: response.modelUsed, - reviewedLineCount: response.reviewedLineCount, - diffLineCount: file.lineCount, - }); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown file review error'; - const modelId = config.model?.main ?? 'unconfigured'; - const modelProvider = await resolveFailureModelProvider(); - - if (isSubrequestBudgetError(error)) { - logger.warn(`File review deferred for ${file.path}; subrequest budget will retry in a fresh invocation`, { - error: errorMessage, - }); - Object.defineProperty(error, 'retryAfterSeconds', { - value: FRESH_INVOCATION_YIELD_SECONDS, - configurable: true, - }); - throw error; - } - - if (env.modelErrors.isRetryableModelError(error)) { - const failureCount = await env.fileReviews.recordRetryableFileReviewFailure(job.id, { - filePath: file.path, - modelUsed: modelId, - modelProvider, - diffLineCount: file.lineCount, - diffInput: null, - durationMs: env.clock.now() - startedAt, - errorMessage, - countsAsAttempt: env.modelErrors.nextChainIndexOf(error) === null, - }); - - if (failureCount >= MAX_RETRYABLE_FILE_REVIEW_FAILURES) { - const finalError = `Review skipped after ${failureCount} repeated model provider outages.`; - await persistFailedFileReview(env, job.id, { - filePath: file.path, - modelUsed: modelId, - modelProvider, - diffLineCount: file.lineCount, - durationMs: env.clock.now() - startedAt, - errorMessage: finalError, - parsedComments: ruleScan.comments, - }); - logger.error(`File review failed permanently for ${file.path} after transient retries`, { - attempts: failureCount, - error: errorMessage, - }); - return; - } - - logger.warn(`File review deferred for ${file.path}; transient model/provider failure will retry later`, { - error: errorMessage, - attempts: failureCount, - }); - Object.defineProperty(error, 'retryAfterSeconds', { - value: retryableModelFailureDelaySeconds(failureCount), - configurable: true, - }); - throw error; - } - - logger.error(`File review failed for ${file.path}`, { error }); - - const isHardLimit = - errorMessage.includes('4006') || - errorMessage.toLowerCase().includes('allocation'); - - if (isHardLimit) { - logger.warn(`File review hit hard provider allocation limit for ${file.path}, marking as failed to allow partial PR review.`, { error: errorMessage }); - } - - await persistFailedFileReview(env, job.id, { - filePath: file.path, - modelUsed: modelId, - modelProvider, - diffLineCount: file.lineCount, - durationMs: env.clock.now() - startedAt, - errorMessage, - parsedComments: ruleScan.comments, - }); - } -} +import { logger } from '../logger'; +import { type ParsedReviewComment, type RepoConfig } from '@codraoss/schema'; +import { parseUnifiedDiff, type FileDiff } from '../diff'; +import { ruleHitsToComments, scanFileForRuleHits, type RuleScanStats } from '../rules/detect'; +import type { RejectedExemplar } from '../prompts/file-review'; +import type { PullRequestRecord, ReviewModel, ReviewRuntime } from '../ports'; +import { type PersistedReviewJob } from './phase-control'; +import { FRESH_INVOCATION_YIELD_SECONDS, MAX_RETRYABLE_FILE_REVIEW_FAILURES } from '../constants'; +import { isSubrequestBudgetError, retryableModelFailureDelaySeconds } from './retry-policy'; + +export async function persistCompletedReview( + env: Pick, + job: PersistedReviewJob, + file: ReturnType[number], + response: { + modelUsed: string; + provider: string; + inputTokens: number; + outputTokens: number; + rawText: string; + userPrompt: string; + parsed: { + comments: ParsedReviewComment[]; + verdict: 'approve' | 'comment'; + fileSummary: string; + overallCorrectness?: string; + confidenceScore?: number; + }; + }, +) { + await env.fileReviews.upsertFileReview(job.id, { + filePath: file.path, + fileStatus: 'done', + modelUsed: response.modelUsed, + modelProvider: response.provider, + diffLineCount: file.lineCount, + diffInput: null, + rawAiOutput: response.rawText, + parsedComments: response.parsed.comments, + inputTokens: response.inputTokens, + outputTokens: response.outputTokens, + durationMs: null, + verdict: response.parsed.verdict, + fileSummary: response.parsed.fileSummary, + overallCorrectness: response.parsed.overallCorrectness, + confidenceScore: response.parsed.confidenceScore, + errorMessage: null, + asyncRequestId: null, + asyncModel: null, + }); +} + +export async function persistFailedFileReview( + env: Pick, + jobId: string, + input: { + filePath: string; + modelUsed: string; + modelProvider?: string | null; + diffLineCount: number; + durationMs?: number | null; + errorMessage: string; + clearAsync?: boolean; + parsedComments?: ParsedReviewComment[]; + }, +) { + await env.fileReviews.upsertFileReview(jobId, { + filePath: input.filePath, + fileStatus: 'failed', + modelUsed: input.modelUsed, + modelProvider: input.modelProvider ?? null, + diffLineCount: input.diffLineCount, + diffInput: null, + rawAiOutput: null, + parsedComments: input.parsedComments ?? [], + inputTokens: null, + outputTokens: null, + durationMs: input.durationMs ?? null, + verdict: null, + fileSummary: null, + errorMessage: input.errorMessage, + ...(input.clearAsync ? { asyncRequestId: null, asyncModel: null } : {}), + }); +} + +export function scanRuleChannel( + file: FileDiff, + config: RepoConfig, +): { comments: ParsedReviewComment[]; stats: RuleScanStats | null } { + const rules = config.review.rules; + if (!rules?.enabled) return { comments: [], stats: null }; + + try { + const result = scanFileForRuleHits(file, { + disabledRuleIds: rules.disabled_rule_ids, + shadowRuleIds: rules.shadow_rule_ids, + deniedClaimTypes: config.review.deny_claim_types, + }); + return { comments: ruleHitsToComments(file, result), stats: result.stats }; + } catch (error) { + logger.warn(`Rule scan failed for ${file.path}; continuing with LLM findings only`, { + error: error instanceof Error ? error.message : String(error), + }); + return { comments: [], stats: null }; + } +} + +/** + * Marks who found what, for display only. + * + * Explicitly NOT for scoring. In the measured corpus, claims found by seven configurations were right + * 7% of the time against 20% for claims found by one -- so the fact that both reviewers found + * something is not a reason to trust it more, and this field must never become a weight. + */ +function tagReviewer(comments: ParsedReviewComment[], reviewerModel: string): ParsedReviewComment[] { + return comments.map((comment) => ({ ...comment, reviewerModel })); +} + +/** Never throws: the primary review already succeeded, and a second opinion is not worth losing it. */ +async function runSecondaryReview( + model: ReviewModel, + params: Parameters[0], + secondary: { model: string; fallbacks: string[] }, + path: string, +) { + try { + // `selectModel` reads `config.model`, so swapping it is the whole mechanism -- no second runner, + // no second chain type. `size_overrides` are deliberately not carried: the secondary is one + // deliberate choice, not a size ladder. + return await model.reviewFile({ + ...params, + config: { + ...params.config, + model: { ...params.config.model, main: secondary.model, fallbacks: secondary.fallbacks }, + }, + }); + } catch (error) { + logger.warn(`Secondary reviewer failed for ${path}; keeping the primary review`, { + model: secondary.model, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +export async function reviewAndPersistFile( + env: ReviewRuntime, + job: PersistedReviewJob, + file: ReturnType[number], + pr: PullRequestRecord, + config: RepoConfig, + totalLineCount: number, + model: ReviewModel, + resolveFailureModelProvider: () => Promise, + previousReview?: { transient_error_count: number }, + rejectedExemplars: readonly RejectedExemplar[] = [], + changelogExcerpt: string | null = null, + fileContext: string | null = null, +) { + const startedAt = env.clock.now(); + const compactPrompt = (previousReview?.transient_error_count ?? 0) > 0; + + const ruleScan = scanRuleChannel(file, config); + + try { + const reviewParams = { + file, + fileContext, + prTitle: pr.title ?? null, + prDescription: pr.body ?? null, + changelogExcerpt, + config, + totalLineCount, + compactPrompt, + rejectedExemplars, + }; + + const response = await model.reviewFile(reviewParams); + + // A second, independent reviewer over the same file. Its findings are UNIONED with the primary's: + // the measured gain from two reviewers is entirely coverage, and nothing here counts agreement. + // + // Best-effort by construction. The primary's result already exists, so a failing secondary must + // never cost the file -- it logs and the review stands on the primary alone. Skipped when + // `compactPrompt` is set, because that flag means the last attempt was already too much. + const secondary = config.model?.secondary ?? null; + const secondaryReview = secondary && !compactPrompt + ? await runSecondaryReview(model, reviewParams, secondary, file.path) + : null; + + const llmComments = [ + ...tagReviewer(response.parsed.comments, response.modelUsed), + ...(secondaryReview ? tagReviewer(secondaryReview.parsed.comments, secondaryReview.modelUsed) : []), + ]; + + await env.fileReviews.upsertFileReview(job.id, { + filePath: file.path, + fileStatus: 'done', + modelUsed: response.modelUsed, + modelProvider: response.provider, + diffLineCount: file.lineCount, + diffInput: null, + rawAiOutput: response.rawText, + // One row per file, always: `file_reviews` is unique on (job_id, file_path), and review + // inheritance, resume and finalize all assume that. The two reviewers merge into it. + parsedComments: [...llmComments, ...ruleScan.comments], + inputTokens: response.inputTokens + (secondaryReview?.inputTokens ?? 0), + outputTokens: response.outputTokens + (secondaryReview?.outputTokens ?? 0), + durationMs: env.clock.now() - startedAt, + verdict: response.parsed.verdict, + fileSummary: response.parsed.fileSummary, + overallCorrectness: response.parsed.overallCorrectness, + confidenceScore: response.parsed.confidenceScore, + errorMessage: null, + withheldCounts: { + evidence: (response.parsed.evidenceStats?.unmatched ?? 0) + + (response.parsed.evidenceStats?.absent ?? 0) + + (response.parsed.evidenceStats?.weak ?? 0), + claimDenied: Object.values(response.parsed.deniedClaimCounts ?? {}).reduce((sum, n) => sum + n, 0), + // Findings about code the diff never touched. Counted apart from the evidence gate: those are + // findings whose quote could not be found at all, these are ones that were found in the wrong + // place, and only the second number says anything about how the reviewer is misreading a PR. + contextOnly: response.parsed.evidenceStats?.contextOnly ?? 0, + // "X is missing", answered by finding X. Counted so the gate's real hit rate is visible. + absenceRefuted: response.parsed.absenceCheckStats?.refuted ?? 0, + }, + // Only logged until now, which made "how often did a review run unconstrained or truncated?" + // unanswerable without reading the logs of every job one at a time. + degraded: response.degraded ?? null, + }); + + logger.info(`File review parsed: ${file.path}`, { + jobId: job.id, + model: response.modelUsed, + kept: response.parsed.comments.length, + evidence: response.parsed.evidenceStats, + claimTypes: response.parsed.claimTypeCounts, + deniedClaims: response.parsed.deniedClaimCounts, + absenceCheck: response.parsed.absenceCheckStats, + ruleChannel: ruleScan.stats, + degraded: response.degraded, + }); + + if (response.wasPromptTruncated) { + logger.warn(`Reviewed only part of ${file.path}; findings from the remainder are missing.`, { + jobId: job.id, + model: response.modelUsed, + reviewedLineCount: response.reviewedLineCount, + diffLineCount: file.lineCount, + }); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown file review error'; + const modelId = config.model?.main ?? 'unconfigured'; + const modelProvider = await resolveFailureModelProvider(); + + if (isSubrequestBudgetError(error)) { + logger.warn(`File review deferred for ${file.path}; subrequest budget will retry in a fresh invocation`, { + error: errorMessage, + }); + Object.defineProperty(error, 'retryAfterSeconds', { + value: FRESH_INVOCATION_YIELD_SECONDS, + configurable: true, + }); + throw error; + } + + if (env.modelErrors.isRetryableModelError(error)) { + const failureCount = await env.fileReviews.recordRetryableFileReviewFailure(job.id, { + filePath: file.path, + modelUsed: modelId, + modelProvider, + diffLineCount: file.lineCount, + diffInput: null, + durationMs: env.clock.now() - startedAt, + errorMessage, + countsAsAttempt: env.modelErrors.nextChainIndexOf(error) === null, + }); + + if (failureCount >= MAX_RETRYABLE_FILE_REVIEW_FAILURES) { + const finalError = `Review skipped after ${failureCount} repeated model provider outages.`; + await persistFailedFileReview(env, job.id, { + filePath: file.path, + modelUsed: modelId, + modelProvider, + diffLineCount: file.lineCount, + durationMs: env.clock.now() - startedAt, + errorMessage: finalError, + parsedComments: ruleScan.comments, + }); + logger.error(`File review failed permanently for ${file.path} after transient retries`, { + attempts: failureCount, + error: errorMessage, + }); + return; + } + + logger.warn(`File review deferred for ${file.path}; transient model/provider failure will retry later`, { + error: errorMessage, + attempts: failureCount, + }); + Object.defineProperty(error, 'retryAfterSeconds', { + value: retryableModelFailureDelaySeconds(failureCount), + configurable: true, + }); + throw error; + } + + logger.error(`File review failed for ${file.path}`, { error }); + + const isHardLimit = + errorMessage.includes('4006') || + errorMessage.toLowerCase().includes('allocation'); + + if (isHardLimit) { + logger.warn(`File review hit hard provider allocation limit for ${file.path}, marking as failed to allow partial PR review.`, { error: errorMessage }); + } + + await persistFailedFileReview(env, job.id, { + filePath: file.path, + modelUsed: modelId, + modelProvider, + diffLineCount: file.lineCount, + durationMs: env.clock.now() - startedAt, + errorMessage, + parsedComments: ruleScan.comments, + }); + } +} diff --git a/packages/core/src/review/finalize.ts b/packages/core/src/review/finalize.ts index ec4959f3..7673d393 100644 --- a/packages/core/src/review/finalize.ts +++ b/packages/core/src/review/finalize.ts @@ -1,324 +1,324 @@ -import { logger } from '../logger'; -import { defaultRepoConfig, type ParsedReviewComment, type RepoConfig } from '@codraoss/schema'; -import { shadowEvaluate } from '../finding-gates'; -import { getDiffFiles } from './diff-cache'; -import type { ReviewFormatter, ReviewGitProvider, ReviewModel, ReviewRuntime } from '../ports'; -import { - type PersistedReviewJob, - enqueueJobPhase, - heartbeatAndCheckSuperseded, -} from './phase-control'; -import { FRESH_INVOCATION_YIELD_SECONDS } from '../constants'; -import { sendReviewTelemetry } from './telemetry'; -import { applyFindingGates } from './gate-pipeline'; - -/** - * Why a completed review is less than the whole pull request, for the job record the dashboard reads. - * - * Files dropped by the limits count as partial too. They used to be reported only in the PR comment, - * which no longer carries that line, so without this a truncated run reports plain success -- which is - * how a 250-file pull request reviewed 25 files and said nothing. - */ -export function partialReviewMessage(input: { - failedFileCount: number; - reviewedFileCount: number; - filesOverCap: number; -}): string | null { - const plural = (n: number) => (n === 1 ? '' : 's'); - const reasons: string[] = []; - - if (input.failedFileCount > 0) { - reasons.push(`${input.failedFileCount} of ${input.reviewedFileCount} file${plural(input.reviewedFileCount)} could not be reviewed`); - } - if (input.filesOverCap > 0) { - reasons.push(`${input.filesOverCap} file${plural(input.filesOverCap)} left out by the file and diff-size limits`); - } - - return reasons.length > 0 ? `Partial review: ${reasons.join('; ')}.` : null; -} - -export async function runFinalizePhase( - env: ReviewRuntime, - job: PersistedReviewJob, - leaseOwner: string, - github: ReviewGitProvider, - formatter: ReviewFormatter, - model: ReviewModel, -) { - await env.jobs.updateJobStep(job.id, 'Generating Summary', { status: 'running' }); - - const pr = await github.getPullRequest(job.owner, job.repo, job.prNumber); - const config = (job.configSnapshot ?? defaultRepoConfig) as RepoConfig; - const reviewSettings = await env.settings.getReviewSettings(); - const [{ files, skipped: filesOverCap }, initialReviews] = await Promise.all([ - getDiffFiles(env, job, github, config, reviewSettings.maxFiles), - env.fileReviews.getFileReviewsForJobs([job.id]), - ]); - let reviews = initialReviews; - - { - const reviewedPaths = new Set(reviews.map((r) => r.file_path)); - const missingFiles = files.filter((f) => !reviewedPaths.has(f.path)); - - if (missingFiles.length > 0) { - logger.warn(`Job ${job.id} reached finalize phase with ${missingFiles.length} missing file reviews. Forcing them to failed state.`); - await env.fileReviews.bulkMarkFilesFailed( - job.id, - missingFiles.map((file) => ({ filePath: file.path, diffLineCount: file.lineCount })), - { modelUsed: config.model?.main ?? 'unconfigured', errorMessage: 'This file was not reviewed before the review run completed.' }, - ); - - reviews = await env.fileReviews.getFileReviewsForJobs([job.id]); - } else if (reviews.length < files.length) { - await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'running' }); - await enqueueJobPhase(env, job.id, 'review', FRESH_INVOCATION_YIELD_SECONDS); - return; - } - } - - await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'done' }); - - const reviewedComments = reviews.flatMap((review) => review.parsed_comments as ParsedReviewComment[]); - const fileSummaries = reviews.map((review) => ({ - path: review.file_path, - summary: review.file_status === 'failed' - ? `Review failed: ${review.error_msg ?? 'Unknown file review error'}` - : (review.file_summary ?? ''), - verdict: review.file_status === 'failed' ? 'failed' : (review.verdict ?? 'comment'), - })); - - const { concurrencyLevel, maxComments: globalMaxComments } = reviewSettings; - const effectiveMaxComments = Math.min(config.review.max_comments, globalMaxComments); - const retryCount = job.retryOfJobId ? 1 : 0; - - if (fileSummaries.length > 0 && fileSummaries.every((file) => file.verdict === 'failed')) { - await env.jobs.updateJobStep(job.id, 'Generating Summary', { status: 'failed', error: 'All files failed to review' }); - - await sendReviewTelemetry( - env, - job, - files, - reviews, - { findingsReported: 0, verdict: 'failed', severityDistribution: {} }, - { concurrencyLevel, retryCount }, - ); - - throw new Error('All files failed to review'); - } - - const hasFailures = fileSummaries.some((file) => file.verdict === 'failed'); - const failedFileCount = fileSummaries.filter((file) => file.verdict === 'failed').length; - - await env.jobs.updateJobStep(job.id, 'Verifying Findings', { status: 'running' }); - - const { - finalComments, - dispositions, - verifyReasons, - verificationSkipped, - suppressedComments, - droppedBySuppression, - beforeVerifyList, - droppedByVerification, - droppedByCap, - omittedCount, - droppedByFilters, - withheldByParser, - byClaimType, - } = await applyFindingGates({ - env, job, config, files, model, effectiveMaxComments, reviewedComments, reviews, - }); - - - logger.info('Finding pipeline outcome', { - jobId: job.id, - parsed: reviewedComments.length, - verificationSkipped, - droppedByFilters, - droppedBySuppression, - droppedByVerification, - droppedByCap, - posted: finalComments.length, - withheldByParser, - byClaimType, - byChannel: { - llm: finalComments.filter((c) => c.source !== 'rule').length, - rule: finalComments.filter((c) => c.source === 'rule').length, - }, - byRule: reviewedComments.reduce>((acc, c) => { - if (c.source === 'rule' && c.ruleId) acc[c.ruleId] = (acc[c.ruleId] ?? 0) + 1; - return acc; - }, {}), - postedAny: finalComments.length > 0, - postedPer100Files: files.length > 0 - ? Math.round((finalComments.length / files.length) * 1000) / 10 - : 0, - }); - - logger.info('Shadow filter evaluation', { - jobId: job.id, - ...shadowEvaluate(beforeVerifyList, finalComments), - }); - - // Failed on every skip reason: each one means findings were posted unverified. - await env.jobs.updateJobStep(job.id, 'Verifying Findings', verificationSkipped - ? { status: 'failed', error: `Verification did not run (${verificationSkipped}); findings were posted unverified.` } - : { status: 'done' }); - - const rawVerdict = formatter.summarizeVerdict([...finalComments, ...suppressedComments], hasFailures); - - const everythingWithheld = finalComments.length === 0 - && suppressedComments.length === 0 - && (withheldByParser > 0 || omittedCount > 0); - const verdictSummary = everythingWithheld && rawVerdict.verdict === 'approve' - ? { ...rawVerdict, verdict: 'comment' as const } - : rawVerdict; - await env.jobs.updateJobStep(job.id, 'Generating Summary', { status: 'done' }); - await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); - - const formattedSummary = formatter.formatReviewOverview({ - commitSha: pr.head.sha, - postedFindings: finalComments.length, - filesReviewed: files.length, - linesReviewed: files.reduce((sum, file) => sum + file.lineCount, 0), - withheldFindings: withheldByParser + droppedByFilters + droppedByVerification, - filesFailed: failedFileCount, - }); - - // Skipped-file counts are dashboard information, not PR content: skips have more than one cause. - if (filesOverCap > 0) { - logger.info('Some reviewable files were skipped by the file or diff-size limits', { - jobId: job.id, - filesOverCap, - reviewed: files.length, - maxFiles: reviewSettings.maxFiles, - }); - } - - const finalizeRetriedPastPost = job.steps.some( - (step) => step.name === 'Completing' && (step.status === 'running' || step.status === 'done'), - ); - await env.jobs.updateJobStep(job.id, 'Completing', { status: 'running' }); - const existingReview: { id: number; postedIndices?: number[] } | null = finalizeRetriedPastPost - ? await github.findBotReviewForCommit(job.owner, job.repo, job.prNumber, pr.head.sha, env.botUsername) - : null; - const review = existingReview ?? await github.createReview(job.owner, job.repo, job.prNumber, { - commitSha: pr.head.sha, - event: formatter.toReviewEvent(verdictSummary.verdict), - body: formattedSummary, - comments: finalComments.map(comment => ({ - path: comment.path, - line: comment.line ?? undefined, - side: 'RIGHT' as const, - position: comment.position ?? undefined, - body: formatter.formatInlineComment(comment), - })), - }); - - if (review.postedIndices && review.postedIndices.length > 0) { - const postedFingerprints = review.postedIndices - .map((index) => finalComments[index]?.fingerprint) - .filter((fingerprint): fingerprint is string => Boolean(fingerprint)); - await env.fileReviews.markCommentsPosted(job.id, postedFingerprints); - } - - // A clean pass also gets a thumbs-up on the pull request's opening post, so the author sees the - // outcome without opening the review. Best-effort: reacting is decoration, and losing it must never - // fail a job that already posted its review. GitHub returns the existing reaction on a repeat, so a - // retried finalize does not duplicate it. - if (finalComments.length === 0 && github.addIssueReaction) { - try { - await github.addIssueReaction(job.owner, job.repo, job.prNumber, '+1'); - } catch (error) { - logger.warn('Could not react to the pull request', { - jobId: job.id, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - try { - const withReasons = new Map(); - for (const fingerprint of new Set([...dispositions.keys(), ...verifyReasons.keys()])) { - withReasons.set(fingerprint, { - disposition: dispositions.get(fingerprint) ?? null, - reason: verifyReasons.get(fingerprint) ?? null, - }); - } - await env.fileReviews.markCommentDispositions(job.id, withReasons); - } catch (error) { - logger.warn('Could not record finding dispositions', { - jobId: job.id, - error: error instanceof Error ? error.message : String(error), - }); - } - - const fileInputTokens = reviews.reduce((sum, review) => sum + (review.input_tokens ?? 0), 0); - const fileOutputTokens = reviews.reduce((sum, review) => sum + (review.output_tokens ?? 0), 0); - - const severityDistribution: Record = {}; - for (const comment of finalComments) { - const sev = comment.severity || 'unknown'; - severityDistribution[sev] = (severityDistribution[sev] || 0) + 1; - } - - const partialErrorMessage = partialReviewMessage({ - failedFileCount: hasFailures ? failedFileCount : 0, - reviewedFileCount: files.length, - filesOverCap, - }); - await env.jobs.completeJob(job.id, { - verdict: verdictSummary.verdict, - fileCount: files.length, - commentCount: finalComments.length, - totalInputTokens: fileInputTokens, - totalOutputTokens: fileOutputTokens, - summaryMarkdown: formattedSummary, - reviewId: review.id, - summaryModel: null, - errorMessage: partialErrorMessage, - }); - logger.info(`Review job completed: ${job.owner}/${job.repo} PR #${job.prNumber}`); - - try { - if (job.checkRunId) { - await github.updateCheckRun(job.owner, job.repo, job.checkRunId, { - status: 'completed', - conclusion: hasFailures ? 'failure' : (verdictSummary.verdict === 'approve' ? 'success' : 'neutral'), - title: hasFailures ? 'Review partially failed' : (verdictSummary.verdict === 'approve' ? 'LGTM' : 'Comments posted'), - summary: `${finalComments.length} inline comments across ${files.length} files.${hasFailures ? ` ${failedFileCount} file${failedFileCount === 1 ? '' : 's'} could not be reviewed.` : ''}`, - }); - await env.jobs.markJobCheckRunCompleted(job.id); - } - - if (config.review.labels !== false) { - const labels = config.review.labels; - const labelMap = { - comment: { name: labels.p1, color: 'f79009' }, - approve: { name: labels.p2, color: '027a48' }, - } as const; - const label = labelMap[verdictSummary.verdict]; - - await github.removeIssueLabelsIfPresent( - job.owner, - job.repo, - job.prNumber, - [labels.p1, labels.p2, labels.p3].filter(possibleLabel => possibleLabel !== label.name), - ); - - await github.ensureLabel(job.owner, job.repo, label.name, label.color); - await github.addIssueLabels(job.owner, job.repo, job.prNumber, [label.name]); - } - } catch (error) { - logger.warn(`Post-review labels/check-run update failed for job ${job.id}; review is posted and job is completed, so leaving it best-effort`, error instanceof Error ? error : new Error(String(error))); - } - - await sendReviewTelemetry( - env, - job, - files, - reviews, - { findingsReported: finalComments.length, verdict: verdictSummary.verdict, severityDistribution }, - { concurrencyLevel, retryCount }, - ); -} +import { logger } from '../logger'; +import { defaultRepoConfig, type ParsedReviewComment, type RepoConfig } from '@codraoss/schema'; +import { shadowEvaluate } from '../finding-gates'; +import { getDiffFiles } from './diff-cache'; +import type { ReviewFormatter, ReviewGitProvider, ReviewModel, ReviewRuntime } from '../ports'; +import { + type PersistedReviewJob, + enqueueJobPhase, + heartbeatAndCheckSuperseded, +} from './phase-control'; +import { FRESH_INVOCATION_YIELD_SECONDS } from '../constants'; +import { sendReviewTelemetry } from './telemetry'; +import { applyFindingGates } from './gate-pipeline'; + +/** + * Why a completed review is less than the whole pull request, for the job record the dashboard reads. + * + * Files dropped by the limits count as partial too. They used to be reported only in the PR comment, + * which no longer carries that line, so without this a truncated run reports plain success -- which is + * how a 250-file pull request reviewed 25 files and said nothing. + */ +export function partialReviewMessage(input: { + failedFileCount: number; + reviewedFileCount: number; + filesOverCap: number; +}): string | null { + const plural = (n: number) => (n === 1 ? '' : 's'); + const reasons: string[] = []; + + if (input.failedFileCount > 0) { + reasons.push(`${input.failedFileCount} of ${input.reviewedFileCount} file${plural(input.reviewedFileCount)} could not be reviewed`); + } + if (input.filesOverCap > 0) { + reasons.push(`${input.filesOverCap} file${plural(input.filesOverCap)} left out by the file and diff-size limits`); + } + + return reasons.length > 0 ? `Partial review: ${reasons.join('; ')}.` : null; +} + +export async function runFinalizePhase( + env: ReviewRuntime, + job: PersistedReviewJob, + leaseOwner: string, + github: ReviewGitProvider, + formatter: ReviewFormatter, + model: ReviewModel, +) { + await env.jobs.updateJobStep(job.id, 'Generating Summary', { status: 'running' }); + + const pr = await github.getPullRequest(job.owner, job.repo, job.prNumber); + const config = (job.configSnapshot ?? defaultRepoConfig) as RepoConfig; + const reviewSettings = await env.settings.getReviewSettings(); + const [{ files, skipped: filesOverCap }, initialReviews] = await Promise.all([ + getDiffFiles(env, job, github, config, reviewSettings.maxFiles), + env.fileReviews.getFileReviewsForJobs([job.id]), + ]); + let reviews = initialReviews; + + { + const reviewedPaths = new Set(reviews.map((r) => r.file_path)); + const missingFiles = files.filter((f) => !reviewedPaths.has(f.path)); + + if (missingFiles.length > 0) { + logger.warn(`Job ${job.id} reached finalize phase with ${missingFiles.length} missing file reviews. Forcing them to failed state.`); + await env.fileReviews.bulkMarkFilesFailed( + job.id, + missingFiles.map((file) => ({ filePath: file.path, diffLineCount: file.lineCount })), + { modelUsed: config.model?.main ?? 'unconfigured', errorMessage: 'This file was not reviewed before the review run completed.' }, + ); + + reviews = await env.fileReviews.getFileReviewsForJobs([job.id]); + } else if (reviews.length < files.length) { + await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'running' }); + await enqueueJobPhase(env, job.id, 'review', FRESH_INVOCATION_YIELD_SECONDS); + return; + } + } + + await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'done' }); + + const reviewedComments = reviews.flatMap((review) => review.parsed_comments as ParsedReviewComment[]); + const fileSummaries = reviews.map((review) => ({ + path: review.file_path, + summary: review.file_status === 'failed' + ? `Review failed: ${review.error_msg ?? 'Unknown file review error'}` + : (review.file_summary ?? ''), + verdict: review.file_status === 'failed' ? 'failed' : (review.verdict ?? 'comment'), + })); + + const { concurrencyLevel, maxComments: globalMaxComments } = reviewSettings; + const effectiveMaxComments = Math.min(config.review.max_comments, globalMaxComments); + const retryCount = job.retryOfJobId ? 1 : 0; + + if (fileSummaries.length > 0 && fileSummaries.every((file) => file.verdict === 'failed')) { + await env.jobs.updateJobStep(job.id, 'Generating Summary', { status: 'failed', error: 'All files failed to review' }); + + await sendReviewTelemetry( + env, + job, + files, + reviews, + { findingsReported: 0, verdict: 'failed', severityDistribution: {} }, + { concurrencyLevel, retryCount }, + ); + + throw new Error('All files failed to review'); + } + + const hasFailures = fileSummaries.some((file) => file.verdict === 'failed'); + const failedFileCount = fileSummaries.filter((file) => file.verdict === 'failed').length; + + await env.jobs.updateJobStep(job.id, 'Verifying Findings', { status: 'running' }); + + const { + finalComments, + dispositions, + verifyReasons, + verificationSkipped, + suppressedComments, + droppedBySuppression, + beforeVerifyList, + droppedByVerification, + droppedByCap, + omittedCount, + droppedByFilters, + withheldByParser, + byClaimType, + } = await applyFindingGates({ + env, job, config, files, model, effectiveMaxComments, reviewedComments, reviews, + }); + + + logger.info('Finding pipeline outcome', { + jobId: job.id, + parsed: reviewedComments.length, + verificationSkipped, + droppedByFilters, + droppedBySuppression, + droppedByVerification, + droppedByCap, + posted: finalComments.length, + withheldByParser, + byClaimType, + byChannel: { + llm: finalComments.filter((c) => c.source !== 'rule').length, + rule: finalComments.filter((c) => c.source === 'rule').length, + }, + byRule: reviewedComments.reduce>((acc, c) => { + if (c.source === 'rule' && c.ruleId) acc[c.ruleId] = (acc[c.ruleId] ?? 0) + 1; + return acc; + }, {}), + postedAny: finalComments.length > 0, + postedPer100Files: files.length > 0 + ? Math.round((finalComments.length / files.length) * 1000) / 10 + : 0, + }); + + logger.info('Shadow filter evaluation', { + jobId: job.id, + ...shadowEvaluate(beforeVerifyList, finalComments), + }); + + // Failed on every skip reason: each one means findings were posted unverified. + await env.jobs.updateJobStep(job.id, 'Verifying Findings', verificationSkipped + ? { status: 'failed', error: `Verification did not run (${verificationSkipped}); findings were posted unverified.` } + : { status: 'done' }); + + const rawVerdict = formatter.summarizeVerdict([...finalComments, ...suppressedComments], hasFailures); + + const everythingWithheld = finalComments.length === 0 + && suppressedComments.length === 0 + && (withheldByParser > 0 || omittedCount > 0); + const verdictSummary = everythingWithheld && rawVerdict.verdict === 'approve' + ? { ...rawVerdict, verdict: 'comment' as const } + : rawVerdict; + await env.jobs.updateJobStep(job.id, 'Generating Summary', { status: 'done' }); + await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); + + const formattedSummary = formatter.formatReviewOverview({ + commitSha: pr.head.sha, + postedFindings: finalComments.length, + filesReviewed: files.length, + linesReviewed: files.reduce((sum, file) => sum + file.lineCount, 0), + withheldFindings: withheldByParser + droppedByFilters + droppedByVerification, + filesFailed: failedFileCount, + }); + + // Skipped-file counts are dashboard information, not PR content: skips have more than one cause. + if (filesOverCap > 0) { + logger.info('Some reviewable files were skipped by the file or diff-size limits', { + jobId: job.id, + filesOverCap, + reviewed: files.length, + maxFiles: reviewSettings.maxFiles, + }); + } + + const finalizeRetriedPastPost = job.steps.some( + (step) => step.name === 'Completing' && (step.status === 'running' || step.status === 'done'), + ); + await env.jobs.updateJobStep(job.id, 'Completing', { status: 'running' }); + const existingReview: { id: number; postedIndices?: number[] } | null = finalizeRetriedPastPost + ? await github.findBotReviewForCommit(job.owner, job.repo, job.prNumber, pr.head.sha, env.botUsername) + : null; + const review = existingReview ?? await github.createReview(job.owner, job.repo, job.prNumber, { + commitSha: pr.head.sha, + event: formatter.toReviewEvent(verdictSummary.verdict), + body: formattedSummary, + comments: finalComments.map(comment => ({ + path: comment.path, + line: comment.line ?? undefined, + side: 'RIGHT' as const, + position: comment.position ?? undefined, + body: formatter.formatInlineComment(comment), + })), + }); + + if (review.postedIndices && review.postedIndices.length > 0) { + const postedFingerprints = review.postedIndices + .map((index) => finalComments[index]?.fingerprint) + .filter((fingerprint): fingerprint is string => Boolean(fingerprint)); + await env.fileReviews.markCommentsPosted(job.id, postedFingerprints); + } + + // A clean pass also gets a thumbs-up on the pull request's opening post, so the author sees the + // outcome without opening the review. Best-effort: reacting is decoration, and losing it must never + // fail a job that already posted its review. GitHub returns the existing reaction on a repeat, so a + // retried finalize does not duplicate it. + if (finalComments.length === 0 && github.addIssueReaction) { + try { + await github.addIssueReaction(job.owner, job.repo, job.prNumber, '+1'); + } catch (error) { + logger.warn('Could not react to the pull request', { + jobId: job.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + try { + const withReasons = new Map(); + for (const fingerprint of new Set([...dispositions.keys(), ...verifyReasons.keys()])) { + withReasons.set(fingerprint, { + disposition: dispositions.get(fingerprint) ?? null, + reason: verifyReasons.get(fingerprint) ?? null, + }); + } + await env.fileReviews.markCommentDispositions(job.id, withReasons); + } catch (error) { + logger.warn('Could not record finding dispositions', { + jobId: job.id, + error: error instanceof Error ? error.message : String(error), + }); + } + + const fileInputTokens = reviews.reduce((sum, review) => sum + (review.input_tokens ?? 0), 0); + const fileOutputTokens = reviews.reduce((sum, review) => sum + (review.output_tokens ?? 0), 0); + + const severityDistribution: Record = {}; + for (const comment of finalComments) { + const sev = comment.severity || 'unknown'; + severityDistribution[sev] = (severityDistribution[sev] || 0) + 1; + } + + const partialErrorMessage = partialReviewMessage({ + failedFileCount: hasFailures ? failedFileCount : 0, + reviewedFileCount: files.length, + filesOverCap, + }); + await env.jobs.completeJob(job.id, { + verdict: verdictSummary.verdict, + fileCount: files.length, + commentCount: finalComments.length, + totalInputTokens: fileInputTokens, + totalOutputTokens: fileOutputTokens, + summaryMarkdown: formattedSummary, + reviewId: review.id, + summaryModel: null, + errorMessage: partialErrorMessage, + }); + logger.info(`Review job completed: ${job.owner}/${job.repo} PR #${job.prNumber}`); + + try { + if (job.checkRunId) { + await github.updateCheckRun(job.owner, job.repo, job.checkRunId, { + status: 'completed', + conclusion: hasFailures ? 'failure' : (verdictSummary.verdict === 'approve' ? 'success' : 'neutral'), + title: hasFailures ? 'Review partially failed' : (verdictSummary.verdict === 'approve' ? 'LGTM' : 'Comments posted'), + summary: `${finalComments.length} inline comments across ${files.length} files.${hasFailures ? ` ${failedFileCount} file${failedFileCount === 1 ? '' : 's'} could not be reviewed.` : ''}`, + }); + await env.jobs.markJobCheckRunCompleted(job.id); + } + + if (config.review.labels !== false) { + const labels = config.review.labels; + const labelMap = { + comment: { name: labels.p1, color: 'f79009' }, + approve: { name: labels.p2, color: '027a48' }, + } as const; + const label = labelMap[verdictSummary.verdict]; + + await github.removeIssueLabelsIfPresent( + job.owner, + job.repo, + job.prNumber, + [labels.p1, labels.p2, labels.p3].filter(possibleLabel => possibleLabel !== label.name), + ); + + await github.ensureLabel(job.owner, job.repo, label.name, label.color); + await github.addIssueLabels(job.owner, job.repo, job.prNumber, [label.name]); + } + } catch (error) { + logger.warn(`Post-review labels/check-run update failed for job ${job.id}; review is posted and job is completed, so leaving it best-effort`, error instanceof Error ? error : new Error(String(error))); + } + + await sendReviewTelemetry( + env, + job, + files, + reviews, + { findingsReported: finalComments.length, verdict: verdictSummary.verdict, severityDistribution }, + { concurrencyLevel, retryCount }, + ); +} diff --git a/packages/core/src/review/gate-pipeline.ts b/packages/core/src/review/gate-pipeline.ts index d6b49a36..46eed86f 100644 --- a/packages/core/src/review/gate-pipeline.ts +++ b/packages/core/src/review/gate-pipeline.ts @@ -1,160 +1,160 @@ -import { dedupeFindings } from '../model-output'; -import { verifyFindings } from '../finding-gates'; -import type { FindingDisposition, ParsedReviewComment, RepoConfig } from '@codraoss/schema'; -import type { FileDiff } from '../diff'; -import type { PersistedReviewJob } from './phase-control'; -import type { ReviewModel, ReviewRuntime } from '../ports'; -import { loadSuppressedFingerprints } from './telemetry'; -import { reviewBreadth } from '../prompts/file-review'; -import { getLanguageForFile } from '../prompts/languages'; - -export async function applyFindingGates(params: { - env: Pick; - job: PersistedReviewJob; - config: RepoConfig; - files: FileDiff[]; - model: Pick; - effectiveMaxComments: number; - reviewedComments: ParsedReviewComment[]; - reviews: Array<{ withheld_counts?: { evidence?: number; claimDenied?: number; contextOnly?: number; absenceRefuted?: number } | null }>; -}) { - const { env, job, config, files, model, effectiveMaxComments, reviewedComments, reviews } = params; - - const severityRanks: Record = { P0: 0, P1: 1, P2: 2, P3: 3, nit: 4 }; - const minRank = severityRanks[config.review.min_severity] ?? 4; - const minConfidence = config.review.min_confidence ?? 0; - - // Precision varies 5.8x by language in the measured corpus, and until now every gate was global. - // Resolved per finding from its own path, so a mixed-language pull request is judged per file rather - // than by whatever the repo is mostly written in. - const languageGates = config.review.language_gates ?? {}; - const gatesByLanguage = new Map( - Object.entries(languageGates).map(([language, gate]) => [language.toLowerCase(), gate]), - ); - const thresholdsFor = (path: string) => { - if (gatesByLanguage.size === 0) return { minRank, minConfidence }; - const language = getLanguageForFile(path)?.language; - const override = language ? gatesByLanguage.get(language.toLowerCase()) : undefined; - if (!override) return { minRank, minConfidence }; - return { - minRank: override.min_severity ? (severityRanks[override.min_severity] ?? 4) : minRank, - minConfidence: override.min_confidence ?? minConfidence, - }; - }; - - const dispositions = new Map(); - const verifyReasons = new Map(); - const recordDisposition = (comments: ParsedReviewComment[], stage: FindingDisposition) => { - for (const comment of comments) { - if (comment.fingerprint && !dispositions.has(comment.fingerprint)) { - dispositions.set(comment.fingerprint, stage); - } - } - }; - - let finalComments = reviewedComments.filter((c) => { - const thresholds = thresholdsFor(c.path); - if ((severityRanks[c.severity] ?? 4) > thresholds.minRank) { - recordDisposition([c], 'severity'); - return false; - } - if (typeof c.confidenceScore === 'number' && c.confidenceScore < thresholds.minConfidence) { - recordDisposition([c], 'confidence'); - return false; - } - return true; - }); - - const suppressed = await loadSuppressedFingerprints(env, job.id); - const suppressedComments: ParsedReviewComment[] = []; - const hasSuppressionData = suppressed.rejected.size > 0 || suppressed.posted.size > 0 - || suppressed.rejectedV2.size > 0 || suppressed.postedV2.size > 0; - if (hasSuppressionData) { - finalComments = finalComments.filter((c) => { - const rejected = (c.fingerprint && suppressed.rejected.has(c.fingerprint)) - || (c.fingerprintV2 && suppressed.rejectedV2.has(c.fingerprintV2)); - - const anchors = c.fingerprint ? suppressed.posted.get(c.fingerprint) : undefined; - const alreadyPosted = (anchors && c.anchorHash && anchors.has(c.anchorHash)) - || (c.fingerprintV2 && suppressed.postedV2.has(c.fingerprintV2)); - - if (rejected || alreadyPosted) { - suppressedComments.push(c); - return false; - } - return true; - }); - } - const droppedBySuppression = suppressedComments.length; - recordDisposition(suppressedComments, 'suppression'); - - const beforeDedupe = finalComments; - finalComments = dedupeFindings(finalComments); - const survivedDedupe = new Set(finalComments); - recordDisposition(beforeDedupe.filter((c) => !survivedDedupe.has(c)), 'dedupe'); - - finalComments.sort((a, b) => { - const rankDiff = (severityRanks[a.severity] ?? 4) - (severityRanks[b.severity] ?? 4); - if (rankDiff !== 0) return rankDiff; - return (b.confidenceScore ?? 0) - (a.confidenceScore ?? 0); - }); - - const beforeVerifyList = finalComments; - const verify = await verifyFindings({ job, config, files, comments: finalComments, model, maxCandidates: reviewBreadth(config.review) }); - finalComments = verify.comments; - const droppedByVerification = verify.dropped.length; - for (const drop of verify.dropped) recordDisposition([drop.comment], drop.disposition); - for (const [comment, reason] of verify.reasons) { - if (comment.fingerprint) verifyReasons.set(comment.fingerprint, reason); - } - - const beforeCapList = finalComments; - const beforeCap = finalComments.length; - if (finalComments.length > effectiveMaxComments) { - finalComments = finalComments.slice(0, effectiveMaxComments); - } - const droppedByCap = beforeCap - finalComments.length; - recordDisposition(beforeCapList.slice(effectiveMaxComments), 'cap'); - const omittedCount = reviewedComments.length - finalComments.length; - const droppedByFilters = omittedCount - droppedBySuppression - droppedByVerification - droppedByCap; - - const withheldByParser = reviews.reduce( - (sum, review) => sum - + (review.withheld_counts?.evidence ?? 0) - + (review.withheld_counts?.claimDenied ?? 0) - // Counted here too, or a file whose findings were ALL about untouched code looks like a file - // with nothing to say, and `everythingWithheld` lets the PR be approved silently. - + (review.withheld_counts?.contextOnly ?? 0) - + (review.withheld_counts?.absenceRefuted ?? 0), - 0, - ); - - const byClaimType: Record = {}; - for (const comment of reviewedComments) { - const key = comment.claimType ?? 'unlabelled'; - byClaimType[key] ??= { generated: 0, posted: 0 }; - byClaimType[key].generated += 1; - } - for (const comment of finalComments) { - const key = comment.claimType ?? 'unlabelled'; - byClaimType[key] ??= { generated: 0, posted: 0 }; - byClaimType[key].posted += 1; - } - return { - finalComments, - dispositions, - verifyReasons, - // Non-null means these findings were never checked. The caller records it on the job, so a review - // that skipped verification stops looking identical to one that passed it. - verificationSkipped: verify.skipped, - suppressedComments, - droppedBySuppression, - beforeVerifyList, - droppedByVerification, - droppedByCap, - omittedCount, - droppedByFilters, - withheldByParser, - byClaimType, - }; -} +import { dedupeFindings } from '../model-output'; +import { verifyFindings } from '../finding-gates'; +import type { FindingDisposition, ParsedReviewComment, RepoConfig } from '@codraoss/schema'; +import type { FileDiff } from '../diff'; +import type { PersistedReviewJob } from './phase-control'; +import type { ReviewModel, ReviewRuntime } from '../ports'; +import { loadSuppressedFingerprints } from './telemetry'; +import { reviewBreadth } from '../prompts/file-review'; +import { getLanguageForFile } from '../prompts/languages'; + +export async function applyFindingGates(params: { + env: Pick; + job: PersistedReviewJob; + config: RepoConfig; + files: FileDiff[]; + model: Pick; + effectiveMaxComments: number; + reviewedComments: ParsedReviewComment[]; + reviews: Array<{ withheld_counts?: { evidence?: number; claimDenied?: number; contextOnly?: number; absenceRefuted?: number } | null }>; +}) { + const { env, job, config, files, model, effectiveMaxComments, reviewedComments, reviews } = params; + + const severityRanks: Record = { P0: 0, P1: 1, P2: 2, P3: 3, nit: 4 }; + const minRank = severityRanks[config.review.min_severity] ?? 4; + const minConfidence = config.review.min_confidence ?? 0; + + // Precision varies 5.8x by language in the measured corpus, and until now every gate was global. + // Resolved per finding from its own path, so a mixed-language pull request is judged per file rather + // than by whatever the repo is mostly written in. + const languageGates = config.review.language_gates ?? {}; + const gatesByLanguage = new Map( + Object.entries(languageGates).map(([language, gate]) => [language.toLowerCase(), gate]), + ); + const thresholdsFor = (path: string) => { + if (gatesByLanguage.size === 0) return { minRank, minConfidence }; + const language = getLanguageForFile(path)?.language; + const override = language ? gatesByLanguage.get(language.toLowerCase()) : undefined; + if (!override) return { minRank, minConfidence }; + return { + minRank: override.min_severity ? (severityRanks[override.min_severity] ?? 4) : minRank, + minConfidence: override.min_confidence ?? minConfidence, + }; + }; + + const dispositions = new Map(); + const verifyReasons = new Map(); + const recordDisposition = (comments: ParsedReviewComment[], stage: FindingDisposition) => { + for (const comment of comments) { + if (comment.fingerprint && !dispositions.has(comment.fingerprint)) { + dispositions.set(comment.fingerprint, stage); + } + } + }; + + let finalComments = reviewedComments.filter((c) => { + const thresholds = thresholdsFor(c.path); + if ((severityRanks[c.severity] ?? 4) > thresholds.minRank) { + recordDisposition([c], 'severity'); + return false; + } + if (typeof c.confidenceScore === 'number' && c.confidenceScore < thresholds.minConfidence) { + recordDisposition([c], 'confidence'); + return false; + } + return true; + }); + + const suppressed = await loadSuppressedFingerprints(env, job.id); + const suppressedComments: ParsedReviewComment[] = []; + const hasSuppressionData = suppressed.rejected.size > 0 || suppressed.posted.size > 0 + || suppressed.rejectedV2.size > 0 || suppressed.postedV2.size > 0; + if (hasSuppressionData) { + finalComments = finalComments.filter((c) => { + const rejected = (c.fingerprint && suppressed.rejected.has(c.fingerprint)) + || (c.fingerprintV2 && suppressed.rejectedV2.has(c.fingerprintV2)); + + const anchors = c.fingerprint ? suppressed.posted.get(c.fingerprint) : undefined; + const alreadyPosted = (anchors && c.anchorHash && anchors.has(c.anchorHash)) + || (c.fingerprintV2 && suppressed.postedV2.has(c.fingerprintV2)); + + if (rejected || alreadyPosted) { + suppressedComments.push(c); + return false; + } + return true; + }); + } + const droppedBySuppression = suppressedComments.length; + recordDisposition(suppressedComments, 'suppression'); + + const beforeDedupe = finalComments; + finalComments = dedupeFindings(finalComments); + const survivedDedupe = new Set(finalComments); + recordDisposition(beforeDedupe.filter((c) => !survivedDedupe.has(c)), 'dedupe'); + + finalComments.sort((a, b) => { + const rankDiff = (severityRanks[a.severity] ?? 4) - (severityRanks[b.severity] ?? 4); + if (rankDiff !== 0) return rankDiff; + return (b.confidenceScore ?? 0) - (a.confidenceScore ?? 0); + }); + + const beforeVerifyList = finalComments; + const verify = await verifyFindings({ job, config, files, comments: finalComments, model, maxCandidates: reviewBreadth(config.review) }); + finalComments = verify.comments; + const droppedByVerification = verify.dropped.length; + for (const drop of verify.dropped) recordDisposition([drop.comment], drop.disposition); + for (const [comment, reason] of verify.reasons) { + if (comment.fingerprint) verifyReasons.set(comment.fingerprint, reason); + } + + const beforeCapList = finalComments; + const beforeCap = finalComments.length; + if (finalComments.length > effectiveMaxComments) { + finalComments = finalComments.slice(0, effectiveMaxComments); + } + const droppedByCap = beforeCap - finalComments.length; + recordDisposition(beforeCapList.slice(effectiveMaxComments), 'cap'); + const omittedCount = reviewedComments.length - finalComments.length; + const droppedByFilters = omittedCount - droppedBySuppression - droppedByVerification - droppedByCap; + + const withheldByParser = reviews.reduce( + (sum, review) => sum + + (review.withheld_counts?.evidence ?? 0) + + (review.withheld_counts?.claimDenied ?? 0) + // Counted here too, or a file whose findings were ALL about untouched code looks like a file + // with nothing to say, and `everythingWithheld` lets the PR be approved silently. + + (review.withheld_counts?.contextOnly ?? 0) + + (review.withheld_counts?.absenceRefuted ?? 0), + 0, + ); + + const byClaimType: Record = {}; + for (const comment of reviewedComments) { + const key = comment.claimType ?? 'unlabelled'; + byClaimType[key] ??= { generated: 0, posted: 0 }; + byClaimType[key].generated += 1; + } + for (const comment of finalComments) { + const key = comment.claimType ?? 'unlabelled'; + byClaimType[key] ??= { generated: 0, posted: 0 }; + byClaimType[key].posted += 1; + } + return { + finalComments, + dispositions, + verifyReasons, + // Non-null means these findings were never checked. The caller records it on the job, so a review + // that skipped verification stops looking identical to one that passed it. + verificationSkipped: verify.skipped, + suppressedComments, + droppedBySuppression, + beforeVerifyList, + droppedByVerification, + droppedByCap, + omittedCount, + droppedByFilters, + withheldByParser, + byClaimType, + }; +} diff --git a/packages/core/src/review/index.ts b/packages/core/src/review/index.ts index 3ec08bf6..5df2f14d 100644 --- a/packages/core/src/review/index.ts +++ b/packages/core/src/review/index.ts @@ -1,363 +1,363 @@ -import { logger } from '../logger'; -import { type WebhookPayload, type ChangeRequestWebhookPayload } from '@codraoss/schema/webhook'; -import { REVIEW_CONCURRENCY_LIMITS, type ReviewJobMessage } from '@codraoss/schema'; -import type { ReviewGitProvider, ReviewRuntime } from '../ports'; -import { extractReviewRequest } from './request'; - -export { getDiffFiles, getOrFetchRawDiffForCompletedJob } from './diff-cache'; - -export { budgetAwareFileLimit, estimatedSubrequestsPerFile } from './budget'; - -export { - narrowUnit, - planReviewUnits, - unitFiles, - type LedgerEntry, - type ReviewUnit, -} from './pack'; - -export { - BIN_DIFF_CHAR_BUDGET, - BIN_MAX_FILES, - BIN_TARGET_DIFF_LINES, - PACKABLE_MAX_DIFF_LINES, -} from '../constants'; - -export { proportionalSplit } from './bin-runner'; - -export { verifyFindings, type VerifyDrop, type VerifyOutcome } from '../finding-gates'; - -export { extractReviewRequest, type ReviewRequest } from './request'; - -// workflows/review.ts floors its inter-phase sleep here; the eslint barrel guard stops it -export { FRESH_INVOCATION_YIELD_SECONDS } from '../constants'; - -import { - type PersistedReviewJob, - NextPhaseError, - failJobAndCheckRun, -} from './phase-control'; -import { - BUSY_RETRY_SECONDS, - FRESH_INVOCATION_YIELD_SECONDS, - JOB_LEASE_SECONDS, - MAX_FINALIZE_CONTINUATIONS, - MAX_JOB_CONTINUATIONS, -} from '../constants'; -import { getRetryableModelFailureDelaySeconds, isAwaitingAsyncReview, isSubrequestBudgetError } from './retry-policy'; -import { persistFailedFileReview } from './file-runner'; -import { runPreparePhase } from './prepare'; -import { runReviewPhase } from './phase'; -import { runFinalizePhase } from './finalize'; - -export { NextPhaseError, failJobAndCheckRun }; - -export type ReviewJobRunResult = - | { action: 'ack' } - | { action: 'retry'; delaySeconds: number } - | { action: 'next_phase'; phase: 'prepare' | 'review' | 'finalize'; delaySeconds: number; jobId?: string; freshInstance?: boolean }; - -/** - * The engine's entrypoint. Runs EXACTLY ONE phase of a review job and returns what the caller should - * do next; the caller owns the loop. - * - * Deliberately not a loop. Every `next_phase` result exists because the next phase needs a fresh - * host invocation to get a clean subrequest budget, and only the driver can hibernate long enough to - * produce one (see FRESH_INVOCATION_YIELD_SECONDS in ./phase-control). A loop in here would run the - * next phase on the current one's spent budget while its TokenTracker restarted at zero -- the exact - * failure that constant was introduced to fix. - * - * Contract for a driver: - * - 'ack': the job is finished or not ours. Stop. - * - 'retry': re-deliver the SAME message after `delaySeconds`. Admission was throttled or the lease - * is held elsewhere; no work happened. - * - 'next_phase': re-invoke with `{ jobId, phase }` after `delaySeconds`. `freshInstance` means the - * delay must be long enough to actually hibernate, not merely to wait. - * - * Safe to call repeatedly for the same job: it claims a lease first, and every phase is idempotent - * enough to resume. It throws only on a programming error -- job failures are recorded and acked. - */ -export async function runReview(env: ReviewRuntime, message: ReviewJobMessage): Promise { - const resolved = await resolveQueuedJob(env, message); - if (!resolved) { - return { action: 'ack' }; - } - - if (resolved.job.status === 'queued') { - const { concurrencyLevel } = await env.settings.getReviewSettings(); - const maxConcurrentJobs = REVIEW_CONCURRENCY_LIMITS[concurrencyLevel]; - const runningCount = await env.jobs.getOtherRunningJobsCount(resolved.job.id); - if (runningCount >= maxConcurrentJobs) { - logger.info(`Throttling admission of job ${resolved.job.id}: ${runningCount} other jobs are currently running.`); - return { action: 'retry', delaySeconds: 30 }; - } - } - - const leaseOwner = env.ids.randomUUID(); - const claim = await env.jobs.claimJobLease(resolved.job.id, leaseOwner, JOB_LEASE_SECONDS); - if (claim.status === 'missing') { - logger.warn(`Job not found for processing: ${resolved.job.id}`); - return { action: 'ack' }; - } - if (claim.status === 'terminal') { - logger.info(`Job ${resolved.job.id} is already terminal (${claim.row.status}), acking queue delivery.`); - return { action: 'ack' }; - } - if (claim.status === 'busy') { - logger.info(`Job ${resolved.job.id} has a fresh lease; retrying queue delivery later.`); - return { action: 'retry', delaySeconds: Math.min(BUSY_RETRY_SECONDS, claim.retryAfterSeconds) }; - } - - const job = env.jobs.mapJob(claim.row); - - if (message.workflowInstanceId && job.workflowInstanceId !== message.workflowInstanceId) { - try { - await env.jobs.setJobWorkflowInstance(job.id, message.workflowInstanceId); - } catch (error) { - logger.warn(`Failed to bind workflow instance id for job ${job.id}`, error instanceof Error ? error : new Error(String(error))); - } - } - - const phase = resolved.phase; - const tracker = env.createTokenTracker(); - const github = env.createGitHub(job.installationId, tracker); - const model = env.createModel(job.id, tracker); - const formatter = env.createFormatter(); - - try { - if (phase === 'prepare') { - await runPreparePhase(env, job, leaseOwner, github); - } else if (phase === 'finalize') { - await runFinalizePhase(env, job, leaseOwner, github, formatter, model); - } else { - await runReviewPhase(env, job, leaseOwner, github, model, tracker); - } - - await env.jobs.releaseJobLease(job.id, leaseOwner); - return { action: 'ack' }; - } catch (error) { - const messageText = error instanceof Error ? error.message : 'Unknown review failure'; - if (messageText === 'JOB_SUPERSEDED') { - logger.info(`Job ${job.id} was superseded during execution, stopping.`); - await env.jobs.releaseJobLease(job.id, leaseOwner); - return { action: 'ack' }; - } - - if (error instanceof NextPhaseError) { - await env.jobs.releaseJobLease(job.id, leaseOwner); - return { action: 'next_phase', phase: error.phase, delaySeconds: error.delaySeconds, jobId: job.id, freshInstance: error.phase === 'finalize' }; - } - - if (env.modelErrors.isRetryableModelError(error)) { - const delaySeconds = getRetryableModelFailureDelaySeconds(error); - logger.warn(`Review job hit transient model/provider failure; scheduling delayed continuation: ${job.owner}/${job.repo} PR #${job.prNumber}`, { - error: messageText, - phase, - delaySeconds, - }); - return continueOrFailWedgedJob(env, job, github, leaseOwner, phase, delaySeconds, 'transient model/provider failures'); - } - - if (isSubrequestBudgetError(error)) { - const record = error && typeof error === 'object' ? error as { retryAfterSeconds?: unknown } : null; - const delaySeconds = typeof record?.retryAfterSeconds === 'number' - ? record.retryAfterSeconds - : FRESH_INVOCATION_YIELD_SECONDS; - logger.warn(`Review job hit the per-invocation subrequest limit; rescheduling ${phase} on a fresh budget: ${job.owner}/${job.repo} PR #${job.prNumber}`, { - error: messageText, - phase, - delaySeconds, - }); - return continueOrFailWedgedJob(env, job, github, leaseOwner, phase, delaySeconds, 'per-invocation subrequest limits'); - } - - console.error('JOB FAILED WITH ERROR:', error); - logger.error(`Review job failed: ${job.owner}/${job.repo} PR #${job.prNumber}`, error); - await failJobAndCheckRun(env, job, github, messageText); - await env.jobs.releaseJobLease(job.id, leaseOwner); - return { action: 'ack' }; - } -} - -async function continueOrFailWedgedJob( - env: ReviewRuntime, - job: PersistedReviewJob, - github: ReviewGitProvider, - leaseOwner: string, - phase: 'prepare' | 'review' | 'finalize', - delaySeconds: number, - reason: string, -): Promise { - const continuationCount = await env.jobs.markJobContinuationQueued(job.id, delaySeconds); - - const ceiling = phase === 'finalize' ? MAX_FINALIZE_CONTINUATIONS : MAX_JOB_CONTINUATIONS; - - if (continuationCount > ceiling) { - if (phase === 'review') { - logger.error(`Review job exceeded the continuation ceiling; degrading to a partial review: ${job.owner}/${job.repo} PR #${job.prNumber}`, { - phase, - continuationCount, - reason, - }); - const stillPending = (await env.fileReviews.getFileReviewsForJobs([job.id])).filter(isAwaitingAsyncReview); - for (const review of stillPending) { - await persistFailedFileReview(env, job.id, { - filePath: review.file_path, - modelUsed: review.async_model ?? review.model_used, - diffLineCount: review.diff_line_count, - errorMessage: 'Async batch review did not complete before the job wedged.', - clearAsync: true, - }); - } - await env.jobs.resetJobContinuationCount(job.id); - await env.jobs.releaseJobLease(job.id, leaseOwner); - return { action: 'next_phase', phase: 'finalize', delaySeconds: FRESH_INVOCATION_YIELD_SECONDS, jobId: job.id, freshInstance: true }; - } else { - const message = `Review could not make progress after ${continuationCount} continuation attempts (${reason}). Failing the job to avoid an endless retry loop; re-run it once the underlying provider issue clears.`; - logger.error(`Review job exceeded the continuation ceiling; failing terminally: ${job.owner}/${job.repo} PR #${job.prNumber}`, { - phase, - continuationCount, - reason, - }); - await failJobAndCheckRun(env, job, github, message); - await env.jobs.releaseJobLease(job.id, leaseOwner); - return { action: 'ack' }; - } - } - - await env.jobs.releaseJobLease(job.id, leaseOwner); - const freshInstance = reason.includes('subrequest'); - return { action: 'next_phase', phase, delaySeconds, jobId: job.id, freshInstance }; -} - -async function resolveQueuedJob( - env: ReviewRuntime, - message: ReviewJobMessage, -): Promise<{ job: PersistedReviewJob; phase: 'prepare' | 'review' | 'finalize' } | null> { - if (message.jobId) { - const row = await env.jobs.getJobForProcessing(message.jobId); - return row ? { job: env.jobs.mapJob(row), phase: message.phase ?? 'review' } : null; - } - - if (!message.eventName) { - logger.warn('Queue message ignored: missing eventName'); - return null; - } - - let eventName = message.eventName; - let payload = message.payload as WebhookPayload | undefined; - - if (payload === undefined) { - const delivery = await env.webhooks.getWebhookDelivery(message.deliveryId); - if (!delivery) { - logger.warn(`Queue message ignored: webhook delivery not found: ${message.deliveryId}`); - return null; - } - - eventName = delivery.event_name; - payload = delivery.payload as WebhookPayload; - } - - if (eventName !== 'change_request' && eventName !== 'comment') { - logger.info(`Queue message ignored: unsupported webhook event ${eventName}`); - return null; - } - - const installationId = String(payload.installationId ?? ''); - if (!installationId || !('repository' in payload) || !payload.repository) { - logger.info('Queue message ignored: missing installation or repository info'); - return null; - } - - const repoConfig = await env.repoConfig.loadRepoConfig({ - installationId, - owner: payload.repository.owner, - repo: payload.repository.name, - }); - - if (repoConfig.enabled === false) { - logger.info(`Job ignored: repository ${payload.repository.owner}/${payload.repository.name} is disabled`); - return null; - } - - const extracted = extractReviewRequest({ - eventName, - payload, - botUsername: env.botUsername, - config: repoConfig.parsedJson, - }); - - if (!extracted) { - if (eventName === 'change_request') { - const prPayload = payload as ChangeRequestWebhookPayload; - if (prPayload.action === 'closed' && repoConfig.parsedJson.review.labels !== false) { - const labels = repoConfig.parsedJson.review.labels; - const gh = env.githubClients.forInstallation(installationId); - await gh.removeIssueLabelsIfPresent( - prPayload.repository.owner, - prPayload.repository.name, - prPayload.changeRequest.number, - [labels.p1, labels.p2, labels.p3], - ); - } - } - return null; - } - - let resolved = extracted; - const githubClient = env.githubClients.forInstallation(installationId); - if (eventName === 'comment') { - const pr = await githubClient.getPullRequest(extracted.owner, extracted.repo, extracted.prNumber); - resolved = { - ...extracted, - prTitle: pr.title, - prAuthor: pr.user.login, - commitSha: pr.head.sha, - baseSha: pr.base.sha, - headRef: pr.head.ref, - baseRef: pr.base.ref, - }; - } - - const duplicateJob = await env.jobs.findExistingJobForHead({ - owner: resolved.owner, - repo: resolved.repo, - prNumber: resolved.prNumber, - commitSha: resolved.commitSha, - trigger: resolved.trigger, - }); - if (duplicateJob) { - if (duplicateJob.status === 'queued' || duplicateJob.status === 'running') { - logger.info(`Resuming duplicate in-flight job ${duplicateJob.id} for ${resolved.owner}/${resolved.repo} PR #${resolved.prNumber}.`); - return { job: duplicateJob, phase: message.phase ?? 'prepare' }; - } - - logger.info(`Duplicate terminal job found for ${resolved.owner}/${resolved.repo} PR #${resolved.prNumber}, skipping.`); - return null; - } - - const job = await env.jobs.insertJob({ - installationId: resolved.installationId, - owner: resolved.owner, - repo: resolved.repo, - prNumber: resolved.prNumber, - prTitle: resolved.prTitle, - prAuthor: resolved.prAuthor, - commitSha: resolved.commitSha, - baseSha: resolved.baseSha, - trigger: resolved.trigger, - headRef: resolved.headRef, - baseRef: resolved.baseRef, - configSnapshot: repoConfig.parsedJson, - }); - - await env.jobs.supersedeOlderJobs({ - installationId: resolved.installationId, - owner: resolved.owner, - repo: resolved.repo, - prNumber: resolved.prNumber, - newJobId: job.id, - }); - - return { job, phase: 'prepare' }; -} +import { logger } from '../logger'; +import { type WebhookPayload, type ChangeRequestWebhookPayload } from '@codraoss/schema/webhook'; +import { REVIEW_CONCURRENCY_LIMITS, type ReviewJobMessage } from '@codraoss/schema'; +import type { ReviewGitProvider, ReviewRuntime } from '../ports'; +import { extractReviewRequest } from './request'; + +export { getDiffFiles, getOrFetchRawDiffForCompletedJob } from './diff-cache'; + +export { budgetAwareFileLimit, estimatedSubrequestsPerFile } from './budget'; + +export { + narrowUnit, + planReviewUnits, + unitFiles, + type LedgerEntry, + type ReviewUnit, +} from './pack'; + +export { + BIN_DIFF_CHAR_BUDGET, + BIN_MAX_FILES, + BIN_TARGET_DIFF_LINES, + PACKABLE_MAX_DIFF_LINES, +} from '../constants'; + +export { proportionalSplit } from './bin-runner'; + +export { verifyFindings, type VerifyDrop, type VerifyOutcome } from '../finding-gates'; + +export { extractReviewRequest, type ReviewRequest } from './request'; + +// workflows/review.ts floors its inter-phase sleep here; the eslint barrel guard stops it +export { FRESH_INVOCATION_YIELD_SECONDS } from '../constants'; + +import { + type PersistedReviewJob, + NextPhaseError, + failJobAndCheckRun, +} from './phase-control'; +import { + BUSY_RETRY_SECONDS, + FRESH_INVOCATION_YIELD_SECONDS, + JOB_LEASE_SECONDS, + MAX_FINALIZE_CONTINUATIONS, + MAX_JOB_CONTINUATIONS, +} from '../constants'; +import { getRetryableModelFailureDelaySeconds, isAwaitingAsyncReview, isSubrequestBudgetError } from './retry-policy'; +import { persistFailedFileReview } from './file-runner'; +import { runPreparePhase } from './prepare'; +import { runReviewPhase } from './phase'; +import { runFinalizePhase } from './finalize'; + +export { NextPhaseError, failJobAndCheckRun }; + +export type ReviewJobRunResult = + | { action: 'ack' } + | { action: 'retry'; delaySeconds: number } + | { action: 'next_phase'; phase: 'prepare' | 'review' | 'finalize'; delaySeconds: number; jobId?: string; freshInstance?: boolean }; + +/** + * The engine's entrypoint. Runs EXACTLY ONE phase of a review job and returns what the caller should + * do next; the caller owns the loop. + * + * Deliberately not a loop. Every `next_phase` result exists because the next phase needs a fresh + * host invocation to get a clean subrequest budget, and only the driver can hibernate long enough to + * produce one (see FRESH_INVOCATION_YIELD_SECONDS in ./phase-control). A loop in here would run the + * next phase on the current one's spent budget while its TokenTracker restarted at zero -- the exact + * failure that constant was introduced to fix. + * + * Contract for a driver: + * - 'ack': the job is finished or not ours. Stop. + * - 'retry': re-deliver the SAME message after `delaySeconds`. Admission was throttled or the lease + * is held elsewhere; no work happened. + * - 'next_phase': re-invoke with `{ jobId, phase }` after `delaySeconds`. `freshInstance` means the + * delay must be long enough to actually hibernate, not merely to wait. + * + * Safe to call repeatedly for the same job: it claims a lease first, and every phase is idempotent + * enough to resume. It throws only on a programming error -- job failures are recorded and acked. + */ +export async function runReview(env: ReviewRuntime, message: ReviewJobMessage): Promise { + const resolved = await resolveQueuedJob(env, message); + if (!resolved) { + return { action: 'ack' }; + } + + if (resolved.job.status === 'queued') { + const { concurrencyLevel } = await env.settings.getReviewSettings(); + const maxConcurrentJobs = REVIEW_CONCURRENCY_LIMITS[concurrencyLevel]; + const runningCount = await env.jobs.getOtherRunningJobsCount(resolved.job.id); + if (runningCount >= maxConcurrentJobs) { + logger.info(`Throttling admission of job ${resolved.job.id}: ${runningCount} other jobs are currently running.`); + return { action: 'retry', delaySeconds: 30 }; + } + } + + const leaseOwner = env.ids.randomUUID(); + const claim = await env.jobs.claimJobLease(resolved.job.id, leaseOwner, JOB_LEASE_SECONDS); + if (claim.status === 'missing') { + logger.warn(`Job not found for processing: ${resolved.job.id}`); + return { action: 'ack' }; + } + if (claim.status === 'terminal') { + logger.info(`Job ${resolved.job.id} is already terminal (${claim.row.status}), acking queue delivery.`); + return { action: 'ack' }; + } + if (claim.status === 'busy') { + logger.info(`Job ${resolved.job.id} has a fresh lease; retrying queue delivery later.`); + return { action: 'retry', delaySeconds: Math.min(BUSY_RETRY_SECONDS, claim.retryAfterSeconds) }; + } + + const job = env.jobs.mapJob(claim.row); + + if (message.workflowInstanceId && job.workflowInstanceId !== message.workflowInstanceId) { + try { + await env.jobs.setJobWorkflowInstance(job.id, message.workflowInstanceId); + } catch (error) { + logger.warn(`Failed to bind workflow instance id for job ${job.id}`, error instanceof Error ? error : new Error(String(error))); + } + } + + const phase = resolved.phase; + const tracker = env.createTokenTracker(); + const github = env.createGitHub(job.installationId, tracker); + const model = env.createModel(job.id, tracker); + const formatter = env.createFormatter(); + + try { + if (phase === 'prepare') { + await runPreparePhase(env, job, leaseOwner, github); + } else if (phase === 'finalize') { + await runFinalizePhase(env, job, leaseOwner, github, formatter, model); + } else { + await runReviewPhase(env, job, leaseOwner, github, model, tracker); + } + + await env.jobs.releaseJobLease(job.id, leaseOwner); + return { action: 'ack' }; + } catch (error) { + const messageText = error instanceof Error ? error.message : 'Unknown review failure'; + if (messageText === 'JOB_SUPERSEDED') { + logger.info(`Job ${job.id} was superseded during execution, stopping.`); + await env.jobs.releaseJobLease(job.id, leaseOwner); + return { action: 'ack' }; + } + + if (error instanceof NextPhaseError) { + await env.jobs.releaseJobLease(job.id, leaseOwner); + return { action: 'next_phase', phase: error.phase, delaySeconds: error.delaySeconds, jobId: job.id, freshInstance: error.phase === 'finalize' }; + } + + if (env.modelErrors.isRetryableModelError(error)) { + const delaySeconds = getRetryableModelFailureDelaySeconds(error); + logger.warn(`Review job hit transient model/provider failure; scheduling delayed continuation: ${job.owner}/${job.repo} PR #${job.prNumber}`, { + error: messageText, + phase, + delaySeconds, + }); + return continueOrFailWedgedJob(env, job, github, leaseOwner, phase, delaySeconds, 'transient model/provider failures'); + } + + if (isSubrequestBudgetError(error)) { + const record = error && typeof error === 'object' ? error as { retryAfterSeconds?: unknown } : null; + const delaySeconds = typeof record?.retryAfterSeconds === 'number' + ? record.retryAfterSeconds + : FRESH_INVOCATION_YIELD_SECONDS; + logger.warn(`Review job hit the per-invocation subrequest limit; rescheduling ${phase} on a fresh budget: ${job.owner}/${job.repo} PR #${job.prNumber}`, { + error: messageText, + phase, + delaySeconds, + }); + return continueOrFailWedgedJob(env, job, github, leaseOwner, phase, delaySeconds, 'per-invocation subrequest limits'); + } + + console.error('JOB FAILED WITH ERROR:', error); + logger.error(`Review job failed: ${job.owner}/${job.repo} PR #${job.prNumber}`, error); + await failJobAndCheckRun(env, job, github, messageText); + await env.jobs.releaseJobLease(job.id, leaseOwner); + return { action: 'ack' }; + } +} + +async function continueOrFailWedgedJob( + env: ReviewRuntime, + job: PersistedReviewJob, + github: ReviewGitProvider, + leaseOwner: string, + phase: 'prepare' | 'review' | 'finalize', + delaySeconds: number, + reason: string, +): Promise { + const continuationCount = await env.jobs.markJobContinuationQueued(job.id, delaySeconds); + + const ceiling = phase === 'finalize' ? MAX_FINALIZE_CONTINUATIONS : MAX_JOB_CONTINUATIONS; + + if (continuationCount > ceiling) { + if (phase === 'review') { + logger.error(`Review job exceeded the continuation ceiling; degrading to a partial review: ${job.owner}/${job.repo} PR #${job.prNumber}`, { + phase, + continuationCount, + reason, + }); + const stillPending = (await env.fileReviews.getFileReviewsForJobs([job.id])).filter(isAwaitingAsyncReview); + for (const review of stillPending) { + await persistFailedFileReview(env, job.id, { + filePath: review.file_path, + modelUsed: review.async_model ?? review.model_used, + diffLineCount: review.diff_line_count, + errorMessage: 'Async batch review did not complete before the job wedged.', + clearAsync: true, + }); + } + await env.jobs.resetJobContinuationCount(job.id); + await env.jobs.releaseJobLease(job.id, leaseOwner); + return { action: 'next_phase', phase: 'finalize', delaySeconds: FRESH_INVOCATION_YIELD_SECONDS, jobId: job.id, freshInstance: true }; + } else { + const message = `Review could not make progress after ${continuationCount} continuation attempts (${reason}). Failing the job to avoid an endless retry loop; re-run it once the underlying provider issue clears.`; + logger.error(`Review job exceeded the continuation ceiling; failing terminally: ${job.owner}/${job.repo} PR #${job.prNumber}`, { + phase, + continuationCount, + reason, + }); + await failJobAndCheckRun(env, job, github, message); + await env.jobs.releaseJobLease(job.id, leaseOwner); + return { action: 'ack' }; + } + } + + await env.jobs.releaseJobLease(job.id, leaseOwner); + const freshInstance = reason.includes('subrequest'); + return { action: 'next_phase', phase, delaySeconds, jobId: job.id, freshInstance }; +} + +async function resolveQueuedJob( + env: ReviewRuntime, + message: ReviewJobMessage, +): Promise<{ job: PersistedReviewJob; phase: 'prepare' | 'review' | 'finalize' } | null> { + if (message.jobId) { + const row = await env.jobs.getJobForProcessing(message.jobId); + return row ? { job: env.jobs.mapJob(row), phase: message.phase ?? 'review' } : null; + } + + if (!message.eventName) { + logger.warn('Queue message ignored: missing eventName'); + return null; + } + + let eventName = message.eventName; + let payload = message.payload as WebhookPayload | undefined; + + if (payload === undefined) { + const delivery = await env.webhooks.getWebhookDelivery(message.deliveryId); + if (!delivery) { + logger.warn(`Queue message ignored: webhook delivery not found: ${message.deliveryId}`); + return null; + } + + eventName = delivery.event_name; + payload = delivery.payload as WebhookPayload; + } + + if (eventName !== 'change_request' && eventName !== 'comment') { + logger.info(`Queue message ignored: unsupported webhook event ${eventName}`); + return null; + } + + const installationId = String(payload.installationId ?? ''); + if (!installationId || !('repository' in payload) || !payload.repository) { + logger.info('Queue message ignored: missing installation or repository info'); + return null; + } + + const repoConfig = await env.repoConfig.loadRepoConfig({ + installationId, + owner: payload.repository.owner, + repo: payload.repository.name, + }); + + if (repoConfig.enabled === false) { + logger.info(`Job ignored: repository ${payload.repository.owner}/${payload.repository.name} is disabled`); + return null; + } + + const extracted = extractReviewRequest({ + eventName, + payload, + botUsername: env.botUsername, + config: repoConfig.parsedJson, + }); + + if (!extracted) { + if (eventName === 'change_request') { + const prPayload = payload as ChangeRequestWebhookPayload; + if (prPayload.action === 'closed' && repoConfig.parsedJson.review.labels !== false) { + const labels = repoConfig.parsedJson.review.labels; + const gh = env.githubClients.forInstallation(installationId); + await gh.removeIssueLabelsIfPresent( + prPayload.repository.owner, + prPayload.repository.name, + prPayload.changeRequest.number, + [labels.p1, labels.p2, labels.p3], + ); + } + } + return null; + } + + let resolved = extracted; + const githubClient = env.githubClients.forInstallation(installationId); + if (eventName === 'comment') { + const pr = await githubClient.getPullRequest(extracted.owner, extracted.repo, extracted.prNumber); + resolved = { + ...extracted, + prTitle: pr.title, + prAuthor: pr.user.login, + commitSha: pr.head.sha, + baseSha: pr.base.sha, + headRef: pr.head.ref, + baseRef: pr.base.ref, + }; + } + + const duplicateJob = await env.jobs.findExistingJobForHead({ + owner: resolved.owner, + repo: resolved.repo, + prNumber: resolved.prNumber, + commitSha: resolved.commitSha, + trigger: resolved.trigger, + }); + if (duplicateJob) { + if (duplicateJob.status === 'queued' || duplicateJob.status === 'running') { + logger.info(`Resuming duplicate in-flight job ${duplicateJob.id} for ${resolved.owner}/${resolved.repo} PR #${resolved.prNumber}.`); + return { job: duplicateJob, phase: message.phase ?? 'prepare' }; + } + + logger.info(`Duplicate terminal job found for ${resolved.owner}/${resolved.repo} PR #${resolved.prNumber}, skipping.`); + return null; + } + + const job = await env.jobs.insertJob({ + installationId: resolved.installationId, + owner: resolved.owner, + repo: resolved.repo, + prNumber: resolved.prNumber, + prTitle: resolved.prTitle, + prAuthor: resolved.prAuthor, + commitSha: resolved.commitSha, + baseSha: resolved.baseSha, + trigger: resolved.trigger, + headRef: resolved.headRef, + baseRef: resolved.baseRef, + configSnapshot: repoConfig.parsedJson, + }); + + await env.jobs.supersedeOlderJobs({ + installationId: resolved.installationId, + owner: resolved.owner, + repo: resolved.repo, + prNumber: resolved.prNumber, + newJobId: job.id, + }); + + return { job, phase: 'prepare' }; +} diff --git a/packages/core/src/review/pack.ts b/packages/core/src/review/pack.ts index 01aad445..dc471825 100644 --- a/packages/core/src/review/pack.ts +++ b/packages/core/src/review/pack.ts @@ -1,90 +1,90 @@ -import { renderFileDiff } from '../prompts/file-review'; -import type { FileDiff } from '../diff'; -import { - PACKABLE_MAX_DIFF_LINES, - BIN_TARGET_DIFF_LINES, - BIN_MAX_FILES, - BIN_DIFF_CHAR_BUDGET, - FRAGMENTED_HUNK_THRESHOLD, - FRAGMENTED_MIN_LINES, -} from '../constants'; - -export type ReviewUnit = - | { kind: 'single'; file: FileDiff } - | { kind: 'bin'; files: FileDiff[]; diffLineCount: number; diffChars: number }; - -export type LedgerEntry = { handled: boolean; transientErrorCount: number }; - -export function unitFiles(unit: ReviewUnit): FileDiff[] { - return unit.kind === 'single' ? [unit.file] : unit.files; -} - -const measure = (file: FileDiff) => renderFileDiff(file).length; - -/** Changes scattered thinly across a file rather than concentrated in one place. */ -export function isFragmented(file: FileDiff): boolean { - return file.hunks.length >= FRAGMENTED_HUNK_THRESHOLD - && file.lineCount >= FRAGMENTED_MIN_LINES - && !file.isNew; -} - -const asBin = (files: FileDiff[]): ReviewUnit => (files.length === 1 - ? { kind: 'single', file: files[0] } - : { - kind: 'bin', - files, - diffLineCount: files.reduce((sum, f) => sum + f.lineCount, 0), - diffChars: files.reduce((sum, f) => sum + measure(f), 0), - }); - -export function planReviewUnits( - files: readonly FileDiff[], - opts: { enabled: boolean; fullFileContext?: boolean }, -): ReviewUnit[] { - if (!opts.enabled) return files.map((file) => ({ kind: 'single', file })); - - const units: ReviewUnit[] = []; - let open: FileDiff[] = []; - let lines = 0; - let chars = 0; - - const close = () => { - if (open.length > 0) units.push(asBin(open)); - open = []; - lines = 0; - chars = 0; - }; - - for (const file of files) { - const fileChars = measure(file); - const promoteForContext = opts.fullFileContext === true && isFragmented(file); - if (promoteForContext || file.lineCount > PACKABLE_MAX_DIFF_LINES || fileChars > BIN_DIFF_CHAR_BUDGET) { - close(); - units.push({ kind: 'single', file }); - continue; - } - - if (open.length > 0 && ( - lines + file.lineCount > BIN_TARGET_DIFF_LINES - || chars + fileChars > BIN_DIFF_CHAR_BUDGET - || open.length >= BIN_MAX_FILES - )) close(); - - open.push(file); - lines += file.lineCount; - chars += fileChars; - } - - close(); - return units; -} - -export function narrowUnit(unit: ReviewUnit, ledger: Map): ReviewUnit[] { - const outstanding = unitFiles(unit).filter((file) => !ledger.get(file.path)?.handled); - if (outstanding.length === 0) return []; - - const failedBefore = outstanding.some((file) => (ledger.get(file.path)?.transientErrorCount ?? 0) > 0); - if (failedBefore) return outstanding.map((file): ReviewUnit => ({ kind: 'single', file })); - - return [asBin(outstanding)]; -} +import { renderFileDiff } from '../prompts/file-review'; +import type { FileDiff } from '../diff'; +import { + PACKABLE_MAX_DIFF_LINES, + BIN_TARGET_DIFF_LINES, + BIN_MAX_FILES, + BIN_DIFF_CHAR_BUDGET, + FRAGMENTED_HUNK_THRESHOLD, + FRAGMENTED_MIN_LINES, +} from '../constants'; + +export type ReviewUnit = + | { kind: 'single'; file: FileDiff } + | { kind: 'bin'; files: FileDiff[]; diffLineCount: number; diffChars: number }; + +export type LedgerEntry = { handled: boolean; transientErrorCount: number }; + +export function unitFiles(unit: ReviewUnit): FileDiff[] { + return unit.kind === 'single' ? [unit.file] : unit.files; +} + +const measure = (file: FileDiff) => renderFileDiff(file).length; + +/** Changes scattered thinly across a file rather than concentrated in one place. */ +export function isFragmented(file: FileDiff): boolean { + return file.hunks.length >= FRAGMENTED_HUNK_THRESHOLD + && file.lineCount >= FRAGMENTED_MIN_LINES + && !file.isNew; +} + +const asBin = (files: FileDiff[]): ReviewUnit => (files.length === 1 + ? { kind: 'single', file: files[0] } + : { + kind: 'bin', + files, + diffLineCount: files.reduce((sum, f) => sum + f.lineCount, 0), + diffChars: files.reduce((sum, f) => sum + measure(f), 0), + }); + +export function planReviewUnits( + files: readonly FileDiff[], + opts: { enabled: boolean; fullFileContext?: boolean }, +): ReviewUnit[] { + if (!opts.enabled) return files.map((file) => ({ kind: 'single', file })); + + const units: ReviewUnit[] = []; + let open: FileDiff[] = []; + let lines = 0; + let chars = 0; + + const close = () => { + if (open.length > 0) units.push(asBin(open)); + open = []; + lines = 0; + chars = 0; + }; + + for (const file of files) { + const fileChars = measure(file); + const promoteForContext = opts.fullFileContext === true && isFragmented(file); + if (promoteForContext || file.lineCount > PACKABLE_MAX_DIFF_LINES || fileChars > BIN_DIFF_CHAR_BUDGET) { + close(); + units.push({ kind: 'single', file }); + continue; + } + + if (open.length > 0 && ( + lines + file.lineCount > BIN_TARGET_DIFF_LINES + || chars + fileChars > BIN_DIFF_CHAR_BUDGET + || open.length >= BIN_MAX_FILES + )) close(); + + open.push(file); + lines += file.lineCount; + chars += fileChars; + } + + close(); + return units; +} + +export function narrowUnit(unit: ReviewUnit, ledger: Map): ReviewUnit[] { + const outstanding = unitFiles(unit).filter((file) => !ledger.get(file.path)?.handled); + if (outstanding.length === 0) return []; + + const failedBefore = outstanding.some((file) => (ledger.get(file.path)?.transientErrorCount ?? 0) > 0); + if (failedBefore) return outstanding.map((file): ReviewUnit => ({ kind: 'single', file })); + + return [asBin(outstanding)]; +} diff --git a/packages/core/src/review/phase-control.ts b/packages/core/src/review/phase-control.ts index b20a6fd9..b477726f 100644 --- a/packages/core/src/review/phase-control.ts +++ b/packages/core/src/review/phase-control.ts @@ -1,66 +1,66 @@ -import { logger } from '../logger'; -import type { PersistedReviewJob, ReviewGitProvider, ReviewRuntime } from '../ports'; -import { - JOB_LEASE_SECONDS, -} from '../constants'; - -// JobSummary, which is exactly what mapJob returns; see the note on the port. -export type { PersistedReviewJob }; - -export async function heartbeatAndCheckSuperseded(env: ReviewRuntime, jobId: string, leaseOwner: string) { - await env.jobs.heartbeatJobLease(jobId, leaseOwner, JOB_LEASE_SECONDS); - const currentJob = await env.jobs.getJobForProcessing(jobId); - if (currentJob?.status === 'superseded') { - throw new Error('JOB_SUPERSEDED'); - } -} - -export class NextPhaseError extends Error { - constructor(public phase: 'prepare' | 'review' | 'finalize', public delaySeconds: number) { - super(`NextPhase: ${phase}`); - } -} - -export async function enqueueJobPhase( - env: ReviewRuntime, - jobId: string, - phase: 'prepare' | 'review' | 'finalize', - delaySeconds = 0, -) { - await env.jobs.markJobContinuationQueued(jobId, delaySeconds); - throw new NextPhaseError(phase, delaySeconds); -} - -export function hasCompletedStep(job: PersistedReviewJob, stepName: string) { - return job.steps.some((step) => step.name === stepName && step.status === 'done'); -} - -export async function failJobAndCheckRun( - env: ReviewRuntime, - job: Pick, - github: Pick, - message: string, -) { - try { - await env.jobs.failJob(job.id, message); - } catch (dbError) { - logger.error(`Critical: failed to mark job ${job.id} as failed in the DB; it may remain stuck until lease-expiry recovery reclaims it`, dbError); - return; - } - - try { - const latest = await env.jobs.getJobForProcessing(job.id); - const checkRunId = latest?.check_run_id ?? job.checkRunId; - if (checkRunId) { - await github.updateCheckRun(job.owner, job.repo, checkRunId, { - status: 'completed', - conclusion: 'failure', - title: 'Review failed', - summary: message, - }); - await env.jobs.markJobCheckRunCompleted(job.id); - } - } catch (checkRunError) { - logger.warn(`Failed to update GitHub check run for failed job ${job.id}; opportunistic maintenance will retry it`, checkRunError); - } -} +import { logger } from '../logger'; +import type { PersistedReviewJob, ReviewGitProvider, ReviewRuntime } from '../ports'; +import { + JOB_LEASE_SECONDS, +} from '../constants'; + +// JobSummary, which is exactly what mapJob returns; see the note on the port. +export type { PersistedReviewJob }; + +export async function heartbeatAndCheckSuperseded(env: ReviewRuntime, jobId: string, leaseOwner: string) { + await env.jobs.heartbeatJobLease(jobId, leaseOwner, JOB_LEASE_SECONDS); + const currentJob = await env.jobs.getJobForProcessing(jobId); + if (currentJob?.status === 'superseded') { + throw new Error('JOB_SUPERSEDED'); + } +} + +export class NextPhaseError extends Error { + constructor(public phase: 'prepare' | 'review' | 'finalize', public delaySeconds: number) { + super(`NextPhase: ${phase}`); + } +} + +export async function enqueueJobPhase( + env: ReviewRuntime, + jobId: string, + phase: 'prepare' | 'review' | 'finalize', + delaySeconds = 0, +) { + await env.jobs.markJobContinuationQueued(jobId, delaySeconds); + throw new NextPhaseError(phase, delaySeconds); +} + +export function hasCompletedStep(job: PersistedReviewJob, stepName: string) { + return job.steps.some((step) => step.name === stepName && step.status === 'done'); +} + +export async function failJobAndCheckRun( + env: ReviewRuntime, + job: Pick, + github: Pick, + message: string, +) { + try { + await env.jobs.failJob(job.id, message); + } catch (dbError) { + logger.error(`Critical: failed to mark job ${job.id} as failed in the DB; it may remain stuck until lease-expiry recovery reclaims it`, dbError); + return; + } + + try { + const latest = await env.jobs.getJobForProcessing(job.id); + const checkRunId = latest?.check_run_id ?? job.checkRunId; + if (checkRunId) { + await github.updateCheckRun(job.owner, job.repo, checkRunId, { + status: 'completed', + conclusion: 'failure', + title: 'Review failed', + summary: message, + }); + await env.jobs.markJobCheckRunCompleted(job.id); + } + } catch (checkRunError) { + logger.warn(`Failed to update GitHub check run for failed job ${job.id}; opportunistic maintenance will retry it`, checkRunError); + } +} diff --git a/packages/core/src/review/phase.ts b/packages/core/src/review/phase.ts index d69041e3..2a3c0138 100644 --- a/packages/core/src/review/phase.ts +++ b/packages/core/src/review/phase.ts @@ -1,360 +1,360 @@ -import { logger } from '../logger'; -import { defaultRepoConfig, REVIEW_CONCURRENCY_LIMITS, type ParsedReviewComment, type RepoConfig } from '@codraoss/schema'; -import { budgetAwareFileLimit } from './budget'; -import { narrowUnit, planReviewUnits } from './pack'; -import { reviewAndPersistBin } from './bin-runner'; -import { getDiffFiles } from './diff-cache'; -import { changelogExcerptFromDiff, wantsFileContext } from '../prompts/file-review'; -import { loadFileContext } from './file-context'; -import type { ReviewGitProvider, ReviewModel, ReviewRuntime } from '../ports'; -import { TokenTracker } from '../token-tracker'; -import { - type PersistedReviewJob, - NextPhaseError, - enqueueJobPhase, - hasCompletedStep, - heartbeatAndCheckSuperseded, -} from './phase-control'; -import { - ASYNC_BATCH_POLL_DELAY_SECONDS, - FRESH_INVOCATION_YIELD_SECONDS, - MAX_JOB_CONTINUATIONS, - REVIEW_CHUNK_WALL_CLOCK_MS, -} from '../constants'; -import { - canInheritParentFileReview, - countsAsHandledFileReview, - isAwaitingAsyncReview, - isSubrequestBudgetError, - resolveModelProviderName, -} from './retry-policy'; -import { loadRejectedExemplars, runPreparePhase } from './prepare'; -import { persistCompletedReview, persistFailedFileReview, reviewAndPersistFile } from './file-runner'; - -export async function runReviewPhase( - env: ReviewRuntime, - job: PersistedReviewJob, - leaseOwner: string, - github: ReviewGitProvider, - model: ReviewModel, - tracker: TokenTracker, -) { - if (!hasCompletedStep(job, 'Preparation')) { - await runPreparePhase(env, job, leaseOwner, github); - return; - } - - await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'running' }); - - const [rejectedExemplars, pr] = await Promise.all([ - loadRejectedExemplars(env, job), - github.getPullRequest(job.owner, job.repo, job.prNumber), - ]); - const config = (job.configSnapshot ?? defaultRepoConfig) as RepoConfig; - const failureModelId = config.model?.main ?? 'unconfigured'; - let failureModelProviderPromise: Promise | null = null; - const resolveFailureModelProvider = () => { - failureModelProviderPromise ??= resolveModelProviderName(env, failureModelId); - return failureModelProviderPromise; - }; - const { concurrencyLevel, maxFiles } = await env.settings.getReviewSettings(); - const { files } = await getDiffFiles(env, job, github, config, maxFiles); - const totalLineCount = files.reduce((sum, file) => sum + file.lineCount, 0); - const changelogExcerpt = changelogExcerptFromDiff(files); - const configuredChunkFileLimit = REVIEW_CONCURRENCY_LIMITS[concurrencyLevel]; - const modelChainLength = 1 + (config.model.fallbacks?.length ?? 0); - const reviewChunkFileLimit = budgetAwareFileLimit( - tracker.remainingSafeBudget(), - configuredChunkFileLimit, - modelChainLength, - config.review.full_file_context, - // A second reviewer walks its own chain per file, so fewer files fit in one invocation. - Boolean(config.model?.secondary), - ); - if (reviewChunkFileLimit <= 0) { - throw new Error('Subrequest budget for this invocation was exhausted before starting the next review chunk.'); - } - const startedAt = env.clock.now(); - let processedThisChunk = 0; - - const jobIdsToQuery = [job.id]; - if (job.retryOfJobId) jobIdsToQuery.push(job.retryOfJobId); - const allExistingReviews = await env.fileReviews.getFileReviewsForJobs(jobIdsToQuery); - type ExistingReview = (typeof allExistingReviews)[number]; - const currentReviews = new Map(); - const parentReviews = new Map(); - for (const review of allExistingReviews) { - if (review.job_id === job.id) currentReviews.set(review.file_path, review); - else if (review.file_status === 'done') parentReviews.set(review.file_path, review); - } - - const reviewTasks: Array> = []; - let terminalProgress = 0; - let awaitingAsync = 0; - - if (job.retryOfJobId && parentReviews.size > 0) { - const inheritablePaths = files.flatMap((file) => { - if (currentReviews.has(file.path)) return []; - const parent = parentReviews.get(file.path); - return parent && canInheritParentFileReview(config, parent) ? [file.path] : []; - }); - - if (inheritablePaths.length > 0) { - const inheritedPaths = await env.fileReviews.bulkInheritFileReviews({ - jobId: job.id, - parentJobId: job.retryOfJobId, - filePaths: inheritablePaths, - }); - for (const path of inheritedPaths) { - const parent = parentReviews.get(path); - if (parent) currentReviews.set(path, parent); - } - terminalProgress += inheritedPaths.length; - if (inheritedPaths.length > 0) { - logger.info(`Bulk-inherited ${inheritedPaths.length} parent file reviews for job ${job.id} in one pass`); - } - } - } - - const binnedPaths = new Set(); - if (config.review.batch_small_files) { - const ledger = new Map(files.map((file) => { - const existing = currentReviews.get(file.path); - const inheritable = parentReviews.get(file.path); - return [file.path, { - handled: Boolean((existing && countsAsHandledFileReview(existing)) || (inheritable && canInheritParentFileReview(config, inheritable))), - transientErrorCount: existing?.transient_error_count ?? 0, - }]; - })); - - const units = planReviewUnits(files, { enabled: true, fullFileContext: config.review.full_file_context }).flatMap((unit) => narrowUnit(unit, ledger)); - const plannedBins = units.filter((unit) => unit.kind === 'bin'); - let binsDispatched = 0; - let filesDispatchedInBins = 0; - - for (const unit of plannedBins) { - if (processedThisChunk >= reviewChunkFileLimit) break; - if (env.clock.now() - startedAt >= REVIEW_CHUNK_WALL_CLOCK_MS) break; - - const binFiles = unit.kind === 'bin' ? unit.files : []; - binFiles.forEach((file) => binnedPaths.add(file.path)); - reviewTasks.push((async () => { - const terminal = await reviewAndPersistBin(env, job, binFiles, pr, config, totalLineCount, model, resolveFailureModelProvider, rejectedExemplars, changelogExcerpt); - terminalProgress += terminal; - })()); - processedThisChunk += 1; - binsDispatched += 1; - filesDispatchedInBins += binFiles.length; - } - - if (plannedBins.length > 0) { - logger.info('Batched review plan', { - jobId: job.id, - binsPlanned: plannedBins.length, - binsDispatched, - filesInBins: filesDispatchedInBins, - modelCallsSaved: filesDispatchedInBins - binsDispatched, - }); - } - } - - for (const file of files) { - if (binnedPaths.has(file.path)) continue; - - const existingReview = currentReviews.get(file.path); - const awaitingReview = existingReview && isAwaitingAsyncReview(existingReview) ? existingReview : null; - if (existingReview && countsAsHandledFileReview(existingReview) && !awaitingReview) { - continue; - } - - if (!awaitingReview && processedThisChunk >= reviewChunkFileLimit) { - continue; - } - - const inherited = parentReviews.get(file.path); - let fileContextPromise: Promise | null = null; - const fileContextFor = () => { - if (!wantsFileContext(file, config.review.full_file_context, { - compactPrompt: (existingReview?.transient_error_count ?? 0) > 0, - })) { - return Promise.resolve(null); - } - fileContextPromise ??= loadFileContext(github, job, file, () => tracker.incrementSubrequests(1)); - return fileContextPromise; - }; - const reviewTask = async () => { - if (awaitingReview) { - const poll = await model.pollReviewBatch({ - model: awaitingReview.async_model ?? awaitingReview.model_used, - requestId: awaitingReview.async_request_id!, - file, - config, - }); - if (poll.status === 'pending') { - awaitingAsync += 1; - return; - } - if (poll.status === 'failed') { - logger.warn(`Async batch poll failed for ${file.path}; falling back to synchronous review`, { - error: poll.error instanceof Error ? poll.error.message : String(poll.error), - }); - await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview, rejectedExemplars, changelogExcerpt, await fileContextFor()); - terminalProgress += 1; - return; - } - await persistCompletedReview(env, job, file, poll.response); - terminalProgress += 1; - return; - } - - if (!inherited) { - const submitted = await model.submitReviewBatch({ - file, - fileContext: await fileContextFor(), - prTitle: pr.title ?? null, - prDescription: pr.body ?? null, - changelogExcerpt, - config, - totalLineCount, - compactPrompt: (existingReview?.transient_error_count ?? 0) > 0, - }); - if (submitted) { - await env.fileReviews.upsertFileReview(job.id, { - filePath: file.path, - fileStatus: 'pending', - modelUsed: submitted.model, - modelProvider: null, - diffLineCount: file.lineCount, - diffInput: null, - rawAiOutput: null, - parsedComments: [], - inputTokens: null, - outputTokens: null, - durationMs: null, - verdict: null, - fileSummary: null, - overallCorrectness: null, - confidenceScore: null, - errorMessage: null, - asyncRequestId: submitted.requestId, - asyncModel: submitted.model, - }); - awaitingAsync += 1; - return; - } - await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview, rejectedExemplars, changelogExcerpt, await fileContextFor()); - terminalProgress += 1; - return; - } - - if (!canInheritParentFileReview(config, inherited)) { - logger.info(`Ignoring inherited review for ${file.path}; parent model ${inherited.model_used} is not in the current model strategy`); - await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview, rejectedExemplars, changelogExcerpt, await fileContextFor()); - terminalProgress += 1; - } else { - await env.fileReviews.upsertFileReview(job.id, { - filePath: file.path, - fileStatus: 'done', - modelUsed: inherited.model_used, - modelProvider: inherited.model_provider, - diffLineCount: inherited.diff_line_count, - diffInput: inherited.diff_input, - rawAiOutput: inherited.raw_ai_output, - parsedComments: inherited.parsed_comments as ParsedReviewComment[], - inputTokens: inherited.input_tokens, - outputTokens: inherited.output_tokens, - durationMs: inherited.duration_ms, - verdict: inherited.verdict, - fileSummary: inherited.file_summary, - overallCorrectness: inherited.overall_correctness, - confidenceScore: inherited.confidence_score, - errorMessage: null, - }); - currentReviews.set(file.path, inherited); - terminalProgress += 1; - } - }; - - reviewTasks.push(reviewTask()); - if (!awaitingReview) processedThisChunk += 1; - - if (env.clock.now() - startedAt >= REVIEW_CHUNK_WALL_CLOCK_MS) { - break; - } - } - - const results = await Promise.allSettled(reviewTasks); - await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); - - if (terminalProgress > 0) { - await env.jobs.resetJobContinuationCount(job.id); - } - - logger.info('Review chunk model usage', { - jobId: job.id, - subrequests: tracker.getSubrequestCount(), - usage: tracker.getTotalUsage(), - wasted: tracker.getWasted(), - }); - - const rejected = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); - if (rejected.length > 0) { - rejected.forEach((result, index) => { - logger.error(`Review chunk task ${index + 1}/${rejected.length} failed`, result.reason); - }); - - const deferrableError = rejected.map(r => r.reason).find(r => env.modelErrors.isRetryableModelError(r) || isSubrequestBudgetError(r)); - if (deferrableError) { - throw deferrableError; - } - - throw rejected.length === 1 - ? rejected[0].reason - : new AggregateError(rejected.map((result) => result.reason), `${rejected.length} review chunk tasks failed`); - } - - const latestReviews = await env.fileReviews.getFileReviewsForJobs([job.id]); - const reviewedPaths = new Set( - latestReviews.flatMap((review) => ( - countsAsHandledFileReview(review) && !isAwaitingAsyncReview(review) ? [review.file_path] : [] - )), - ); - const completedCount = files.filter((file) => reviewedPaths.has(file.path)).length; - - if (completedCount >= files.length) { - await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'done' }); - await enqueueJobPhase(env, job.id, 'finalize', FRESH_INVOCATION_YIELD_SECONDS); - return; - } - - if (awaitingAsync > 0 && terminalProgress === 0) { - const pollCount = await env.jobs.markJobContinuationQueued(job.id, ASYNC_BATCH_POLL_DELAY_SECONDS); - if (pollCount > MAX_JOB_CONTINUATIONS) { - logger.error(`Async batch reviews did not complete after ${pollCount} polls; degrading to a partial review: ${job.owner}/${job.repo} PR #${job.prNumber}`); - for (const review of latestReviews.filter(isAwaitingAsyncReview)) { - await persistFailedFileReview(env, job.id, { - filePath: review.file_path, - modelUsed: review.async_model ?? review.model_used, - diffLineCount: review.diff_line_count, - errorMessage: 'Async batch review did not complete in time.', - clearAsync: true, - }); - } - await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'done' }); - throw new NextPhaseError('finalize', FRESH_INVOCATION_YIELD_SECONDS); - } - throw new NextPhaseError('review', ASYNC_BATCH_POLL_DELAY_SECONDS); - } - - if (job.checkRunId) { - try { - await github.updateCheckRun(job.owner, job.repo, job.checkRunId, { - title: `Reviewing (${completedCount}/${files.length})`, - summary: 'Codra is continuing this review in the next queue chunk.', - }); - } catch (error) { - logger.warn(`Failed to update progress check run for job ${job.id}; continuing to the next chunk anyway`, error instanceof Error ? error : new Error(String(error))); - } - } - await enqueueJobPhase(env, job.id, 'review', FRESH_INVOCATION_YIELD_SECONDS); -} +import { logger } from '../logger'; +import { defaultRepoConfig, REVIEW_CONCURRENCY_LIMITS, type ParsedReviewComment, type RepoConfig } from '@codraoss/schema'; +import { budgetAwareFileLimit } from './budget'; +import { narrowUnit, planReviewUnits } from './pack'; +import { reviewAndPersistBin } from './bin-runner'; +import { getDiffFiles } from './diff-cache'; +import { changelogExcerptFromDiff, wantsFileContext } from '../prompts/file-review'; +import { loadFileContext } from './file-context'; +import type { ReviewGitProvider, ReviewModel, ReviewRuntime } from '../ports'; +import { TokenTracker } from '../token-tracker'; +import { + type PersistedReviewJob, + NextPhaseError, + enqueueJobPhase, + hasCompletedStep, + heartbeatAndCheckSuperseded, +} from './phase-control'; +import { + ASYNC_BATCH_POLL_DELAY_SECONDS, + FRESH_INVOCATION_YIELD_SECONDS, + MAX_JOB_CONTINUATIONS, + REVIEW_CHUNK_WALL_CLOCK_MS, +} from '../constants'; +import { + canInheritParentFileReview, + countsAsHandledFileReview, + isAwaitingAsyncReview, + isSubrequestBudgetError, + resolveModelProviderName, +} from './retry-policy'; +import { loadRejectedExemplars, runPreparePhase } from './prepare'; +import { persistCompletedReview, persistFailedFileReview, reviewAndPersistFile } from './file-runner'; + +export async function runReviewPhase( + env: ReviewRuntime, + job: PersistedReviewJob, + leaseOwner: string, + github: ReviewGitProvider, + model: ReviewModel, + tracker: TokenTracker, +) { + if (!hasCompletedStep(job, 'Preparation')) { + await runPreparePhase(env, job, leaseOwner, github); + return; + } + + await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'running' }); + + const [rejectedExemplars, pr] = await Promise.all([ + loadRejectedExemplars(env, job), + github.getPullRequest(job.owner, job.repo, job.prNumber), + ]); + const config = (job.configSnapshot ?? defaultRepoConfig) as RepoConfig; + const failureModelId = config.model?.main ?? 'unconfigured'; + let failureModelProviderPromise: Promise | null = null; + const resolveFailureModelProvider = () => { + failureModelProviderPromise ??= resolveModelProviderName(env, failureModelId); + return failureModelProviderPromise; + }; + const { concurrencyLevel, maxFiles } = await env.settings.getReviewSettings(); + const { files } = await getDiffFiles(env, job, github, config, maxFiles); + const totalLineCount = files.reduce((sum, file) => sum + file.lineCount, 0); + const changelogExcerpt = changelogExcerptFromDiff(files); + const configuredChunkFileLimit = REVIEW_CONCURRENCY_LIMITS[concurrencyLevel]; + const modelChainLength = 1 + (config.model.fallbacks?.length ?? 0); + const reviewChunkFileLimit = budgetAwareFileLimit( + tracker.remainingSafeBudget(), + configuredChunkFileLimit, + modelChainLength, + config.review.full_file_context, + // A second reviewer walks its own chain per file, so fewer files fit in one invocation. + Boolean(config.model?.secondary), + ); + if (reviewChunkFileLimit <= 0) { + throw new Error('Subrequest budget for this invocation was exhausted before starting the next review chunk.'); + } + const startedAt = env.clock.now(); + let processedThisChunk = 0; + + const jobIdsToQuery = [job.id]; + if (job.retryOfJobId) jobIdsToQuery.push(job.retryOfJobId); + const allExistingReviews = await env.fileReviews.getFileReviewsForJobs(jobIdsToQuery); + type ExistingReview = (typeof allExistingReviews)[number]; + const currentReviews = new Map(); + const parentReviews = new Map(); + for (const review of allExistingReviews) { + if (review.job_id === job.id) currentReviews.set(review.file_path, review); + else if (review.file_status === 'done') parentReviews.set(review.file_path, review); + } + + const reviewTasks: Array> = []; + let terminalProgress = 0; + let awaitingAsync = 0; + + if (job.retryOfJobId && parentReviews.size > 0) { + const inheritablePaths = files.flatMap((file) => { + if (currentReviews.has(file.path)) return []; + const parent = parentReviews.get(file.path); + return parent && canInheritParentFileReview(config, parent) ? [file.path] : []; + }); + + if (inheritablePaths.length > 0) { + const inheritedPaths = await env.fileReviews.bulkInheritFileReviews({ + jobId: job.id, + parentJobId: job.retryOfJobId, + filePaths: inheritablePaths, + }); + for (const path of inheritedPaths) { + const parent = parentReviews.get(path); + if (parent) currentReviews.set(path, parent); + } + terminalProgress += inheritedPaths.length; + if (inheritedPaths.length > 0) { + logger.info(`Bulk-inherited ${inheritedPaths.length} parent file reviews for job ${job.id} in one pass`); + } + } + } + + const binnedPaths = new Set(); + if (config.review.batch_small_files) { + const ledger = new Map(files.map((file) => { + const existing = currentReviews.get(file.path); + const inheritable = parentReviews.get(file.path); + return [file.path, { + handled: Boolean((existing && countsAsHandledFileReview(existing)) || (inheritable && canInheritParentFileReview(config, inheritable))), + transientErrorCount: existing?.transient_error_count ?? 0, + }]; + })); + + const units = planReviewUnits(files, { enabled: true, fullFileContext: config.review.full_file_context }).flatMap((unit) => narrowUnit(unit, ledger)); + const plannedBins = units.filter((unit) => unit.kind === 'bin'); + let binsDispatched = 0; + let filesDispatchedInBins = 0; + + for (const unit of plannedBins) { + if (processedThisChunk >= reviewChunkFileLimit) break; + if (env.clock.now() - startedAt >= REVIEW_CHUNK_WALL_CLOCK_MS) break; + + const binFiles = unit.kind === 'bin' ? unit.files : []; + binFiles.forEach((file) => binnedPaths.add(file.path)); + reviewTasks.push((async () => { + const terminal = await reviewAndPersistBin(env, job, binFiles, pr, config, totalLineCount, model, resolveFailureModelProvider, rejectedExemplars, changelogExcerpt); + terminalProgress += terminal; + })()); + processedThisChunk += 1; + binsDispatched += 1; + filesDispatchedInBins += binFiles.length; + } + + if (plannedBins.length > 0) { + logger.info('Batched review plan', { + jobId: job.id, + binsPlanned: plannedBins.length, + binsDispatched, + filesInBins: filesDispatchedInBins, + modelCallsSaved: filesDispatchedInBins - binsDispatched, + }); + } + } + + for (const file of files) { + if (binnedPaths.has(file.path)) continue; + + const existingReview = currentReviews.get(file.path); + const awaitingReview = existingReview && isAwaitingAsyncReview(existingReview) ? existingReview : null; + if (existingReview && countsAsHandledFileReview(existingReview) && !awaitingReview) { + continue; + } + + if (!awaitingReview && processedThisChunk >= reviewChunkFileLimit) { + continue; + } + + const inherited = parentReviews.get(file.path); + let fileContextPromise: Promise | null = null; + const fileContextFor = () => { + if (!wantsFileContext(file, config.review.full_file_context, { + compactPrompt: (existingReview?.transient_error_count ?? 0) > 0, + })) { + return Promise.resolve(null); + } + fileContextPromise ??= loadFileContext(github, job, file, () => tracker.incrementSubrequests(1)); + return fileContextPromise; + }; + const reviewTask = async () => { + if (awaitingReview) { + const poll = await model.pollReviewBatch({ + model: awaitingReview.async_model ?? awaitingReview.model_used, + requestId: awaitingReview.async_request_id!, + file, + config, + }); + if (poll.status === 'pending') { + awaitingAsync += 1; + return; + } + if (poll.status === 'failed') { + logger.warn(`Async batch poll failed for ${file.path}; falling back to synchronous review`, { + error: poll.error instanceof Error ? poll.error.message : String(poll.error), + }); + await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview, rejectedExemplars, changelogExcerpt, await fileContextFor()); + terminalProgress += 1; + return; + } + await persistCompletedReview(env, job, file, poll.response); + terminalProgress += 1; + return; + } + + if (!inherited) { + const submitted = await model.submitReviewBatch({ + file, + fileContext: await fileContextFor(), + prTitle: pr.title ?? null, + prDescription: pr.body ?? null, + changelogExcerpt, + config, + totalLineCount, + compactPrompt: (existingReview?.transient_error_count ?? 0) > 0, + }); + if (submitted) { + await env.fileReviews.upsertFileReview(job.id, { + filePath: file.path, + fileStatus: 'pending', + modelUsed: submitted.model, + modelProvider: null, + diffLineCount: file.lineCount, + diffInput: null, + rawAiOutput: null, + parsedComments: [], + inputTokens: null, + outputTokens: null, + durationMs: null, + verdict: null, + fileSummary: null, + overallCorrectness: null, + confidenceScore: null, + errorMessage: null, + asyncRequestId: submitted.requestId, + asyncModel: submitted.model, + }); + awaitingAsync += 1; + return; + } + await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview, rejectedExemplars, changelogExcerpt, await fileContextFor()); + terminalProgress += 1; + return; + } + + if (!canInheritParentFileReview(config, inherited)) { + logger.info(`Ignoring inherited review for ${file.path}; parent model ${inherited.model_used} is not in the current model strategy`); + await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview, rejectedExemplars, changelogExcerpt, await fileContextFor()); + terminalProgress += 1; + } else { + await env.fileReviews.upsertFileReview(job.id, { + filePath: file.path, + fileStatus: 'done', + modelUsed: inherited.model_used, + modelProvider: inherited.model_provider, + diffLineCount: inherited.diff_line_count, + diffInput: inherited.diff_input, + rawAiOutput: inherited.raw_ai_output, + parsedComments: inherited.parsed_comments as ParsedReviewComment[], + inputTokens: inherited.input_tokens, + outputTokens: inherited.output_tokens, + durationMs: inherited.duration_ms, + verdict: inherited.verdict, + fileSummary: inherited.file_summary, + overallCorrectness: inherited.overall_correctness, + confidenceScore: inherited.confidence_score, + errorMessage: null, + }); + currentReviews.set(file.path, inherited); + terminalProgress += 1; + } + }; + + reviewTasks.push(reviewTask()); + if (!awaitingReview) processedThisChunk += 1; + + if (env.clock.now() - startedAt >= REVIEW_CHUNK_WALL_CLOCK_MS) { + break; + } + } + + const results = await Promise.allSettled(reviewTasks); + await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); + + if (terminalProgress > 0) { + await env.jobs.resetJobContinuationCount(job.id); + } + + logger.info('Review chunk model usage', { + jobId: job.id, + subrequests: tracker.getSubrequestCount(), + usage: tracker.getTotalUsage(), + wasted: tracker.getWasted(), + }); + + const rejected = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); + if (rejected.length > 0) { + rejected.forEach((result, index) => { + logger.error(`Review chunk task ${index + 1}/${rejected.length} failed`, result.reason); + }); + + const deferrableError = rejected.map(r => r.reason).find(r => env.modelErrors.isRetryableModelError(r) || isSubrequestBudgetError(r)); + if (deferrableError) { + throw deferrableError; + } + + throw rejected.length === 1 + ? rejected[0].reason + : new AggregateError(rejected.map((result) => result.reason), `${rejected.length} review chunk tasks failed`); + } + + const latestReviews = await env.fileReviews.getFileReviewsForJobs([job.id]); + const reviewedPaths = new Set( + latestReviews.flatMap((review) => ( + countsAsHandledFileReview(review) && !isAwaitingAsyncReview(review) ? [review.file_path] : [] + )), + ); + const completedCount = files.filter((file) => reviewedPaths.has(file.path)).length; + + if (completedCount >= files.length) { + await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'done' }); + await enqueueJobPhase(env, job.id, 'finalize', FRESH_INVOCATION_YIELD_SECONDS); + return; + } + + if (awaitingAsync > 0 && terminalProgress === 0) { + const pollCount = await env.jobs.markJobContinuationQueued(job.id, ASYNC_BATCH_POLL_DELAY_SECONDS); + if (pollCount > MAX_JOB_CONTINUATIONS) { + logger.error(`Async batch reviews did not complete after ${pollCount} polls; degrading to a partial review: ${job.owner}/${job.repo} PR #${job.prNumber}`); + for (const review of latestReviews.filter(isAwaitingAsyncReview)) { + await persistFailedFileReview(env, job.id, { + filePath: review.file_path, + modelUsed: review.async_model ?? review.model_used, + diffLineCount: review.diff_line_count, + errorMessage: 'Async batch review did not complete in time.', + clearAsync: true, + }); + } + await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'done' }); + throw new NextPhaseError('finalize', FRESH_INVOCATION_YIELD_SECONDS); + } + throw new NextPhaseError('review', ASYNC_BATCH_POLL_DELAY_SECONDS); + } + + if (job.checkRunId) { + try { + await github.updateCheckRun(job.owner, job.repo, job.checkRunId, { + title: `Reviewing (${completedCount}/${files.length})`, + summary: 'Codra is continuing this review in the next queue chunk.', + }); + } catch (error) { + logger.warn(`Failed to update progress check run for job ${job.id}; continuing to the next chunk anyway`, error instanceof Error ? error : new Error(String(error))); + } + } + await enqueueJobPhase(env, job.id, 'review', FRESH_INVOCATION_YIELD_SECONDS); +} diff --git a/packages/core/src/review/prepare.ts b/packages/core/src/review/prepare.ts index c357ec48..202b2f04 100644 --- a/packages/core/src/review/prepare.ts +++ b/packages/core/src/review/prepare.ts @@ -1,76 +1,76 @@ -import { logger } from '../logger'; -import { defaultRepoConfig, type RepoConfig } from '@codraoss/schema'; -import type { ReviewGitProvider, ReviewRuntime } from '../ports'; -import { getDiffFiles } from './diff-cache'; -import type { RejectedExemplar } from '../prompts/file-review'; -import { type PersistedReviewJob, enqueueJobPhase } from './phase-control'; -import { JOB_LEASE_SECONDS, FRESH_INVOCATION_YIELD_SECONDS } from '../constants'; - -export async function runPreparePhase( - env: ReviewRuntime, - job: PersistedReviewJob, - leaseOwner: string, - github: ReviewGitProvider, -) { - await env.jobs.updateJobStep(job.id, 'Preparation', { status: 'running' }); - const pr = await github.getPullRequest(job.owner, job.repo, job.prNumber); - const config = (job.configSnapshot ?? defaultRepoConfig) as RepoConfig; - - try { - await env.jobs.setJobPullRequestMeta(job.id, { - prTitle: pr.title ?? null, - prAuthor: pr.user?.login ?? null, - }); - } catch (error) { - logger.warn(`Failed to refresh PR metadata for job ${job.id}`, error instanceof Error ? error : new Error(String(error))); - } - - let checkRunId = job.checkRunId; - if (!checkRunId) { - const checkRun = await github.createCheckRun(job.owner, job.repo, { - headSha: pr.head.sha, - title: 'Review queued', - summary: 'Codra has started reviewing this pull request.', - }); - checkRunId = checkRun.id; - await env.jobs.updateJobCheckRun(job.id, checkRun.id); - } - - const { maxFiles } = await env.settings.getReviewSettings(); - const { files } = await getDiffFiles(env, job, github, config, maxFiles); - await env.jobs.completePreparationStep(job.id, files.length); - await env.jobs.heartbeatJobLease(job.id, leaseOwner, JOB_LEASE_SECONDS); - - if (files.length === 0) { - await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'done' }); - await enqueueJobPhase(env, job.id, 'finalize', FRESH_INVOCATION_YIELD_SECONDS); - return; - } - - if (checkRunId) { - try { - await github.updateCheckRun(job.owner, job.repo, checkRunId, { - title: `Reviewing (0/${files.length})`, - summary: 'Codra is analyzing changed files.', - }); - } catch (error) { - logger.warn(`Failed to update initial progress check run for job ${job.id}; continuing to the review phase anyway`, error instanceof Error ? error : new Error(String(error))); - } - } - await enqueueJobPhase(env, job.id, 'review', FRESH_INVOCATION_YIELD_SECONDS); -} - -export async function loadRejectedExemplars(env: Pick, job: PersistedReviewJob): Promise { - try { - const repositoryId = await env.learning.getRepositoryIdForJob(job.id); - if (repositoryId === null) return []; - const rows = await env.learning.getRejectedExemplars({ repositoryId, limit: 5 }); - return rows.map((row) => ({ title: row.title, claimType: row.claim_type })); - } catch (error) { - logger.warn('Could not load rejected exemplars; reviewing without them', { - jobId: job.id, - error: error instanceof Error ? error.message : String(error), - }); - return []; - } -} +import { logger } from '../logger'; +import { defaultRepoConfig, type RepoConfig } from '@codraoss/schema'; +import type { ReviewGitProvider, ReviewRuntime } from '../ports'; +import { getDiffFiles } from './diff-cache'; +import type { RejectedExemplar } from '../prompts/file-review'; +import { type PersistedReviewJob, enqueueJobPhase } from './phase-control'; +import { JOB_LEASE_SECONDS, FRESH_INVOCATION_YIELD_SECONDS } from '../constants'; + +export async function runPreparePhase( + env: ReviewRuntime, + job: PersistedReviewJob, + leaseOwner: string, + github: ReviewGitProvider, +) { + await env.jobs.updateJobStep(job.id, 'Preparation', { status: 'running' }); + const pr = await github.getPullRequest(job.owner, job.repo, job.prNumber); + const config = (job.configSnapshot ?? defaultRepoConfig) as RepoConfig; + + try { + await env.jobs.setJobPullRequestMeta(job.id, { + prTitle: pr.title ?? null, + prAuthor: pr.user?.login ?? null, + }); + } catch (error) { + logger.warn(`Failed to refresh PR metadata for job ${job.id}`, error instanceof Error ? error : new Error(String(error))); + } + + let checkRunId = job.checkRunId; + if (!checkRunId) { + const checkRun = await github.createCheckRun(job.owner, job.repo, { + headSha: pr.head.sha, + title: 'Review queued', + summary: 'Codra has started reviewing this pull request.', + }); + checkRunId = checkRun.id; + await env.jobs.updateJobCheckRun(job.id, checkRun.id); + } + + const { maxFiles } = await env.settings.getReviewSettings(); + const { files } = await getDiffFiles(env, job, github, config, maxFiles); + await env.jobs.completePreparationStep(job.id, files.length); + await env.jobs.heartbeatJobLease(job.id, leaseOwner, JOB_LEASE_SECONDS); + + if (files.length === 0) { + await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'done' }); + await enqueueJobPhase(env, job.id, 'finalize', FRESH_INVOCATION_YIELD_SECONDS); + return; + } + + if (checkRunId) { + try { + await github.updateCheckRun(job.owner, job.repo, checkRunId, { + title: `Reviewing (0/${files.length})`, + summary: 'Codra is analyzing changed files.', + }); + } catch (error) { + logger.warn(`Failed to update initial progress check run for job ${job.id}; continuing to the review phase anyway`, error instanceof Error ? error : new Error(String(error))); + } + } + await enqueueJobPhase(env, job.id, 'review', FRESH_INVOCATION_YIELD_SECONDS); +} + +export async function loadRejectedExemplars(env: Pick, job: PersistedReviewJob): Promise { + try { + const repositoryId = await env.learning.getRepositoryIdForJob(job.id); + if (repositoryId === null) return []; + const rows = await env.learning.getRejectedExemplars({ repositoryId, limit: 5 }); + return rows.map((row) => ({ title: row.title, claimType: row.claim_type })); + } catch (error) { + logger.warn('Could not load rejected exemplars; reviewing without them', { + jobId: job.id, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } +} diff --git a/packages/core/src/review/retry-policy.ts b/packages/core/src/review/retry-policy.ts index 866e69a2..29cb76d3 100644 --- a/packages/core/src/review/retry-policy.ts +++ b/packages/core/src/review/retry-policy.ts @@ -1,101 +1,101 @@ -import { logger } from '../logger'; -import { normalizeModelId, type RepoConfig } from '@codraoss/schema'; -import { isSubrequestBudgetMessage, isTimeoutMessage, matchesAnyTransientSubstring } from '@codraoss/schema/transient-errors'; -import type { ReviewRuntime } from '../ports'; -import { RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS } from '../constants'; - - -export function isRetryableFileReviewErrorMessage(message: string | null | undefined) { - if (!message) return false; - const lower = message.toLowerCase(); - - if (lower.includes('retrying later') || lower.includes('all configured review models failed')) { - return true; - } - - if (isTimeoutMessage(lower)) { - return false; - } - - return ( - matchesAnyTransientSubstring(lower) || - lower.includes('google request failed with 5') || - lower.includes('temporary') || - lower.includes('subrequest') - ); -} - -export function isSubrequestBudgetError(error: unknown): boolean { - return isSubrequestBudgetMessage(error); -} - -export function retryableModelFailureDelaySeconds(failureCount: number | null | undefined) { - if (!failureCount || failureCount < 1) return RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS[0]; - const index = Math.min(failureCount - 1, RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS.length - 1); - return RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS[index]; -} - -export function getRetryableModelFailureDelaySeconds(error: unknown) { - const record = error && typeof error === 'object' ? error as { retryAfterSeconds?: unknown } : null; - const retryAfterSeconds = - typeof record?.retryAfterSeconds === 'number' - ? record.retryAfterSeconds - : null; - return retryAfterSeconds ?? RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS[0]; -} - -export function shouldRetryExistingFileReview(review: { file_status: string; error_msg: string | null }) { - return review.file_status === 'failed' && isRetryableFileReviewErrorMessage(review.error_msg); -} - -export function countsAsHandledFileReview(review: { file_status: string; error_msg: string | null }) { - return !shouldRetryExistingFileReview(review); -} - -export function isAwaitingAsyncReview(review: { file_status: string; async_request_id?: string | null }) { - return review.file_status === 'pending' && !!review.async_request_id; -} - -export function bareModelId(model: string): string { - const normalized = normalizeModelId(model); - const colon = normalized.indexOf(':'); - return colon === -1 ? normalized : normalized.slice(colon + 1); -} - -export function configuredModelSet(config: RepoConfig) { - const models = new Set(); - const addModel = (model: string | null | undefined) => { - if (model) models.add(bareModelId(model)); - }; - - addModel(config.model?.main); - for (const fallback of config.model?.fallbacks ?? []) { - addModel(fallback); - } - for (const tier of config.model?.size_overrides ?? []) { - addModel(tier.model); - for (const fallback of tier.fallbacks ?? []) { - addModel(fallback); - } - } - - return models; -} - -export function canInheritParentFileReview(config: RepoConfig, review: { model_used: string }) { - return configuredModelSet(config).has(bareModelId(review.model_used)); -} - -export async function resolveModelProviderName(env: Pick, modelId: string | null | undefined) { - if (!modelId || modelId === 'unconfigured') return null; - - try { - const resolved = await env.modelConfigs.getResolvedModelConfig(normalizeModelId(modelId)); - return resolved?.providerName ?? null; - } catch (error) { - logger.warn(`Failed to resolve provider for model ${modelId}`, { - error: error instanceof Error ? error.message : String(error), - }); - return null; - } -} +import { logger } from '../logger'; +import { normalizeModelId, type RepoConfig } from '@codraoss/schema'; +import { isSubrequestBudgetMessage, isTimeoutMessage, matchesAnyTransientSubstring } from '@codraoss/schema/transient-errors'; +import type { ReviewRuntime } from '../ports'; +import { RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS } from '../constants'; + + +export function isRetryableFileReviewErrorMessage(message: string | null | undefined) { + if (!message) return false; + const lower = message.toLowerCase(); + + if (lower.includes('retrying later') || lower.includes('all configured review models failed')) { + return true; + } + + if (isTimeoutMessage(lower)) { + return false; + } + + return ( + matchesAnyTransientSubstring(lower) || + lower.includes('google request failed with 5') || + lower.includes('temporary') || + lower.includes('subrequest') + ); +} + +export function isSubrequestBudgetError(error: unknown): boolean { + return isSubrequestBudgetMessage(error); +} + +export function retryableModelFailureDelaySeconds(failureCount: number | null | undefined) { + if (!failureCount || failureCount < 1) return RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS[0]; + const index = Math.min(failureCount - 1, RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS.length - 1); + return RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS[index]; +} + +export function getRetryableModelFailureDelaySeconds(error: unknown) { + const record = error && typeof error === 'object' ? error as { retryAfterSeconds?: unknown } : null; + const retryAfterSeconds = + typeof record?.retryAfterSeconds === 'number' + ? record.retryAfterSeconds + : null; + return retryAfterSeconds ?? RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS[0]; +} + +export function shouldRetryExistingFileReview(review: { file_status: string; error_msg: string | null }) { + return review.file_status === 'failed' && isRetryableFileReviewErrorMessage(review.error_msg); +} + +export function countsAsHandledFileReview(review: { file_status: string; error_msg: string | null }) { + return !shouldRetryExistingFileReview(review); +} + +export function isAwaitingAsyncReview(review: { file_status: string; async_request_id?: string | null }) { + return review.file_status === 'pending' && !!review.async_request_id; +} + +export function bareModelId(model: string): string { + const normalized = normalizeModelId(model); + const colon = normalized.indexOf(':'); + return colon === -1 ? normalized : normalized.slice(colon + 1); +} + +export function configuredModelSet(config: RepoConfig) { + const models = new Set(); + const addModel = (model: string | null | undefined) => { + if (model) models.add(bareModelId(model)); + }; + + addModel(config.model?.main); + for (const fallback of config.model?.fallbacks ?? []) { + addModel(fallback); + } + for (const tier of config.model?.size_overrides ?? []) { + addModel(tier.model); + for (const fallback of tier.fallbacks ?? []) { + addModel(fallback); + } + } + + return models; +} + +export function canInheritParentFileReview(config: RepoConfig, review: { model_used: string }) { + return configuredModelSet(config).has(bareModelId(review.model_used)); +} + +export async function resolveModelProviderName(env: Pick, modelId: string | null | undefined) { + if (!modelId || modelId === 'unconfigured') return null; + + try { + const resolved = await env.modelConfigs.getResolvedModelConfig(normalizeModelId(modelId)); + return resolved?.providerName ?? null; + } catch (error) { + logger.warn(`Failed to resolve provider for model ${modelId}`, { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} diff --git a/packages/core/src/review/telemetry.ts b/packages/core/src/review/telemetry.ts index 64e49c1d..711e4312 100644 --- a/packages/core/src/review/telemetry.ts +++ b/packages/core/src/review/telemetry.ts @@ -1,82 +1,82 @@ -import { logger } from '../logger'; -import type { ReviewRuntime } from '../ports'; -import { type PersistedReviewJob } from './phase-control'; -import { bareModelId } from './retry-policy'; - -export async function sendReviewTelemetry( - env: ReviewRuntime, - job: PersistedReviewJob, - files: Array<{ path: string; lineCount: number }>, - reviews: Array<{ file_status: string; input_tokens: number | null; output_tokens: number | null; model_used: string }>, - overrides: { findingsReported: number; verdict: string; severityDistribution: Record }, - meta: { concurrencyLevel: string; retryCount: number }, -) { - try { - const doneReviews = reviews.filter((r) => r.file_status === 'done'); - - const cleanModels = Array.from( - new Set( - doneReviews.flatMap((r) => { - const model = bareModelId(r.model_used); - return model && !model.toLowerCase().includes('test') ? [model] : []; - }), - ), - ); - - const extractExtension = (filePath: string): string => { - const name = filePath.split('/').pop() || filePath; - const dotIndex = name.lastIndexOf('.'); - if (dotIndex <= 0) return ''; - return name.slice(dotIndex + 1).toLowerCase(); - }; - - await env.telemetry.send({ - linesReviewed: files.reduce((sum, file) => sum + file.lineCount, 0), - inputTokens: doneReviews.reduce((sum, r) => sum + (r.input_tokens ?? 0), 0), - outputTokens: doneReviews.reduce((sum, r) => sum + (r.output_tokens ?? 0), 0), - modelsUsed: cleanModels, - fileExtensions: Array.from(new Set(files.flatMap((f) => { - const extension = extractExtension(f.path); - return extension ? [extension] : []; - }))), - triggerType: job.trigger, - reviewDurationMs: Math.max(0, env.clock.now() - new Date(job.createdAt).getTime()), - filesReviewed: files.length, - concurrencyLevel: meta.concurrencyLevel, - prTotalLinesChanged: files.reduce((sum, file) => sum + file.lineCount, 0), - retryCount: meta.retryCount, - ...overrides, - }); - } catch (e) { - logger.error('Failed to send telemetry', e instanceof Error ? e : new Error(String(e))); - } -} - -export async function loadSuppressedFingerprints(env: Pick, jobId: string) { - const posted = new Map>(); - const rejected = new Set(); - const postedV2 = new Set(); - const rejectedV2 = new Set(); - - try { - for (const row of await env.fileReviews.getSuppressedFindings(jobId)) { - if (!row.anchored) { - if (row.fingerprint) rejected.add(row.fingerprint); - if (row.fingerprint_v2) rejectedV2.add(row.fingerprint_v2); - continue; - } - if (row.fingerprint_v2) postedV2.add(row.fingerprint_v2); - if (!row.fingerprint || !row.anchor_hash) continue; - const anchors = posted.get(row.fingerprint) ?? new Set(); - anchors.add(row.anchor_hash); - posted.set(row.fingerprint, anchors); - } - } catch (error) { - logger.warn('Could not load suppressed findings; posting without cross-run dedupe', { - jobId, - error: error instanceof Error ? error.message : String(error), - }); - } - - return { posted, rejected, postedV2, rejectedV2 }; -} +import { logger } from '../logger'; +import type { ReviewRuntime } from '../ports'; +import { type PersistedReviewJob } from './phase-control'; +import { bareModelId } from './retry-policy'; + +export async function sendReviewTelemetry( + env: ReviewRuntime, + job: PersistedReviewJob, + files: Array<{ path: string; lineCount: number }>, + reviews: Array<{ file_status: string; input_tokens: number | null; output_tokens: number | null; model_used: string }>, + overrides: { findingsReported: number; verdict: string; severityDistribution: Record }, + meta: { concurrencyLevel: string; retryCount: number }, +) { + try { + const doneReviews = reviews.filter((r) => r.file_status === 'done'); + + const cleanModels = Array.from( + new Set( + doneReviews.flatMap((r) => { + const model = bareModelId(r.model_used); + return model && !model.toLowerCase().includes('test') ? [model] : []; + }), + ), + ); + + const extractExtension = (filePath: string): string => { + const name = filePath.split('/').pop() || filePath; + const dotIndex = name.lastIndexOf('.'); + if (dotIndex <= 0) return ''; + return name.slice(dotIndex + 1).toLowerCase(); + }; + + await env.telemetry.send({ + linesReviewed: files.reduce((sum, file) => sum + file.lineCount, 0), + inputTokens: doneReviews.reduce((sum, r) => sum + (r.input_tokens ?? 0), 0), + outputTokens: doneReviews.reduce((sum, r) => sum + (r.output_tokens ?? 0), 0), + modelsUsed: cleanModels, + fileExtensions: Array.from(new Set(files.flatMap((f) => { + const extension = extractExtension(f.path); + return extension ? [extension] : []; + }))), + triggerType: job.trigger, + reviewDurationMs: Math.max(0, env.clock.now() - new Date(job.createdAt).getTime()), + filesReviewed: files.length, + concurrencyLevel: meta.concurrencyLevel, + prTotalLinesChanged: files.reduce((sum, file) => sum + file.lineCount, 0), + retryCount: meta.retryCount, + ...overrides, + }); + } catch (e) { + logger.error('Failed to send telemetry', e instanceof Error ? e : new Error(String(e))); + } +} + +export async function loadSuppressedFingerprints(env: Pick, jobId: string) { + const posted = new Map>(); + const rejected = new Set(); + const postedV2 = new Set(); + const rejectedV2 = new Set(); + + try { + for (const row of await env.fileReviews.getSuppressedFindings(jobId)) { + if (!row.anchored) { + if (row.fingerprint) rejected.add(row.fingerprint); + if (row.fingerprint_v2) rejectedV2.add(row.fingerprint_v2); + continue; + } + if (row.fingerprint_v2) postedV2.add(row.fingerprint_v2); + if (!row.fingerprint || !row.anchor_hash) continue; + const anchors = posted.get(row.fingerprint) ?? new Set(); + anchors.add(row.anchor_hash); + posted.set(row.fingerprint, anchors); + } + } catch (error) { + logger.warn('Could not load suppressed findings; posting without cross-run dedupe', { + jobId, + error: error instanceof Error ? error.message : String(error), + }); + } + + return { posted, rejected, postedV2, rejectedV2 }; +} diff --git a/packages/core/src/rules/detect.ts b/packages/core/src/rules/detect.ts index 25bedf93..067a029d 100644 --- a/packages/core/src/rules/detect.ts +++ b/packages/core/src/rules/detect.ts @@ -1,147 +1,147 @@ -import type { ClaimType, ParsedReviewComment } from '@codraoss/schema'; -import type { DiffLine, FileDiff } from '../diff'; -import { commentSyntaxFor, stripCommentsAndStrings } from '../claim-checks'; -import { buildAnchorHash, buildFindingFingerprint, buildFindingFingerprintV2, normalizeDiffText } from '../fingerprint'; -import { CLAIM_TYPE_CATEGORY } from '@codraoss/schema'; -import { RULES, type Rule } from './table'; - -import { MAX_RULE_SCAN_ADDED_LINES } from '../constants'; - -export type RuleHit = { - rule: Rule; - line: DiffLine; - shadow: boolean; -}; - -export type RuleScanStats = { - addedLinesScanned: number; - sievePassed: number; - hits: number; - shadowHits: number; - suppressedAsMoved: number; - unstrippable: number; - truncated: boolean; - byRule: Record; -}; - -export type RuleScanResult = { hits: RuleHit[]; stats: RuleScanStats }; - -export type RuleScanOptions = { - disabledRuleIds?: readonly string[]; - shadowRuleIds?: readonly string[]; - deniedClaimTypes?: readonly ClaimType[]; -}; - -function extensionOf(path: string) { - return path.toLowerCase().split('.').pop() ?? ''; -} - -function ruleApplies(rule: Rule, ext: string) { - return !rule.extensions || rule.extensions.includes(ext); -} - -export function scanFileForRuleHits(file: FileDiff, options: RuleScanOptions = {}): RuleScanResult { - const stats: RuleScanStats = { - addedLinesScanned: 0, - sievePassed: 0, - hits: 0, - shadowHits: 0, - suppressedAsMoved: 0, - unstrippable: 0, - truncated: false, - byRule: {}, - }; - const hits: RuleHit[] = []; - - if (file.isDeleted || file.isBinary || !file.path) return { hits, stats }; - - const ext = extensionOf(file.path); - const denied = new Set(options.deniedClaimTypes ?? []); - const disabled = new Set(options.disabledRuleIds ?? []); - const shadowIds = new Set(options.shadowRuleIds ?? []); - - const active = RULES.filter((rule) => - rule.enabled - && !disabled.has(rule.id) - && !denied.has(rule.claimType) - && ruleApplies(rule, ext)); - if (active.length === 0) return { hits, stats }; - - const triggers = [...new Set(active.flatMap((rule) => rule.triggers))]; - const syntax = commentSyntaxFor(file.path); - - for (const hunk of file.hunks) { - const removed = new Set(); - for (const l of hunk.lines) { - if (l.kind === 'del') removed.add(normalizeDiffText(l.content)); - } - - for (const line of hunk.lines) { - if (line.kind !== 'add') continue; - if (stats.addedLinesScanned >= MAX_RULE_SCAN_ADDED_LINES) { - stats.truncated = true; - break; - } - stats.addedLinesScanned += 1; - - const raw = line.content; - if (!triggers.some((trigger) => raw.includes(trigger))) continue; - stats.sievePassed += 1; - - const stripped = stripCommentsAndStrings(raw, syntax); - if (stripped === null) { - stats.unstrippable += 1; - continue; - } - - for (const rule of active) { - if (!rule.triggers.some((trigger) => raw.includes(trigger))) continue; - if (!rule.pattern.test(stripped)) continue; - if (rule.rejectRaw?.test(raw)) continue; - - if (removed.has(normalizeDiffText(raw))) { - stats.suppressedAsMoved += 1; - continue; - } - - const shadow = shadowIds.has(rule.id); - hits.push({ rule, line, shadow }); - stats.byRule[rule.id] = (stats.byRule[rule.id] ?? 0) + 1; - if (shadow) stats.shadowHits += 1; - else stats.hits += 1; - break; - } - } - if (stats.truncated) break; - } - - return { hits, stats }; -} - -export function ruleHitsToComments(file: FileDiff, result: RuleScanResult): ParsedReviewComment[] { - const comments: ParsedReviewComment[] = []; - for (const hit of result.hits) { - if (hit.shadow) continue; - - const { rule, line } = hit; - const anchorHash = buildAnchorHash(line.content); - comments.push({ - path: file.path, - line: line.newLineNumber ?? null, - position: line.position ?? null, - severity: rule.severity, - category: CLAIM_TYPE_CATEGORY[rule.claimType] ?? 'quality', - title: rule.title, - body: rule.body, - evidence: line.content, - anchorHash, - claimType: rule.claimType, - fingerprint: buildFindingFingerprint(file.path, `${rule.title} @${anchorHash}`), - fingerprintV2: buildFindingFingerprintV2(file.path, rule.claimType, anchorHash), - source: 'rule' as const, - ruleId: rule.id, - } satisfies ParsedReviewComment); - } - - return comments; -} +import type { ClaimType, ParsedReviewComment } from '@codraoss/schema'; +import type { DiffLine, FileDiff } from '../diff'; +import { commentSyntaxFor, stripCommentsAndStrings } from '../claim-checks'; +import { buildAnchorHash, buildFindingFingerprint, buildFindingFingerprintV2, normalizeDiffText } from '../fingerprint'; +import { CLAIM_TYPE_CATEGORY } from '@codraoss/schema'; +import { RULES, type Rule } from './table'; + +import { MAX_RULE_SCAN_ADDED_LINES } from '../constants'; + +export type RuleHit = { + rule: Rule; + line: DiffLine; + shadow: boolean; +}; + +export type RuleScanStats = { + addedLinesScanned: number; + sievePassed: number; + hits: number; + shadowHits: number; + suppressedAsMoved: number; + unstrippable: number; + truncated: boolean; + byRule: Record; +}; + +export type RuleScanResult = { hits: RuleHit[]; stats: RuleScanStats }; + +export type RuleScanOptions = { + disabledRuleIds?: readonly string[]; + shadowRuleIds?: readonly string[]; + deniedClaimTypes?: readonly ClaimType[]; +}; + +function extensionOf(path: string) { + return path.toLowerCase().split('.').pop() ?? ''; +} + +function ruleApplies(rule: Rule, ext: string) { + return !rule.extensions || rule.extensions.includes(ext); +} + +export function scanFileForRuleHits(file: FileDiff, options: RuleScanOptions = {}): RuleScanResult { + const stats: RuleScanStats = { + addedLinesScanned: 0, + sievePassed: 0, + hits: 0, + shadowHits: 0, + suppressedAsMoved: 0, + unstrippable: 0, + truncated: false, + byRule: {}, + }; + const hits: RuleHit[] = []; + + if (file.isDeleted || file.isBinary || !file.path) return { hits, stats }; + + const ext = extensionOf(file.path); + const denied = new Set(options.deniedClaimTypes ?? []); + const disabled = new Set(options.disabledRuleIds ?? []); + const shadowIds = new Set(options.shadowRuleIds ?? []); + + const active = RULES.filter((rule) => + rule.enabled + && !disabled.has(rule.id) + && !denied.has(rule.claimType) + && ruleApplies(rule, ext)); + if (active.length === 0) return { hits, stats }; + + const triggers = [...new Set(active.flatMap((rule) => rule.triggers))]; + const syntax = commentSyntaxFor(file.path); + + for (const hunk of file.hunks) { + const removed = new Set(); + for (const l of hunk.lines) { + if (l.kind === 'del') removed.add(normalizeDiffText(l.content)); + } + + for (const line of hunk.lines) { + if (line.kind !== 'add') continue; + if (stats.addedLinesScanned >= MAX_RULE_SCAN_ADDED_LINES) { + stats.truncated = true; + break; + } + stats.addedLinesScanned += 1; + + const raw = line.content; + if (!triggers.some((trigger) => raw.includes(trigger))) continue; + stats.sievePassed += 1; + + const stripped = stripCommentsAndStrings(raw, syntax); + if (stripped === null) { + stats.unstrippable += 1; + continue; + } + + for (const rule of active) { + if (!rule.triggers.some((trigger) => raw.includes(trigger))) continue; + if (!rule.pattern.test(stripped)) continue; + if (rule.rejectRaw?.test(raw)) continue; + + if (removed.has(normalizeDiffText(raw))) { + stats.suppressedAsMoved += 1; + continue; + } + + const shadow = shadowIds.has(rule.id); + hits.push({ rule, line, shadow }); + stats.byRule[rule.id] = (stats.byRule[rule.id] ?? 0) + 1; + if (shadow) stats.shadowHits += 1; + else stats.hits += 1; + break; + } + } + if (stats.truncated) break; + } + + return { hits, stats }; +} + +export function ruleHitsToComments(file: FileDiff, result: RuleScanResult): ParsedReviewComment[] { + const comments: ParsedReviewComment[] = []; + for (const hit of result.hits) { + if (hit.shadow) continue; + + const { rule, line } = hit; + const anchorHash = buildAnchorHash(line.content); + comments.push({ + path: file.path, + line: line.newLineNumber ?? null, + position: line.position ?? null, + severity: rule.severity, + category: CLAIM_TYPE_CATEGORY[rule.claimType] ?? 'quality', + title: rule.title, + body: rule.body, + evidence: line.content, + anchorHash, + claimType: rule.claimType, + fingerprint: buildFindingFingerprint(file.path, `${rule.title} @${anchorHash}`), + fingerprintV2: buildFindingFingerprintV2(file.path, rule.claimType, anchorHash), + source: 'rule' as const, + ruleId: rule.id, + } satisfies ParsedReviewComment); + } + + return comments; +} diff --git a/packages/core/src/rules/table.ts b/packages/core/src/rules/table.ts index 52c770ab..3854bd1e 100644 --- a/packages/core/src/rules/table.ts +++ b/packages/core/src/rules/table.ts @@ -1,133 +1,133 @@ -import type { ClaimType, reviewSeverities } from '@codraoss/schema'; - -type ReviewSeverity = typeof reviewSeverities[number]; - -export type Rule = { - id: string; - claimType: ClaimType; - severity: ReviewSeverity; - title: string; - body: string; - triggers: readonly string[]; - pattern: RegExp; - rejectRaw?: RegExp; - extensions?: readonly string[]; - enabled: boolean; -}; - -const ts = ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'] as const; - -export const RULES: readonly Rule[] = [ - { - id: 'empty-catch', - claimType: 'swallowed_error', - severity: 'P2', - title: 'Empty catch block swallows the error', - body: 'This `catch` has no body, so the error is discarded with no log, no rethrow and no recovery. ' - + 'A failure here becomes silent. If the error is genuinely expected, say so in a comment inside the block.', - triggers: ['catch'], - pattern: /\bcatch\s*(\([^)]*\))?\s*\{\s*\}/, - rejectRaw: /\bcatch\s*(\([^)]*\))?\s*\{\s*(?:\/\/|\/\*)/, - extensions: ts, - enabled: true, - }, - { - id: 'debugger-statement', - claimType: 'other', - severity: 'P1', - title: '`debugger` statement left in the diff', - body: 'A `debugger` statement halts execution whenever devtools are open. This is almost always ' - + 'a leftover from local debugging.', - triggers: ['debugger'], - pattern: /^\s*debugger\s*;?\s*$/, - extensions: ts, - enabled: true, - }, - { - id: 'focused-test', - claimType: 'other', - severity: 'P1', - title: 'Focused test will skip the rest of the suite', - body: 'A focused test (`.only`) silently prevents every other test in the file from running, so ' - + 'CI stays green while covering almost nothing.', - triggers: ['.only'], - pattern: /\b(?:describe|it|test|context|suite)\s*\.\s*only\s*\(/, - extensions: ts, - enabled: true, - }, - { - id: 'dynamic-code-exec', - claimType: 'unsafe_dynamic_code', - severity: 'P1', - title: 'Dynamic code execution', - body: '`eval` and the `Function` constructor execute arbitrary strings as code. If any part of ' - + 'that string can be influenced by input, this is remote code execution.', - triggers: ['eval(', 'Function('], - pattern: /(?:^|[^.\w])eval\s*\(|new\s+Function\s*\(/, - extensions: ts, - enabled: true, - }, - { - id: 'dynamic-html-sink', - claimType: 'unsafe_dom_sink', - severity: 'P1', - title: 'Unsanitized value assigned to an HTML sink', - body: 'Assigning a non-literal to `innerHTML`/`outerHTML` (or passing one to `insertAdjacentHTML`) ' - + 'executes any markup it contains. If the value can carry user input this is XSS.', - triggers: ['innerHTML', 'outerHTML', 'insertAdjacentHTML'], - pattern: /\.(?:inner|outer)HTML\s*=\s*[A-Za-z_$][\w$.[\]()]*|insertAdjacentHTML\s*\([^)]*,\s*[A-Za-z_$]/, - extensions: ts, - enabled: true, - }, - { - id: 'mutable-default-arg', - claimType: 'mutable_default_arg', - severity: 'P2', - title: 'Mutable default argument', - body: 'Python evaluates a default argument once, at definition time, so this list/dict/set is ' - + 'shared by every call. Mutating it leaks state between invocations. Use `None` and build the ' - + 'value inside the function.', - triggers: ['def '], - pattern: /\bdef\s+\w+\s*\([^)]*=\s*(?:\[\s*\]|\{\s*\}|set\s*\(\s*\)|dict\s*\(\s*\)|list\s*\(\s*\))/, - extensions: ['py'], - enabled: true, - }, - { - id: 'destructive-migration', - claimType: 'destructive_migration', - severity: 'P1', - title: 'Destructive migration statement', - body: 'This statement discards data irreversibly. On a forward-only migration chain there is no ' - + 'rollback: confirm the column/table is genuinely unused and that a backup exists.', - triggers: ['DROP', 'TRUNCATE', 'drop', 'truncate'], - pattern: /\b(?:drop\s+(?:column|table)|truncate\s+table|truncate\s+\w)/i, - extensions: ['sql'], - enabled: true, - }, - - - { - id: 'hardcoded-secret', - claimType: 'hardcoded_secret', - severity: 'P0', - title: 'Possible hardcoded credential', - body: 'This looks like a literal credential committed to the repository. If it is real, rotate it ' - + 'and move it to a secret binding.', - triggers: ['sk-', 'AIza', 'ghp_', 'AKIA'], - pattern: /\b(?:sk-[A-Za-z0-9]{20,}|AIza[0-9A-Za-z_-]{30,}|gh[pousr]_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16})\b/, - enabled: false, - }, - { - id: 'insecure-random', - claimType: 'insecure_randomness', - severity: 'P2', - title: '`Math.random()` used for a security-sensitive value', - body: '`Math.random()` is not cryptographically secure and its output is predictable. Use ' - + '`crypto.getRandomValues()` for tokens, ids or anything an attacker should not guess.', - triggers: ['Math.random'], - pattern: /\b(?:token|secret|key|nonce|salt|password|session|id)\w*\s*=[^=]*Math\.random\s*\(/i, - extensions: ts, - enabled: false, - }, -]; - +import type { ClaimType, reviewSeverities } from '@codraoss/schema'; + +type ReviewSeverity = typeof reviewSeverities[number]; + +export type Rule = { + id: string; + claimType: ClaimType; + severity: ReviewSeverity; + title: string; + body: string; + triggers: readonly string[]; + pattern: RegExp; + rejectRaw?: RegExp; + extensions?: readonly string[]; + enabled: boolean; +}; + +const ts = ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'] as const; + +export const RULES: readonly Rule[] = [ + { + id: 'empty-catch', + claimType: 'swallowed_error', + severity: 'P2', + title: 'Empty catch block swallows the error', + body: 'This `catch` has no body, so the error is discarded with no log, no rethrow and no recovery. ' + + 'A failure here becomes silent. If the error is genuinely expected, say so in a comment inside the block.', + triggers: ['catch'], + pattern: /\bcatch\s*(\([^)]*\))?\s*\{\s*\}/, + rejectRaw: /\bcatch\s*(\([^)]*\))?\s*\{\s*(?:\/\/|\/\*)/, + extensions: ts, + enabled: true, + }, + { + id: 'debugger-statement', + claimType: 'other', + severity: 'P1', + title: '`debugger` statement left in the diff', + body: 'A `debugger` statement halts execution whenever devtools are open. This is almost always ' + + 'a leftover from local debugging.', + triggers: ['debugger'], + pattern: /^\s*debugger\s*;?\s*$/, + extensions: ts, + enabled: true, + }, + { + id: 'focused-test', + claimType: 'other', + severity: 'P1', + title: 'Focused test will skip the rest of the suite', + body: 'A focused test (`.only`) silently prevents every other test in the file from running, so ' + + 'CI stays green while covering almost nothing.', + triggers: ['.only'], + pattern: /\b(?:describe|it|test|context|suite)\s*\.\s*only\s*\(/, + extensions: ts, + enabled: true, + }, + { + id: 'dynamic-code-exec', + claimType: 'unsafe_dynamic_code', + severity: 'P1', + title: 'Dynamic code execution', + body: '`eval` and the `Function` constructor execute arbitrary strings as code. If any part of ' + + 'that string can be influenced by input, this is remote code execution.', + triggers: ['eval(', 'Function('], + pattern: /(?:^|[^.\w])eval\s*\(|new\s+Function\s*\(/, + extensions: ts, + enabled: true, + }, + { + id: 'dynamic-html-sink', + claimType: 'unsafe_dom_sink', + severity: 'P1', + title: 'Unsanitized value assigned to an HTML sink', + body: 'Assigning a non-literal to `innerHTML`/`outerHTML` (or passing one to `insertAdjacentHTML`) ' + + 'executes any markup it contains. If the value can carry user input this is XSS.', + triggers: ['innerHTML', 'outerHTML', 'insertAdjacentHTML'], + pattern: /\.(?:inner|outer)HTML\s*=\s*[A-Za-z_$][\w$.[\]()]*|insertAdjacentHTML\s*\([^)]*,\s*[A-Za-z_$]/, + extensions: ts, + enabled: true, + }, + { + id: 'mutable-default-arg', + claimType: 'mutable_default_arg', + severity: 'P2', + title: 'Mutable default argument', + body: 'Python evaluates a default argument once, at definition time, so this list/dict/set is ' + + 'shared by every call. Mutating it leaks state between invocations. Use `None` and build the ' + + 'value inside the function.', + triggers: ['def '], + pattern: /\bdef\s+\w+\s*\([^)]*=\s*(?:\[\s*\]|\{\s*\}|set\s*\(\s*\)|dict\s*\(\s*\)|list\s*\(\s*\))/, + extensions: ['py'], + enabled: true, + }, + { + id: 'destructive-migration', + claimType: 'destructive_migration', + severity: 'P1', + title: 'Destructive migration statement', + body: 'This statement discards data irreversibly. On a forward-only migration chain there is no ' + + 'rollback: confirm the column/table is genuinely unused and that a backup exists.', + triggers: ['DROP', 'TRUNCATE', 'drop', 'truncate'], + pattern: /\b(?:drop\s+(?:column|table)|truncate\s+table|truncate\s+\w)/i, + extensions: ['sql'], + enabled: true, + }, + + + { + id: 'hardcoded-secret', + claimType: 'hardcoded_secret', + severity: 'P0', + title: 'Possible hardcoded credential', + body: 'This looks like a literal credential committed to the repository. If it is real, rotate it ' + + 'and move it to a secret binding.', + triggers: ['sk-', 'AIza', 'ghp_', 'AKIA'], + pattern: /\b(?:sk-[A-Za-z0-9]{20,}|AIza[0-9A-Za-z_-]{30,}|gh[pousr]_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16})\b/, + enabled: false, + }, + { + id: 'insecure-random', + claimType: 'insecure_randomness', + severity: 'P2', + title: '`Math.random()` used for a security-sensitive value', + body: '`Math.random()` is not cryptographically secure and its output is predictable. Use ' + + '`crypto.getRandomValues()` for tokens, ids or anything an attacker should not guess.', + triggers: ['Math.random'], + pattern: /\b(?:token|secret|key|nonce|salt|password|session|id)\w*\s*=[^=]*Math\.random\s*\(/i, + extensions: ts, + enabled: false, + }, +]; + diff --git a/packages/core/src/token-tracker.ts b/packages/core/src/token-tracker.ts index bcd1f904..abc5edb1 100644 --- a/packages/core/src/token-tracker.ts +++ b/packages/core/src/token-tracker.ts @@ -1,119 +1,119 @@ -import { logger } from './logger'; - -export interface TokenUsage { - input: number; - output: number; -} - -export interface ModelUsage extends TokenUsage { - model: string; - calls: number; -} - -export type WastedAttemptReason = 'rate-limited' | 'error'; - -export interface WastedUsage { - attempts: number; - estimatedInput: number; - skips: number; - byReason: Record; -} - -export class TokenTracker { - private usage: Map = new Map(); - private wasted = { attempts: 0, estimatedInput: 0, skips: 0 }; - private wastedByReason: Map = new Map(); - private subrequests = 0; - private readonly MAX_SUBREQUESTS = 50; - private readonly SAFE_MARGIN = 25; - - incrementSubrequests(count = 1) { - this.subrequests += count; - } - - getSubrequestCount() { - return this.subrequests; - } - - hasRemainingSubrequests(needed = 1) { - return this.subrequests + needed <= this.MAX_SUBREQUESTS; - } - - isNearLimit() { - return this.subrequests >= this.MAX_SUBREQUESTS - this.SAFE_MARGIN; - } - - remainingSafeBudget() { - return Math.max(0, this.MAX_SUBREQUESTS - this.SAFE_MARGIN - this.subrequests); - } - - record(model: string, input: number, output: number) { - const existing = this.usage.get(model) || { model, input: 0, output: 0, calls: 0 }; - - this.usage.set(model, { - model, - input: existing.input + input, - output: existing.output + output, - calls: existing.calls + 1, - }); - - logger.debug(`Token usage recorded for ${model}`, { - input, - output, - totalInput: existing.input + input, - totalOutput: existing.output + output - }); - } - - recordFailedAttempt(model: string, estimatedInputTokens: number, reason: WastedAttemptReason) { - this.wasted.attempts += 1; - this.wasted.estimatedInput += estimatedInputTokens; - this.wastedByReason.set(reason, (this.wastedByReason.get(reason) ?? 0) + 1); - - logger.debug(`Wasted model attempt on ${model}`, { estimatedInput: estimatedInputTokens, reason }); - } - - recordSkippedCall(model: string, reason: string) { - this.wasted.skips += 1; - - logger.debug(`Skipped model call on ${model}`, { reason }); - } - - getWasted(): WastedUsage { - return { ...this.wasted, byReason: Object.fromEntries(this.wastedByReason) }; - } - - getTotalUsage(): TokenUsage { - let input = 0; - let output = 0; - for (const modelUsage of this.usage.values()) { - input += modelUsage.input; - output += modelUsage.output; - } - return { input, output }; - } - - getBreakdown(): ModelUsage[] { - return Array.from(this.usage.values()); - } - - merge(other: TokenTracker) { - for (const usage of other.getBreakdown()) { - this.record(usage.model, usage.input, usage.output); - } - - const otherWasted = other.getWasted(); - this.wasted.attempts += otherWasted.attempts; - this.wasted.estimatedInput += otherWasted.estimatedInput; - this.wasted.skips += otherWasted.skips; - for (const [reason, count] of Object.entries(otherWasted.byReason)) { - this.wastedByReason.set(reason, (this.wastedByReason.get(reason) ?? 0) + count); - } - } - - reset() { - this.usage.clear(); - this.wasted = { attempts: 0, estimatedInput: 0, skips: 0 }; - this.wastedByReason.clear(); - } -} +import { logger } from './logger'; + +export interface TokenUsage { + input: number; + output: number; +} + +export interface ModelUsage extends TokenUsage { + model: string; + calls: number; +} + +export type WastedAttemptReason = 'rate-limited' | 'error'; + +export interface WastedUsage { + attempts: number; + estimatedInput: number; + skips: number; + byReason: Record; +} + +export class TokenTracker { + private usage: Map = new Map(); + private wasted = { attempts: 0, estimatedInput: 0, skips: 0 }; + private wastedByReason: Map = new Map(); + private subrequests = 0; + private readonly MAX_SUBREQUESTS = 50; + private readonly SAFE_MARGIN = 25; + + incrementSubrequests(count = 1) { + this.subrequests += count; + } + + getSubrequestCount() { + return this.subrequests; + } + + hasRemainingSubrequests(needed = 1) { + return this.subrequests + needed <= this.MAX_SUBREQUESTS; + } + + isNearLimit() { + return this.subrequests >= this.MAX_SUBREQUESTS - this.SAFE_MARGIN; + } + + remainingSafeBudget() { + return Math.max(0, this.MAX_SUBREQUESTS - this.SAFE_MARGIN - this.subrequests); + } + + record(model: string, input: number, output: number) { + const existing = this.usage.get(model) || { model, input: 0, output: 0, calls: 0 }; + + this.usage.set(model, { + model, + input: existing.input + input, + output: existing.output + output, + calls: existing.calls + 1, + }); + + logger.debug(`Token usage recorded for ${model}`, { + input, + output, + totalInput: existing.input + input, + totalOutput: existing.output + output + }); + } + + recordFailedAttempt(model: string, estimatedInputTokens: number, reason: WastedAttemptReason) { + this.wasted.attempts += 1; + this.wasted.estimatedInput += estimatedInputTokens; + this.wastedByReason.set(reason, (this.wastedByReason.get(reason) ?? 0) + 1); + + logger.debug(`Wasted model attempt on ${model}`, { estimatedInput: estimatedInputTokens, reason }); + } + + recordSkippedCall(model: string, reason: string) { + this.wasted.skips += 1; + + logger.debug(`Skipped model call on ${model}`, { reason }); + } + + getWasted(): WastedUsage { + return { ...this.wasted, byReason: Object.fromEntries(this.wastedByReason) }; + } + + getTotalUsage(): TokenUsage { + let input = 0; + let output = 0; + for (const modelUsage of this.usage.values()) { + input += modelUsage.input; + output += modelUsage.output; + } + return { input, output }; + } + + getBreakdown(): ModelUsage[] { + return Array.from(this.usage.values()); + } + + merge(other: TokenTracker) { + for (const usage of other.getBreakdown()) { + this.record(usage.model, usage.input, usage.output); + } + + const otherWasted = other.getWasted(); + this.wasted.attempts += otherWasted.attempts; + this.wasted.estimatedInput += otherWasted.estimatedInput; + this.wasted.skips += otherWasted.skips; + for (const [reason, count] of Object.entries(otherWasted.byReason)) { + this.wastedByReason.set(reason, (this.wastedByReason.get(reason) ?? 0) + count); + } + } + + reset() { + this.usage.clear(); + this.wasted = { attempts: 0, estimatedInput: 0, skips: 0 }; + this.wastedByReason.clear(); + } +} diff --git a/packages/core/test/in-memory.ts b/packages/core/test/in-memory.ts index 0902108a..6c5394ad 100644 --- a/packages/core/test/in-memory.ts +++ b/packages/core/test/in-memory.ts @@ -1,415 +1,415 @@ - -import { defaultRepoConfig, reviewSettingsSchema, type ParsedReviewComment, type RepoConfig, type ReviewSettings } from '@codraoss/schema'; -import type { - BulkFileReviewInput, - FileReviewRow, - JobLeaseClaim, - JobRow, - PersistedReviewJob, - ReviewRuntime, -} from '../src/ports'; - -export type Recorded = { - /** Every port write, in order, so a test can assert on the sequence rather than the end state. */ - calls: string[]; - jobs: Map; - fileReviews: Map; - kv: Map; - postedReviews: Array<{ body: string; comments: Array<{ path: string; body: string }> }>; - checkRuns: Array<{ title: string; status?: string; conclusion?: string }>; - telemetry: unknown[]; -}; - -const ISO = '2026-01-01T00:00:00.000Z'; - -export function makeJob(overrides: Partial = {}): PersistedReviewJob { - return { - id: '11111111-2222-4333-8444-555555555555', - owner: 'acme', - repo: 'widgets', - installationId: '42', - prNumber: 7, - prTitle: 'Add a retry', - prAuthor: 'octocat', - commitSha: 'a'.repeat(40), - trigger: 'auto', - status: 'queued', - verdict: null, - fileCount: 0, - commentCount: 0, - totalInputTokens: 0, - totalOutputTokens: 0, - createdAt: ISO, - updatedAt: ISO, - startedAt: null, - finishedAt: null, - errorMessage: null, - steps: [], - checkRunId: null, - configSnapshot: null, - ...overrides, - }; -} - -export const SAMPLE_DIFF = `diff --git a/src/retry.ts b/src/retry.ts -index 1111111..2222222 100644 ---- a/src/retry.ts -+++ b/src/retry.ts -@@ -1,3 +1,6 @@ - export function retry() { -+ const delay = 1000; -+ return delay; - } -diff --git a/src/log.ts b/src/log.ts -index 3333333..4444444 100644 ---- a/src/log.ts -+++ b/src/log.ts -@@ -1,2 +1,4 @@ - export function log(message: string) { -+ console.log(message); - } -`; - -export type ModelBehaviour = { - /** Findings the model "reports" per file path. */ - findingsByPath?: Record>; - /** Verdicts the verifier returns, keyed by candidate index. Absent means it keeps everything. */ - verifyVerdicts?: Record; - failEveryCall?: Error; -}; - -export function createInMemoryRuntime( - seed: { job?: Partial; settings?: Partial; config?: RepoConfig; model?: ModelBehaviour } = {}, -): { runtime: ReviewRuntime; recorded: Recorded; now: { value: number } } { - const config = seed.config ?? defaultRepoConfig; - const job = makeJob({ configSnapshot: config, ...seed.job }); - const model = seed.model ?? {}; - - const recorded: Recorded = { - calls: [], - jobs: new Map([[job.id, job]]), - fileReviews: new Map(), - kv: new Map(), - postedReviews: [], - checkRuns: [], - telemetry: [], - }; - const record = (name: string) => recorded.calls.push(name); - - const now = { value: 1_700_000_000_000 }; - - const settings = reviewSettingsSchema.parse({ maxFiles: 25, ...seed.settings }); - - const toRow = (j: PersistedReviewJob): JobRow => ({ ...j, status: j.status, check_run_id: j.checkRunId ?? null }); - const patch = (jobId: string, changes: Partial) => { - const existing = recorded.jobs.get(jobId); - if (existing) recorded.jobs.set(jobId, { ...existing, ...changes }); - }; - const setStep = (jobId: string, name: string, status: 'pending' | 'running' | 'done' | 'failed') => { - const existing = recorded.jobs.get(jobId); - if (!existing) return; - const steps = existing.steps.filter((step) => step.name !== name); - recorded.jobs.set(jobId, { ...existing, steps: [...steps, { name, status, startedAt: ISO, finishedAt: status === 'done' ? ISO : null }] }); - }; - - const emptyRow = (jobId: string, input: { filePath: string; diffLineCount?: number }): FileReviewRow => ({ - id: `fr-${input.filePath}`, - job_id: jobId, - file_path: input.filePath, - file_status: 'pending', - model_used: 'fake/model', - diff_line_count: input.diffLineCount ?? 0, - diff_input: null, - raw_ai_output: null, - parsed_comments: [], - input_tokens: null, - output_tokens: null, - duration_ms: null, - verdict: null, - file_summary: null, - overall_correctness: null, - confidence_score: null, - error_msg: null, - model_provider: null, - transient_error_count: 0, - async_request_id: null, - async_model: null, - withheld_counts: {}, - batch_size: null, - }); - - const findingsFor = (path: string): ParsedReviewComment[] => - (model.findingsByPath?.[path] ?? []).map((finding) => ({ - path, - line: finding.line, - title: finding.title, - body: finding.body, - severity: 'P1' as const, - confidenceScore: 90, - evidence: finding.evidence, - })) as ParsedReviewComment[]; - - const runtime: ReviewRuntime = { - kv: { - get: async (key) => recorded.kv.get(key) ?? null, - put: async (key, value) => { recorded.kv.set(key, value); }, - }, - clock: { now: () => now.value }, - ids: { randomUUID: () => 'lease-owner-0001' }, - - botUsername: 'codra-bot', - - jobs: { - mapJob: (row) => recorded.jobs.get(String(row.id))!, - getJobForProcessing: async (jobId) => { - const found = recorded.jobs.get(jobId); - return found ? toRow(found) : null; - }, - claimJobLease: async (jobId): Promise => { - record('claimJobLease'); - const found = recorded.jobs.get(jobId); - if (!found) return { status: 'missing' }; - patch(jobId, { status: found.status === 'queued' ? 'running' : found.status }); - return { status: 'claimed', row: toRow(recorded.jobs.get(jobId)!) }; - }, - heartbeatJobLease: async () => { record('heartbeat'); }, - releaseJobLease: async () => { record('releaseJobLease'); }, - markJobContinuationQueued: async () => 1, - resetJobContinuationCount: async () => {}, - getOtherRunningJobsCount: async () => 0, - - recoverExpiredJobLeases: async () => ({ requeuedJobIds: [], failedJobs: [] }), - getTerminalJobsNeedingCheckRunCompletion: async () => [], - hasPendingMaintenanceWork: async () => false, - clearSystemActive: async () => {}, - - setJobWorkflowInstance: async () => {}, - setJobPullRequestMeta: async (jobId, meta) => { patch(jobId, meta); }, - insertJob: async () => job, - findExistingJobForHead: async () => null, - - updateJobCheckRun: async (jobId, checkRunId) => { patch(jobId, { checkRunId }); }, - markJobCheckRunCompleted: async () => { record('markJobCheckRunCompleted'); }, - completePreparationStep: async (jobId, fileCount) => { - record('completePreparationStep'); - patch(jobId, { fileCount }); - setStep(jobId, 'Preparation', 'done'); - }, - updateJobStep: async (jobId, stepName, update) => { - record(`step:${stepName}:${update.status}`); - setStep(jobId, stepName, update.status); - }, - completeJob: async (jobId, input) => { - record('completeJob'); - patch(jobId, { - status: 'done', - verdict: input.verdict, - commentCount: input.commentCount, - fileCount: input.fileCount, - totalInputTokens: input.totalInputTokens, - totalOutputTokens: input.totalOutputTokens, - }); - }, - failJob: async (jobId, errorMessage) => { - record('failJob'); - patch(jobId, { status: 'failed', errorMessage }); - }, - supersedeOlderJobs: async () => 0, - }, - - fileReviews: { - upsertFileReview: async (jobId, input) => { - record(`upsert:${input.filePath}:${input.fileStatus}`); - recorded.fileReviews.set(input.filePath, { - ...emptyRow(jobId, input), - file_status: input.fileStatus, - model_used: input.modelUsed, - model_provider: input.modelProvider ?? null, - diff_line_count: input.diffLineCount, - raw_ai_output: input.rawAiOutput, - parsed_comments: input.parsedComments, - input_tokens: input.inputTokens, - output_tokens: input.outputTokens, - duration_ms: input.durationMs, - verdict: input.verdict, - file_summary: input.fileSummary, - confidence_score: input.confidenceScore ?? null, - error_msg: input.errorMessage, - withheld_counts: input.withheldCounts ?? {}, - batch_size: 1, - }); - }, - recordRetryableFileReviewFailure: async (_jobId, input) => { - record(`transientFailure:${input.filePath}`); - const existing = recorded.fileReviews.get(input.filePath); - const count = (existing?.transient_error_count ?? 0) + (input.countsAsAttempt === false ? 0 : 1); - recorded.fileReviews.set(input.filePath, { ...(existing ?? emptyRow(_jobId, input)), transient_error_count: count, error_msg: input.errorMessage }); - return count; - }, - getFileReviewsForJobs: async () => [...recorded.fileReviews.values()], - - bulkInheritFileReviews: async () => [], - bulkUpsertFileReviews: async (jobId, inputs: BulkFileReviewInput[]) => { - record(`bulkUpsert:${inputs.length}`); - for (const input of inputs) { - recorded.fileReviews.set(input.filePath, { - ...emptyRow(jobId, input), - file_status: input.fileStatus, - model_used: input.modelUsed, - model_provider: input.modelProvider ?? null, - diff_line_count: input.diffLineCount, - raw_ai_output: input.rawAiOutput, - parsed_comments: input.parsedComments, - input_tokens: input.inputTokens, - output_tokens: input.outputTokens, - duration_ms: input.durationMs, - verdict: input.verdict, - file_summary: input.fileSummary, - confidence_score: input.confidenceScore ?? null, - error_msg: input.errorMessage, - batch_size: input.batchSize, - }); - } - }, - bulkRecordRetryableFileReviewFailures: async (_jobId, inputs) => - inputs.map((input) => ({ filePath: input.filePath, transientErrorCount: 1 })), - bulkMarkFilesFailed: async (jobId, files, opts) => { - record(`bulkMarkFailed:${files.length}`); - for (const file of files) { - recorded.fileReviews.set(file.filePath, { - ...emptyRow(jobId, file), - file_status: 'failed', - model_used: opts.modelUsed, - error_msg: opts.errorMessage, - }); - } - }, - - getSuppressedFindings: async () => [], - markCommentsPosted: async (_jobId, fingerprints) => { record(`markCommentsPosted:${fingerprints.length}`); }, - markCommentDispositions: async (_jobId, byFingerprint) => { record(`markDispositions:${byFingerprint.size}`); }, - }, - - settings: { getReviewSettings: async () => settings }, - webhooks: { getWebhookDelivery: async () => null }, - learning: { - getRepositoryIdForJob: async () => 1, - getRejectedExemplars: async () => [], - }, - modelConfigs: { getResolvedModelConfig: async () => ({ providerName: 'fake' }) }, - repoConfig: { loadRepoConfig: async () => ({ parsedJson: config, enabled: true }) }, - telemetry: { send: async (event) => { recorded.telemetry.push(event); } }, - - createTokenTracker: () => new TokenTrackerStub() as never, - createGitHub: () => ({ - getPullRequest: async () => ({ - number: job.prNumber, - title: job.prTitle, - body: 'Adds a retry helper.', - draft: false, - head: { sha: job.commitSha, ref: 'feature' }, - base: { sha: 'b'.repeat(40), ref: 'main' }, - user: { login: job.prAuthor ?? 'octocat' }, - }), - getPullRequestDiff: async () => { record('getPullRequestDiff'); return SAMPLE_DIFF; }, - getCompareDiff: async () => SAMPLE_DIFF, - createCheckRun: async (_o, _r, params) => { recorded.checkRuns.push({ title: params.title }); return { id: 555 }; }, - updateCheckRun: async (_o, _r, _id, params) => { - recorded.checkRuns.push({ title: params.title, status: params.status, conclusion: params.conclusion }); - return undefined; - }, - createReview: async (_o, _r, _pr, params) => { - record('createReview'); - recorded.postedReviews.push({ body: params.body, comments: params.comments.map((c) => ({ path: c.path, body: c.body })) }); - return { id: 999, postedIndices: params.comments.map((_c, index) => index) }; - }, - findBotReviewForCommit: async () => null, - ensureLabel: async () => undefined, - addIssueLabels: async () => undefined, - removeIssueLabelsIfPresent: async () => undefined, - }), - createModel: () => ({ - reviewFile: async (params) => { - if (model.failEveryCall) throw model.failEveryCall; - record(`reviewFile:${params.file.path}`); - const comments = findingsFor(params.file.path); - return { - rawText: JSON.stringify({ comments }), - inputTokens: 100, - outputTokens: 20, - modelUsed: 'fake/model', - provider: 'fake', - reviewedLineCount: params.file.lineCount, - wasPromptTruncated: false, - userPrompt: 'prompt', - parsed: { - comments, - verdict: comments.length > 0 ? 'comment' : 'approve', - fileSummary: `Reviewed ${params.file.path}`, - }, - } as never; - }, - reviewFiles: async (params) => { - if (model.failEveryCall) throw model.failEveryCall; - record(`reviewFiles:${params.files.length}`); - const reviews = new Map( - params.files.map((file) => { - const comments = findingsFor(file.path); - return [file.path, { - comments, - verdict: comments.length > 0 ? 'comment' : 'approve', - fileSummary: `Reviewed ${file.path}`, - }]; - }), - ); - return { - rawText: 'batch', - inputTokens: 200, - outputTokens: 40, - modelUsed: 'fake/model', - provider: 'fake', - userPrompt: 'prompt', - batch: { reviews, missing: [] }, - } as never; - }, - submitReviewBatch: async () => null, - pollReviewBatch: async () => ({ status: 'pending' as const }), - verifyFindings: async (params) => { - record(`verifyFindings:${params.candidates.length}`); - const results = params.candidates.map((candidate) => ({ - index: candidate.index, - verdict: model.verifyVerdicts?.[candidate.index] ?? 'keep', - reason: 'fake verdict', - })); - return { rawText: JSON.stringify({ results }), inputTokens: 50, outputTokens: 10, modelUsed: 'fake/model', provider: 'fake' }; - }, - }), - createFormatter: () => ({ - toReviewEvent: (verdict) => (verdict === 'approve' ? 'APPROVE' : 'COMMENT'), - summarizeVerdict: (comments, hasFailures) => ({ - verdict: comments.length > 0 || hasFailures ? 'comment' : 'approve', - errors: 0, - warnings: comments.length, - }), - formatInlineComment: (comment) => `**${comment.title}**\n\n${comment.body}`, - formatReviewOverview: ({ commitSha, postedFindings }) => - `### Codra Review\nReviewed ${commitSha.slice(0, 7)}: ${postedFindings} posted`, - }), - - githubClients: { forInstallation: () => { throw new Error('webhook resolution is not exercised by these tests'); } }, - modelErrors: { - isRetryableModelError: (error) => error instanceof Error && error.message.includes('transient'), - nextChainIndexOf: () => null, - }, - }; - - return { runtime, recorded, now }; -} - -class TokenTrackerStub { - incrementSubrequests() {} - getSubrequestCount() { return 0; } - remainingSafeBudget() { return 40; } - getTotalUsage() { return { inputTokens: 0, outputTokens: 0 }; } - getWasted() { return { calls: 0, inputTokens: 0, outputTokens: 0 }; } -} + +import { defaultRepoConfig, reviewSettingsSchema, type ParsedReviewComment, type RepoConfig, type ReviewSettings } from '@codraoss/schema'; +import type { + BulkFileReviewInput, + FileReviewRow, + JobLeaseClaim, + JobRow, + PersistedReviewJob, + ReviewRuntime, +} from '../src/ports'; + +export type Recorded = { + /** Every port write, in order, so a test can assert on the sequence rather than the end state. */ + calls: string[]; + jobs: Map; + fileReviews: Map; + kv: Map; + postedReviews: Array<{ body: string; comments: Array<{ path: string; body: string }> }>; + checkRuns: Array<{ title: string; status?: string; conclusion?: string }>; + telemetry: unknown[]; +}; + +const ISO = '2026-01-01T00:00:00.000Z'; + +export function makeJob(overrides: Partial = {}): PersistedReviewJob { + return { + id: '11111111-2222-4333-8444-555555555555', + owner: 'acme', + repo: 'widgets', + installationId: '42', + prNumber: 7, + prTitle: 'Add a retry', + prAuthor: 'octocat', + commitSha: 'a'.repeat(40), + trigger: 'auto', + status: 'queued', + verdict: null, + fileCount: 0, + commentCount: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + createdAt: ISO, + updatedAt: ISO, + startedAt: null, + finishedAt: null, + errorMessage: null, + steps: [], + checkRunId: null, + configSnapshot: null, + ...overrides, + }; +} + +export const SAMPLE_DIFF = `diff --git a/src/retry.ts b/src/retry.ts +index 1111111..2222222 100644 +--- a/src/retry.ts ++++ b/src/retry.ts +@@ -1,3 +1,6 @@ + export function retry() { ++ const delay = 1000; ++ return delay; + } +diff --git a/src/log.ts b/src/log.ts +index 3333333..4444444 100644 +--- a/src/log.ts ++++ b/src/log.ts +@@ -1,2 +1,4 @@ + export function log(message: string) { ++ console.log(message); + } +`; + +export type ModelBehaviour = { + /** Findings the model "reports" per file path. */ + findingsByPath?: Record>; + /** Verdicts the verifier returns, keyed by candidate index. Absent means it keeps everything. */ + verifyVerdicts?: Record; + failEveryCall?: Error; +}; + +export function createInMemoryRuntime( + seed: { job?: Partial; settings?: Partial; config?: RepoConfig; model?: ModelBehaviour } = {}, +): { runtime: ReviewRuntime; recorded: Recorded; now: { value: number } } { + const config = seed.config ?? defaultRepoConfig; + const job = makeJob({ configSnapshot: config, ...seed.job }); + const model = seed.model ?? {}; + + const recorded: Recorded = { + calls: [], + jobs: new Map([[job.id, job]]), + fileReviews: new Map(), + kv: new Map(), + postedReviews: [], + checkRuns: [], + telemetry: [], + }; + const record = (name: string) => recorded.calls.push(name); + + const now = { value: 1_700_000_000_000 }; + + const settings = reviewSettingsSchema.parse({ maxFiles: 25, ...seed.settings }); + + const toRow = (j: PersistedReviewJob): JobRow => ({ ...j, status: j.status, check_run_id: j.checkRunId ?? null }); + const patch = (jobId: string, changes: Partial) => { + const existing = recorded.jobs.get(jobId); + if (existing) recorded.jobs.set(jobId, { ...existing, ...changes }); + }; + const setStep = (jobId: string, name: string, status: 'pending' | 'running' | 'done' | 'failed') => { + const existing = recorded.jobs.get(jobId); + if (!existing) return; + const steps = existing.steps.filter((step) => step.name !== name); + recorded.jobs.set(jobId, { ...existing, steps: [...steps, { name, status, startedAt: ISO, finishedAt: status === 'done' ? ISO : null }] }); + }; + + const emptyRow = (jobId: string, input: { filePath: string; diffLineCount?: number }): FileReviewRow => ({ + id: `fr-${input.filePath}`, + job_id: jobId, + file_path: input.filePath, + file_status: 'pending', + model_used: 'fake/model', + diff_line_count: input.diffLineCount ?? 0, + diff_input: null, + raw_ai_output: null, + parsed_comments: [], + input_tokens: null, + output_tokens: null, + duration_ms: null, + verdict: null, + file_summary: null, + overall_correctness: null, + confidence_score: null, + error_msg: null, + model_provider: null, + transient_error_count: 0, + async_request_id: null, + async_model: null, + withheld_counts: {}, + batch_size: null, + }); + + const findingsFor = (path: string): ParsedReviewComment[] => + (model.findingsByPath?.[path] ?? []).map((finding) => ({ + path, + line: finding.line, + title: finding.title, + body: finding.body, + severity: 'P1' as const, + confidenceScore: 90, + evidence: finding.evidence, + })) as ParsedReviewComment[]; + + const runtime: ReviewRuntime = { + kv: { + get: async (key) => recorded.kv.get(key) ?? null, + put: async (key, value) => { recorded.kv.set(key, value); }, + }, + clock: { now: () => now.value }, + ids: { randomUUID: () => 'lease-owner-0001' }, + + botUsername: 'codra-bot', + + jobs: { + mapJob: (row) => recorded.jobs.get(String(row.id))!, + getJobForProcessing: async (jobId) => { + const found = recorded.jobs.get(jobId); + return found ? toRow(found) : null; + }, + claimJobLease: async (jobId): Promise => { + record('claimJobLease'); + const found = recorded.jobs.get(jobId); + if (!found) return { status: 'missing' }; + patch(jobId, { status: found.status === 'queued' ? 'running' : found.status }); + return { status: 'claimed', row: toRow(recorded.jobs.get(jobId)!) }; + }, + heartbeatJobLease: async () => { record('heartbeat'); }, + releaseJobLease: async () => { record('releaseJobLease'); }, + markJobContinuationQueued: async () => 1, + resetJobContinuationCount: async () => {}, + getOtherRunningJobsCount: async () => 0, + + recoverExpiredJobLeases: async () => ({ requeuedJobIds: [], failedJobs: [] }), + getTerminalJobsNeedingCheckRunCompletion: async () => [], + hasPendingMaintenanceWork: async () => false, + clearSystemActive: async () => {}, + + setJobWorkflowInstance: async () => {}, + setJobPullRequestMeta: async (jobId, meta) => { patch(jobId, meta); }, + insertJob: async () => job, + findExistingJobForHead: async () => null, + + updateJobCheckRun: async (jobId, checkRunId) => { patch(jobId, { checkRunId }); }, + markJobCheckRunCompleted: async () => { record('markJobCheckRunCompleted'); }, + completePreparationStep: async (jobId, fileCount) => { + record('completePreparationStep'); + patch(jobId, { fileCount }); + setStep(jobId, 'Preparation', 'done'); + }, + updateJobStep: async (jobId, stepName, update) => { + record(`step:${stepName}:${update.status}`); + setStep(jobId, stepName, update.status); + }, + completeJob: async (jobId, input) => { + record('completeJob'); + patch(jobId, { + status: 'done', + verdict: input.verdict, + commentCount: input.commentCount, + fileCount: input.fileCount, + totalInputTokens: input.totalInputTokens, + totalOutputTokens: input.totalOutputTokens, + }); + }, + failJob: async (jobId, errorMessage) => { + record('failJob'); + patch(jobId, { status: 'failed', errorMessage }); + }, + supersedeOlderJobs: async () => 0, + }, + + fileReviews: { + upsertFileReview: async (jobId, input) => { + record(`upsert:${input.filePath}:${input.fileStatus}`); + recorded.fileReviews.set(input.filePath, { + ...emptyRow(jobId, input), + file_status: input.fileStatus, + model_used: input.modelUsed, + model_provider: input.modelProvider ?? null, + diff_line_count: input.diffLineCount, + raw_ai_output: input.rawAiOutput, + parsed_comments: input.parsedComments, + input_tokens: input.inputTokens, + output_tokens: input.outputTokens, + duration_ms: input.durationMs, + verdict: input.verdict, + file_summary: input.fileSummary, + confidence_score: input.confidenceScore ?? null, + error_msg: input.errorMessage, + withheld_counts: input.withheldCounts ?? {}, + batch_size: 1, + }); + }, + recordRetryableFileReviewFailure: async (_jobId, input) => { + record(`transientFailure:${input.filePath}`); + const existing = recorded.fileReviews.get(input.filePath); + const count = (existing?.transient_error_count ?? 0) + (input.countsAsAttempt === false ? 0 : 1); + recorded.fileReviews.set(input.filePath, { ...(existing ?? emptyRow(_jobId, input)), transient_error_count: count, error_msg: input.errorMessage }); + return count; + }, + getFileReviewsForJobs: async () => [...recorded.fileReviews.values()], + + bulkInheritFileReviews: async () => [], + bulkUpsertFileReviews: async (jobId, inputs: BulkFileReviewInput[]) => { + record(`bulkUpsert:${inputs.length}`); + for (const input of inputs) { + recorded.fileReviews.set(input.filePath, { + ...emptyRow(jobId, input), + file_status: input.fileStatus, + model_used: input.modelUsed, + model_provider: input.modelProvider ?? null, + diff_line_count: input.diffLineCount, + raw_ai_output: input.rawAiOutput, + parsed_comments: input.parsedComments, + input_tokens: input.inputTokens, + output_tokens: input.outputTokens, + duration_ms: input.durationMs, + verdict: input.verdict, + file_summary: input.fileSummary, + confidence_score: input.confidenceScore ?? null, + error_msg: input.errorMessage, + batch_size: input.batchSize, + }); + } + }, + bulkRecordRetryableFileReviewFailures: async (_jobId, inputs) => + inputs.map((input) => ({ filePath: input.filePath, transientErrorCount: 1 })), + bulkMarkFilesFailed: async (jobId, files, opts) => { + record(`bulkMarkFailed:${files.length}`); + for (const file of files) { + recorded.fileReviews.set(file.filePath, { + ...emptyRow(jobId, file), + file_status: 'failed', + model_used: opts.modelUsed, + error_msg: opts.errorMessage, + }); + } + }, + + getSuppressedFindings: async () => [], + markCommentsPosted: async (_jobId, fingerprints) => { record(`markCommentsPosted:${fingerprints.length}`); }, + markCommentDispositions: async (_jobId, byFingerprint) => { record(`markDispositions:${byFingerprint.size}`); }, + }, + + settings: { getReviewSettings: async () => settings }, + webhooks: { getWebhookDelivery: async () => null }, + learning: { + getRepositoryIdForJob: async () => 1, + getRejectedExemplars: async () => [], + }, + modelConfigs: { getResolvedModelConfig: async () => ({ providerName: 'fake' }) }, + repoConfig: { loadRepoConfig: async () => ({ parsedJson: config, enabled: true }) }, + telemetry: { send: async (event) => { recorded.telemetry.push(event); } }, + + createTokenTracker: () => new TokenTrackerStub() as never, + createGitHub: () => ({ + getPullRequest: async () => ({ + number: job.prNumber, + title: job.prTitle, + body: 'Adds a retry helper.', + draft: false, + head: { sha: job.commitSha, ref: 'feature' }, + base: { sha: 'b'.repeat(40), ref: 'main' }, + user: { login: job.prAuthor ?? 'octocat' }, + }), + getPullRequestDiff: async () => { record('getPullRequestDiff'); return SAMPLE_DIFF; }, + getCompareDiff: async () => SAMPLE_DIFF, + createCheckRun: async (_o, _r, params) => { recorded.checkRuns.push({ title: params.title }); return { id: 555 }; }, + updateCheckRun: async (_o, _r, _id, params) => { + recorded.checkRuns.push({ title: params.title, status: params.status, conclusion: params.conclusion }); + return undefined; + }, + createReview: async (_o, _r, _pr, params) => { + record('createReview'); + recorded.postedReviews.push({ body: params.body, comments: params.comments.map((c) => ({ path: c.path, body: c.body })) }); + return { id: 999, postedIndices: params.comments.map((_c, index) => index) }; + }, + findBotReviewForCommit: async () => null, + ensureLabel: async () => undefined, + addIssueLabels: async () => undefined, + removeIssueLabelsIfPresent: async () => undefined, + }), + createModel: () => ({ + reviewFile: async (params) => { + if (model.failEveryCall) throw model.failEveryCall; + record(`reviewFile:${params.file.path}`); + const comments = findingsFor(params.file.path); + return { + rawText: JSON.stringify({ comments }), + inputTokens: 100, + outputTokens: 20, + modelUsed: 'fake/model', + provider: 'fake', + reviewedLineCount: params.file.lineCount, + wasPromptTruncated: false, + userPrompt: 'prompt', + parsed: { + comments, + verdict: comments.length > 0 ? 'comment' : 'approve', + fileSummary: `Reviewed ${params.file.path}`, + }, + } as never; + }, + reviewFiles: async (params) => { + if (model.failEveryCall) throw model.failEveryCall; + record(`reviewFiles:${params.files.length}`); + const reviews = new Map( + params.files.map((file) => { + const comments = findingsFor(file.path); + return [file.path, { + comments, + verdict: comments.length > 0 ? 'comment' : 'approve', + fileSummary: `Reviewed ${file.path}`, + }]; + }), + ); + return { + rawText: 'batch', + inputTokens: 200, + outputTokens: 40, + modelUsed: 'fake/model', + provider: 'fake', + userPrompt: 'prompt', + batch: { reviews, missing: [] }, + } as never; + }, + submitReviewBatch: async () => null, + pollReviewBatch: async () => ({ status: 'pending' as const }), + verifyFindings: async (params) => { + record(`verifyFindings:${params.candidates.length}`); + const results = params.candidates.map((candidate) => ({ + index: candidate.index, + verdict: model.verifyVerdicts?.[candidate.index] ?? 'keep', + reason: 'fake verdict', + })); + return { rawText: JSON.stringify({ results }), inputTokens: 50, outputTokens: 10, modelUsed: 'fake/model', provider: 'fake' }; + }, + }), + createFormatter: () => ({ + toReviewEvent: (verdict) => (verdict === 'approve' ? 'APPROVE' : 'COMMENT'), + summarizeVerdict: (comments, hasFailures) => ({ + verdict: comments.length > 0 || hasFailures ? 'comment' : 'approve', + errors: 0, + warnings: comments.length, + }), + formatInlineComment: (comment) => `**${comment.title}**\n\n${comment.body}`, + formatReviewOverview: ({ commitSha, postedFindings }) => + `### Codra Review\nReviewed ${commitSha.slice(0, 7)}: ${postedFindings} posted`, + }), + + githubClients: { forInstallation: () => { throw new Error('webhook resolution is not exercised by these tests'); } }, + modelErrors: { + isRetryableModelError: (error) => error instanceof Error && error.message.includes('transient'), + nextChainIndexOf: () => null, + }, + }; + + return { runtime, recorded, now }; +} + +class TokenTrackerStub { + incrementSubrequests() {} + getSubrequestCount() { return 0; } + remainingSafeBudget() { return 40; } + getTotalUsage() { return { inputTokens: 0, outputTokens: 0 }; } + getWasted() { return { calls: 0, inputTokens: 0, outputTokens: 0 }; } +} diff --git a/packages/core/test/logger.spec.ts b/packages/core/test/logger.spec.ts index db892ab7..1ba3b82a 100644 --- a/packages/core/test/logger.spec.ts +++ b/packages/core/test/logger.spec.ts @@ -1,104 +1,104 @@ -import { describe, expect, it, vi } from 'vitest'; -import { consoleLogger, formatLogRecord, logger, redact, scrubString, setLoggerSink } from '../src/logger'; - -describe('scrubString', () => { - it('replaces a JWT in the middle of a message', () => { - const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.abc-DEF_123'; - expect(scrubString(`auth failed for ${jwt} on retry`)).toBe('auth failed for [REDACTED_JWT] on retry'); - }); - - it('keeps the scheme but drops the credential for Bearer and Basic', () => { - expect(scrubString('Authorization: Bearer ghs_abcdefghijklmnop')).toBe('Authorization: Bearer [REDACTED]'); - expect(scrubString('sent Basic dXNlcjpwYXNzd29yZA==')).toBe('sent Basic [REDACTED]'); - }); - - it('leaves ordinary prose and dotted paths alone', () => { - expect(scrubString('parsed src/server/core/logger.ts fine')).toBe('parsed src/server/core/logger.ts fine'); - expect(scrubString('a.b.c')).toBe('a.b.c'); - }); -}); - -describe('redact', () => { - it('masks values under sensitive keys, case-insensitively and by substring', () => { - expect(redact({ apiKey: 'x', API_KEY: 'y', total_input_tokens: 5, nested: { password: 'p' } })).toEqual({ - apiKey: '[REDACTED]', - API_KEY: '[REDACTED]', - total_input_tokens: '[REDACTED]', - nested: { password: '[REDACTED]' }, - }); - }); - - it('serializes Error instances instead of flattening them to {}', () => { - const error = new Error('Bearer ghs_abcdefghijklmnop rejected'); - const result = redact(error); - expect(result.name).toBe('Error'); - expect(result.message).toBe('Bearer [REDACTED] rejected'); - expect(typeof result.stack).toBe('string'); - }); - - it('passes through primitives and recurses into arrays', () => { - expect(redact(null)).toBeNull(); - expect(redact(undefined)).toBeUndefined(); - expect(redact(7)).toBe(7); - expect(redact([{ secret: 'a' }, 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig'])).toEqual([ - { secret: '[REDACTED]' }, - '[REDACTED_JWT]', - ]); - }); -}); - -describe('formatLogRecord', () => { - it('spreads contexts in order, later winning, and scrubs the message', () => { - const record = formatLogRecord('info', 'Bearer ghs_abcdefghijklmnop', [{ requestId: 'a', jobId: '1' }, { jobId: '2' }], { count: 3 }); - expect(record.level).toBe('info'); - expect(record.message).toBe('Bearer [REDACTED]'); - expect(record.requestId).toBe('a'); - expect(record.jobId).toBe('2'); - expect(record.data).toEqual({ count: 3 }); - expect(typeof record.timestamp).toBe('string'); - }); - - it('omits `data` entirely when none is given', () => { - expect('data' in formatLogRecord('warn', 'no payload', [])).toBe(false); - }); -}); - -describe('logger facade', () => { - it('routes through whichever sink is installed, including one installed after import', () => { - const calls: Array<[string, string]> = []; - const fake = { - info: (m: string) => calls.push(['info', m]), - warn: (m: string) => calls.push(['warn', m]), - error: (m: string) => calls.push(['error', m]), - debug: (m: string) => calls.push(['debug', m]), - }; - setLoggerSink(fake); - try { - logger.info('i'); - logger.warn('w'); - logger.error('e'); - logger.debug('d'); - expect(calls).toEqual([['info', 'i'], ['warn', 'w'], ['error', 'e'], ['debug', 'd']]); - } finally { - setLoggerSink(consoleLogger); - } - }); - - it('falls back to the console sink, routing errors to console.error and warnings to console.warn', () => { - const error = vi.spyOn(console, 'error').mockImplementation(() => {}); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); - try { - logger.error('boom'); - logger.warn('careful'); - logger.info('fyi'); - expect(JSON.parse(error.mock.calls[0][0]).level).toBe('error'); - expect(JSON.parse(warn.mock.calls[0][0]).level).toBe('warn'); - expect(JSON.parse(log.mock.calls[0][0]).level).toBe('info'); - } finally { - error.mockRestore(); - warn.mockRestore(); - log.mockRestore(); - } - }); -}); +import { describe, expect, it, vi } from 'vitest'; +import { consoleLogger, formatLogRecord, logger, redact, scrubString, setLoggerSink } from '../src/logger'; + +describe('scrubString', () => { + it('replaces a JWT in the middle of a message', () => { + const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.abc-DEF_123'; + expect(scrubString(`auth failed for ${jwt} on retry`)).toBe('auth failed for [REDACTED_JWT] on retry'); + }); + + it('keeps the scheme but drops the credential for Bearer and Basic', () => { + expect(scrubString('Authorization: Bearer ghs_abcdefghijklmnop')).toBe('Authorization: Bearer [REDACTED]'); + expect(scrubString('sent Basic dXNlcjpwYXNzd29yZA==')).toBe('sent Basic [REDACTED]'); + }); + + it('leaves ordinary prose and dotted paths alone', () => { + expect(scrubString('parsed src/server/core/logger.ts fine')).toBe('parsed src/server/core/logger.ts fine'); + expect(scrubString('a.b.c')).toBe('a.b.c'); + }); +}); + +describe('redact', () => { + it('masks values under sensitive keys, case-insensitively and by substring', () => { + expect(redact({ apiKey: 'x', API_KEY: 'y', total_input_tokens: 5, nested: { password: 'p' } })).toEqual({ + apiKey: '[REDACTED]', + API_KEY: '[REDACTED]', + total_input_tokens: '[REDACTED]', + nested: { password: '[REDACTED]' }, + }); + }); + + it('serializes Error instances instead of flattening them to {}', () => { + const error = new Error('Bearer ghs_abcdefghijklmnop rejected'); + const result = redact(error); + expect(result.name).toBe('Error'); + expect(result.message).toBe('Bearer [REDACTED] rejected'); + expect(typeof result.stack).toBe('string'); + }); + + it('passes through primitives and recurses into arrays', () => { + expect(redact(null)).toBeNull(); + expect(redact(undefined)).toBeUndefined(); + expect(redact(7)).toBe(7); + expect(redact([{ secret: 'a' }, 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig'])).toEqual([ + { secret: '[REDACTED]' }, + '[REDACTED_JWT]', + ]); + }); +}); + +describe('formatLogRecord', () => { + it('spreads contexts in order, later winning, and scrubs the message', () => { + const record = formatLogRecord('info', 'Bearer ghs_abcdefghijklmnop', [{ requestId: 'a', jobId: '1' }, { jobId: '2' }], { count: 3 }); + expect(record.level).toBe('info'); + expect(record.message).toBe('Bearer [REDACTED]'); + expect(record.requestId).toBe('a'); + expect(record.jobId).toBe('2'); + expect(record.data).toEqual({ count: 3 }); + expect(typeof record.timestamp).toBe('string'); + }); + + it('omits `data` entirely when none is given', () => { + expect('data' in formatLogRecord('warn', 'no payload', [])).toBe(false); + }); +}); + +describe('logger facade', () => { + it('routes through whichever sink is installed, including one installed after import', () => { + const calls: Array<[string, string]> = []; + const fake = { + info: (m: string) => calls.push(['info', m]), + warn: (m: string) => calls.push(['warn', m]), + error: (m: string) => calls.push(['error', m]), + debug: (m: string) => calls.push(['debug', m]), + }; + setLoggerSink(fake); + try { + logger.info('i'); + logger.warn('w'); + logger.error('e'); + logger.debug('d'); + expect(calls).toEqual([['info', 'i'], ['warn', 'w'], ['error', 'e'], ['debug', 'd']]); + } finally { + setLoggerSink(consoleLogger); + } + }); + + it('falls back to the console sink, routing errors to console.error and warnings to console.warn', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + logger.error('boom'); + logger.warn('careful'); + logger.info('fyi'); + expect(JSON.parse(error.mock.calls[0][0]).level).toBe('error'); + expect(JSON.parse(warn.mock.calls[0][0]).level).toBe('warn'); + expect(JSON.parse(log.mock.calls[0][0]).level).toBe('info'); + } finally { + error.mockRestore(); + warn.mockRestore(); + log.mockRestore(); + } + }); +}); diff --git a/packages/core/test/redos-bounds.spec.ts b/packages/core/test/redos-bounds.spec.ts index 7bee9dfb..7b3a870c 100644 --- a/packages/core/test/redos-bounds.spec.ts +++ b/packages/core/test/redos-bounds.spec.ts @@ -1,72 +1,72 @@ -import { describe, expect, it } from 'vitest'; -import { extractJson } from '../src/model-output/json'; -import { refuteUndecidableClaim } from '../src/claim-checks'; - -// Validates regex polynomial-redos fixes maintain parsing parity while capping execution time. - -// ReDOS budget catching unbounded quantifiers without CI flakiness. -const BUDGET_MS = 250; - -function timed(fn: () => unknown) { - const startedAt = performance.now(); - fn(); - return performance.now() - startedAt; -} - -describe('extractJson: fence parsing unchanged, backtracking gone', () => { - it('still strips a ```json fence, with or without padding', () => { - expect(extractJson('```json\n{"a":1}\n```')).toBe('{"a":1}'); - expect(extractJson('```json \n\n {"a":1} \n\n```')).toBe('{"a":1}'); - }); - - it('still prefers the LAST json fence, as the parser always has', () => { - expect(extractJson('```json\n{"first":1}\n```\ntext\n```json\n{"second":2}\n```')).toBe('{"second":2}'); - }); - - it('still recovers an object from an unterminated fence via the later stages', () => { - expect(extractJson('```json\t \t{"a":1}')).toBe('{"a":1}'); - }); - - it('still reads an untagged or language-tagged generic fence', () => { - const withKeys = '{"findings":[],"verdict":"approve"}'; - expect(extractJson(`\`\`\`\n${withKeys}\n\`\`\``)).toContain('"findings"'); - expect(extractJson(`\`\`\`js \n${withKeys}\n\`\`\``)).toContain('"findings"'); - expect(extractJson(`\`\`\`c++-x\n${withKeys}\n\`\`\``)).toContain('"findings"'); - }); - - it('returns the raw string when there is no fence at all', () => { - expect(extractJson('{"a":1}')).toBe('{"a":1}'); - }); - - it('does not degrade on a fence followed by a long whitespace run', () => { - expect(timed(() => extractJson('```json' + ' '.repeat(40_000)))).toBeLessThan(BUDGET_MS); - expect(timed(() => extractJson('```' + ' '.repeat(40_000)))).toBeLessThan(BUDGET_MS); - }); -}); - -describe('claim-check regexes: bounded, and unchanged for realistic input', () => { - const claim = (body: string) => refuteUndecidableClaim({ title: '', body }); - - it('still refutes a real callee-failure claim', () => { - expect(claim('If the `this.persistence.loadCooldowns()` call fails the rejection is unhandled.')).toBe('callee-errors'); - expect(claim(`When getThing${' '.repeat(50)}() fails, the error is not caught.`)).toBe('callee-errors'); - }); - - it('still declines claims that lack one of the three signals', () => { - expect(claim('If getThing() fails, nothing much happens.')).toBeNull(); - expect(claim('The unhandled rejection here is bad.')).toBeNull(); - }); - - it('does not degrade on a body that is a long run of `$`', () => { - expect(timed(() => claim('If ' + '$'.repeat(40_000) + ' fails it is unhandled'))).toBeLessThan(BUDGET_MS); - }); - - it('does not degrade on a diff line that is a long whitespace run', () => { - expect(timed(() => ' '.repeat(40_000).replace(/\s{0,50}\.\s{0,50}/g, '.'))).toBeLessThan(BUDGET_MS); - }); - - it('documents the one accepted behaviour change: gaps beyond the bound stop matching', () => { - expect(claim(`If getThing${' '.repeat(51)}() fails, the error is not caught.`)).toBeNull(); - expect(claim(`If getThing${' '.repeat(50)}() fails, the error is not caught.`)).toBe('callee-errors'); - }); -}); +import { describe, expect, it } from 'vitest'; +import { extractJson } from '../src/model-output/json'; +import { refuteUndecidableClaim } from '../src/claim-checks'; + +// Validates regex polynomial-redos fixes maintain parsing parity while capping execution time. + +// ReDOS budget catching unbounded quantifiers without CI flakiness. +const BUDGET_MS = 250; + +function timed(fn: () => unknown) { + const startedAt = performance.now(); + fn(); + return performance.now() - startedAt; +} + +describe('extractJson: fence parsing unchanged, backtracking gone', () => { + it('still strips a ```json fence, with or without padding', () => { + expect(extractJson('```json\n{"a":1}\n```')).toBe('{"a":1}'); + expect(extractJson('```json \n\n {"a":1} \n\n```')).toBe('{"a":1}'); + }); + + it('still prefers the LAST json fence, as the parser always has', () => { + expect(extractJson('```json\n{"first":1}\n```\ntext\n```json\n{"second":2}\n```')).toBe('{"second":2}'); + }); + + it('still recovers an object from an unterminated fence via the later stages', () => { + expect(extractJson('```json\t \t{"a":1}')).toBe('{"a":1}'); + }); + + it('still reads an untagged or language-tagged generic fence', () => { + const withKeys = '{"findings":[],"verdict":"approve"}'; + expect(extractJson(`\`\`\`\n${withKeys}\n\`\`\``)).toContain('"findings"'); + expect(extractJson(`\`\`\`js \n${withKeys}\n\`\`\``)).toContain('"findings"'); + expect(extractJson(`\`\`\`c++-x\n${withKeys}\n\`\`\``)).toContain('"findings"'); + }); + + it('returns the raw string when there is no fence at all', () => { + expect(extractJson('{"a":1}')).toBe('{"a":1}'); + }); + + it('does not degrade on a fence followed by a long whitespace run', () => { + expect(timed(() => extractJson('```json' + ' '.repeat(40_000)))).toBeLessThan(BUDGET_MS); + expect(timed(() => extractJson('```' + ' '.repeat(40_000)))).toBeLessThan(BUDGET_MS); + }); +}); + +describe('claim-check regexes: bounded, and unchanged for realistic input', () => { + const claim = (body: string) => refuteUndecidableClaim({ title: '', body }); + + it('still refutes a real callee-failure claim', () => { + expect(claim('If the `this.persistence.loadCooldowns()` call fails the rejection is unhandled.')).toBe('callee-errors'); + expect(claim(`When getThing${' '.repeat(50)}() fails, the error is not caught.`)).toBe('callee-errors'); + }); + + it('still declines claims that lack one of the three signals', () => { + expect(claim('If getThing() fails, nothing much happens.')).toBeNull(); + expect(claim('The unhandled rejection here is bad.')).toBeNull(); + }); + + it('does not degrade on a body that is a long run of `$`', () => { + expect(timed(() => claim('If ' + '$'.repeat(40_000) + ' fails it is unhandled'))).toBeLessThan(BUDGET_MS); + }); + + it('does not degrade on a diff line that is a long whitespace run', () => { + expect(timed(() => ' '.repeat(40_000).replace(/\s{0,50}\.\s{0,50}/g, '.'))).toBeLessThan(BUDGET_MS); + }); + + it('documents the one accepted behaviour change: gaps beyond the bound stop matching', () => { + expect(claim(`If getThing${' '.repeat(51)}() fails, the error is not caught.`)).toBeNull(); + expect(claim(`If getThing${' '.repeat(50)}() fails, the error is not caught.`)).toBe('callee-errors'); + }); +}); diff --git a/packages/core/test/review-in-memory.spec.ts b/packages/core/test/review-in-memory.spec.ts index 0817b158..6ac80a59 100644 --- a/packages/core/test/review-in-memory.spec.ts +++ b/packages/core/test/review-in-memory.spec.ts @@ -1,143 +1,143 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { runReview, type ReviewJobRunResult } from '../src'; -import { setLoggerSink } from '../src/logger'; -import { createInMemoryRuntime } from './in-memory'; - -// Note what is NOT here: no vi.mock, no module interception, no test database, no fetch stub. The - -beforeEach(() => { - setLoggerSink({ info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }); -}); - -/** Drives runReview the way a host driver would: one phase per call, following the result. */ -async function drive(runtime: Parameters[0], jobId: string, maxPhases = 10) { - const results: ReviewJobRunResult[] = []; - let next: { jobId: string; phase?: 'prepare' | 'review' | 'finalize' } = { jobId, phase: 'prepare' }; - - for (let i = 0; i < maxPhases; i++) { - const result = await runReview(runtime, next as never); - results.push(result); - if (result.action !== 'next_phase') return results; - next = { jobId: result.jobId ?? jobId, phase: result.phase }; - } - throw new Error(`Review did not settle within ${maxPhases} phases`); -} - -describe('runReview end to end on in-memory ports', () => { - it('carries a job from prepare through review to a posted review', async () => { - const { runtime, recorded } = createInMemoryRuntime({ - model: { - findingsByPath: { - 'src/retry.ts': [{ title: 'Hard-coded delay', body: 'Extract the 1000ms delay into a constant.', line: 2, evidence: 'const delay = 1000;' }], - }, - }, - }); - const jobId = [...recorded.jobs.keys()][0]; - - const results = await drive(runtime, jobId); - - expect(results.map((r) => r.action)).toEqual(['next_phase', 'next_phase', 'ack']); - expect(results[0]).toMatchObject({ action: 'next_phase', phase: 'review' }); - expect(results[1]).toMatchObject({ action: 'next_phase', phase: 'finalize', freshInstance: true }); - - const job = recorded.jobs.get(jobId)!; - expect(job.status).toBe('done'); - expect(job.verdict).toBe('comment'); - - expect([...recorded.fileReviews.keys()].sort()).toEqual(['src/log.ts', 'src/retry.ts']); - expect([...recorded.fileReviews.values()].every((row) => row.file_status === 'done')).toBe(true); - - expect(recorded.postedReviews).toHaveLength(1); - expect(recorded.postedReviews[0].comments).toEqual([ - { path: 'src/retry.ts', body: expect.stringContaining('Hard-coded delay') }, - ]); - // The posted body is the formatter's overview, carrying the head sha and the count actually posted. - expect(recorded.postedReviews[0].body).toContain(`Reviewed ${'a'.repeat(7)}: 1 posted`); - - expect(recorded.checkRuns[0].title).toBe('Review queued'); - expect(recorded.checkRuns.at(-1)).toMatchObject({ status: 'completed' }); - expect(recorded.telemetry).toHaveLength(1); - }); - - it('claims the lease before doing any work, and releases it on every exit', async () => { - const { runtime, recorded } = createInMemoryRuntime(); - const jobId = [...recorded.jobs.keys()][0]; - - await drive(runtime, jobId); - - expect(recorded.calls[0]).toBe('claimJobLease'); - expect(recorded.calls.filter((call) => call === 'releaseJobLease')).toHaveLength(3); - expect(recorded.calls.filter((call) => call === 'claimJobLease')).toHaveLength(3); - }); - - it('approves a clean diff without posting inline comments', async () => { - const { runtime, recorded } = createInMemoryRuntime(); - const jobId = [...recorded.jobs.keys()][0]; - - await drive(runtime, jobId); - - expect(recorded.jobs.get(jobId)!.verdict).toBe('approve'); - expect(recorded.postedReviews[0].comments).toEqual([]); - }); - - it('posts both findings when the verifier keeps them, and one when it refutes the other', async () => { - const findingsByPath = { - 'src/retry.ts': [{ title: 'Hard-coded delay', body: 'Extract it.', line: 2, evidence: 'const delay = 1000;' }], - 'src/log.ts': [{ title: 'Logs user input', body: 'Could leak PII.', line: 2, evidence: 'console.log(message);' }], - }; - - const kept = createInMemoryRuntime({ model: { findingsByPath } }); - await drive(kept.runtime, [...kept.recorded.jobs.keys()][0]); - expect(kept.recorded.postedReviews[0].comments.map((c) => c.path).sort()).toEqual(['src/log.ts', 'src/retry.ts']); - expect(kept.recorded.calls.some((call) => call === 'verifyFindings:2')).toBe(true); - - const refuted = createInMemoryRuntime({ model: { findingsByPath, verifyVerdicts: { 0: 'drop' } } }); - await drive(refuted.runtime, [...refuted.recorded.jobs.keys()][0]); - expect(refuted.recorded.postedReviews[0].comments).toHaveLength(1); - expect(refuted.recorded.calls.some((call) => call.startsWith('markDispositions:'))).toBe(true); - }); - - it('fetches the diff from the provider once and serves later phases from the cache', async () => { - const { runtime, recorded } = createInMemoryRuntime(); - const jobId = [...recorded.jobs.keys()][0]; - - await drive(runtime, jobId); - - expect(recorded.calls.filter((call) => call === 'getPullRequestDiff')).toHaveLength(1); - expect([...recorded.kv.keys()]).toEqual([`diff:${jobId}`]); - }); - - it('records a terminal failure and closes the check run when the model fails unrecoverably', async () => { - const { runtime, recorded } = createInMemoryRuntime({ - model: { failEveryCall: new Error('provider returned 400: malformed request') }, - }); - const jobId = [...recorded.jobs.keys()][0]; - - const results = await drive(runtime, jobId); - - expect(results.at(-1)!.action).toBe('ack'); - expect([...recorded.fileReviews.values()].every((row) => row.file_status === 'failed')).toBe(true); - expect(recorded.jobs.get(jobId)!.status).toBe('failed'); - expect(recorded.calls).toContain('failJob'); - expect(recorded.checkRuns.at(-1)).toMatchObject({ conclusion: 'failure' }); - expect(recorded.postedReviews).toEqual([]); - }); - - it('is deterministic: the clock and id generator are ports, so durations do not vary', async () => { - const first = createInMemoryRuntime(); - const second = createInMemoryRuntime(); - - await drive(first.runtime, [...first.recorded.jobs.keys()][0]); - await drive(second.runtime, [...second.recorded.jobs.keys()][0]); - - expect(first.recorded.calls).toEqual(second.recorded.calls); - expect([...first.recorded.fileReviews.values()].map((r) => r.duration_ms)) - .toEqual([...second.recorded.fileReviews.values()].map((r) => r.duration_ms)); - }); - - it('acks without work when the job does not exist', async () => { - const { runtime } = createInMemoryRuntime(); - expect(await runReview(runtime, { jobId: '99999999-2222-4333-8444-555555555555', phase: 'review' } as never)) - .toEqual({ action: 'ack' }); - }); -}); +import { beforeEach, describe, expect, it } from 'vitest'; +import { runReview, type ReviewJobRunResult } from '../src'; +import { setLoggerSink } from '../src/logger'; +import { createInMemoryRuntime } from './in-memory'; + +// Note what is NOT here: no vi.mock, no module interception, no test database, no fetch stub. The + +beforeEach(() => { + setLoggerSink({ info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }); +}); + +/** Drives runReview the way a host driver would: one phase per call, following the result. */ +async function drive(runtime: Parameters[0], jobId: string, maxPhases = 10) { + const results: ReviewJobRunResult[] = []; + let next: { jobId: string; phase?: 'prepare' | 'review' | 'finalize' } = { jobId, phase: 'prepare' }; + + for (let i = 0; i < maxPhases; i++) { + const result = await runReview(runtime, next as never); + results.push(result); + if (result.action !== 'next_phase') return results; + next = { jobId: result.jobId ?? jobId, phase: result.phase }; + } + throw new Error(`Review did not settle within ${maxPhases} phases`); +} + +describe('runReview end to end on in-memory ports', () => { + it('carries a job from prepare through review to a posted review', async () => { + const { runtime, recorded } = createInMemoryRuntime({ + model: { + findingsByPath: { + 'src/retry.ts': [{ title: 'Hard-coded delay', body: 'Extract the 1000ms delay into a constant.', line: 2, evidence: 'const delay = 1000;' }], + }, + }, + }); + const jobId = [...recorded.jobs.keys()][0]; + + const results = await drive(runtime, jobId); + + expect(results.map((r) => r.action)).toEqual(['next_phase', 'next_phase', 'ack']); + expect(results[0]).toMatchObject({ action: 'next_phase', phase: 'review' }); + expect(results[1]).toMatchObject({ action: 'next_phase', phase: 'finalize', freshInstance: true }); + + const job = recorded.jobs.get(jobId)!; + expect(job.status).toBe('done'); + expect(job.verdict).toBe('comment'); + + expect([...recorded.fileReviews.keys()].sort()).toEqual(['src/log.ts', 'src/retry.ts']); + expect([...recorded.fileReviews.values()].every((row) => row.file_status === 'done')).toBe(true); + + expect(recorded.postedReviews).toHaveLength(1); + expect(recorded.postedReviews[0].comments).toEqual([ + { path: 'src/retry.ts', body: expect.stringContaining('Hard-coded delay') }, + ]); + // The posted body is the formatter's overview, carrying the head sha and the count actually posted. + expect(recorded.postedReviews[0].body).toContain(`Reviewed ${'a'.repeat(7)}: 1 posted`); + + expect(recorded.checkRuns[0].title).toBe('Review queued'); + expect(recorded.checkRuns.at(-1)).toMatchObject({ status: 'completed' }); + expect(recorded.telemetry).toHaveLength(1); + }); + + it('claims the lease before doing any work, and releases it on every exit', async () => { + const { runtime, recorded } = createInMemoryRuntime(); + const jobId = [...recorded.jobs.keys()][0]; + + await drive(runtime, jobId); + + expect(recorded.calls[0]).toBe('claimJobLease'); + expect(recorded.calls.filter((call) => call === 'releaseJobLease')).toHaveLength(3); + expect(recorded.calls.filter((call) => call === 'claimJobLease')).toHaveLength(3); + }); + + it('approves a clean diff without posting inline comments', async () => { + const { runtime, recorded } = createInMemoryRuntime(); + const jobId = [...recorded.jobs.keys()][0]; + + await drive(runtime, jobId); + + expect(recorded.jobs.get(jobId)!.verdict).toBe('approve'); + expect(recorded.postedReviews[0].comments).toEqual([]); + }); + + it('posts both findings when the verifier keeps them, and one when it refutes the other', async () => { + const findingsByPath = { + 'src/retry.ts': [{ title: 'Hard-coded delay', body: 'Extract it.', line: 2, evidence: 'const delay = 1000;' }], + 'src/log.ts': [{ title: 'Logs user input', body: 'Could leak PII.', line: 2, evidence: 'console.log(message);' }], + }; + + const kept = createInMemoryRuntime({ model: { findingsByPath } }); + await drive(kept.runtime, [...kept.recorded.jobs.keys()][0]); + expect(kept.recorded.postedReviews[0].comments.map((c) => c.path).sort()).toEqual(['src/log.ts', 'src/retry.ts']); + expect(kept.recorded.calls.some((call) => call === 'verifyFindings:2')).toBe(true); + + const refuted = createInMemoryRuntime({ model: { findingsByPath, verifyVerdicts: { 0: 'drop' } } }); + await drive(refuted.runtime, [...refuted.recorded.jobs.keys()][0]); + expect(refuted.recorded.postedReviews[0].comments).toHaveLength(1); + expect(refuted.recorded.calls.some((call) => call.startsWith('markDispositions:'))).toBe(true); + }); + + it('fetches the diff from the provider once and serves later phases from the cache', async () => { + const { runtime, recorded } = createInMemoryRuntime(); + const jobId = [...recorded.jobs.keys()][0]; + + await drive(runtime, jobId); + + expect(recorded.calls.filter((call) => call === 'getPullRequestDiff')).toHaveLength(1); + expect([...recorded.kv.keys()]).toEqual([`diff:${jobId}`]); + }); + + it('records a terminal failure and closes the check run when the model fails unrecoverably', async () => { + const { runtime, recorded } = createInMemoryRuntime({ + model: { failEveryCall: new Error('provider returned 400: malformed request') }, + }); + const jobId = [...recorded.jobs.keys()][0]; + + const results = await drive(runtime, jobId); + + expect(results.at(-1)!.action).toBe('ack'); + expect([...recorded.fileReviews.values()].every((row) => row.file_status === 'failed')).toBe(true); + expect(recorded.jobs.get(jobId)!.status).toBe('failed'); + expect(recorded.calls).toContain('failJob'); + expect(recorded.checkRuns.at(-1)).toMatchObject({ conclusion: 'failure' }); + expect(recorded.postedReviews).toEqual([]); + }); + + it('is deterministic: the clock and id generator are ports, so durations do not vary', async () => { + const first = createInMemoryRuntime(); + const second = createInMemoryRuntime(); + + await drive(first.runtime, [...first.recorded.jobs.keys()][0]); + await drive(second.runtime, [...second.recorded.jobs.keys()][0]); + + expect(first.recorded.calls).toEqual(second.recorded.calls); + expect([...first.recorded.fileReviews.values()].map((r) => r.duration_ms)) + .toEqual([...second.recorded.fileReviews.values()].map((r) => r.duration_ms)); + }); + + it('acks without work when the job does not exist', async () => { + const { runtime } = createInMemoryRuntime(); + expect(await runReview(runtime, { jobId: '99999999-2222-4333-8444-555555555555', phase: 'review' } as never)) + .toEqual({ action: 'ack' }); + }); +}); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 1ed732a9..42c47102 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -1,9 +1,9 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - include: ['test/**/*.spec.ts'], - environment: 'node', - globals: false, - }, -}); +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.spec.ts'], + environment: 'node', + globals: false, + }, +}); diff --git a/packages/db/package.json b/packages/db/package.json index eb08cff8..106680e1 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -25,6 +25,8 @@ }, "files": [ "dist", + "migrations", + "scripts", "LICENSE", "README.md" ], diff --git a/packages/db/scripts/migrate-env.mjs b/packages/db/scripts/migrate-env.mjs index 575f5d71..93d06dcc 100644 --- a/packages/db/scripts/migrate-env.mjs +++ b/packages/db/scripts/migrate-env.mjs @@ -2,7 +2,10 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +// cwd is checked first so the script works when run from node_modules, where the script-relative path would resolve inside node_modules; the script-relative fallback still finds root env files from a monorepo subdirectory. +const cwdDir = process.cwd(); +const scriptRelativeDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const searchDirs = cwdDir === scriptRelativeDir ? [cwdDir] : [cwdDir, scriptRelativeDir]; export function parseEnvValue(value) { const trimmed = value.trim(); @@ -19,24 +22,26 @@ export function parseEnvValue(value) { export async function readDatabaseUrlFromEnvFiles() { const envFiles = ['.dev.vars', '.env.local', '.env']; - for (const file of envFiles) { - try { - const content = await readFile(path.join(rootDir, file), 'utf8'); - for (const line of content.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const separatorIndex = trimmed.indexOf('='); - if (separatorIndex === -1) continue; - - const key = trimmed.slice(0, separatorIndex).trim(); - if (key === 'DATABASE_URL') { - return parseEnvValue(trimmed.slice(separatorIndex + 1)); + for (const dir of searchDirs) { + for (const file of envFiles) { + try { + const content = await readFile(path.join(dir, file), 'utf8'); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const separatorIndex = trimmed.indexOf('='); + if (separatorIndex === -1) continue; + + const key = trimmed.slice(0, separatorIndex).trim(); + if (key === 'DATABASE_URL') { + return parseEnvValue(trimmed.slice(separatorIndex + 1)); + } + } + } catch (error) { + if (error?.code !== 'ENOENT') { + throw error; } - } - } catch (error) { - if (error?.code !== 'ENOENT') { - throw error; } } } diff --git a/packages/db/scripts/migrate.mjs b/packages/db/scripts/migrate.mjs index 0553afac..549d1754 100644 --- a/packages/db/scripts/migrate.mjs +++ b/packages/db/scripts/migrate.mjs @@ -8,6 +8,14 @@ import { splitSqlStatements } from './migrate-sql-split.mjs'; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const migrationsDir = path.join(rootDir, 'migrations'); const migrationLockId = 93741624; + +// An extra migrations directory can be layered on top of the core set: resolved against cwd so it works from anywhere, and tracked with an `extra:` prefix so its 002_x.sql cannot collide with a core 002_y.sql. +const extraDirInput = + process.argv.find((arg) => arg.startsWith('--extra-dir='))?.slice('--extra-dir='.length) + ?? process.env.CODRA_EXTRA_MIGRATIONS_DIR + ?? null; +const extraMigrationsDir = extraDirInput ? path.resolve(process.cwd(), extraDirInput) : null; +const extraTrackingPrefix = 'extra:'; const kimiK25Model = '@cf/moonshotai/kimi-k2.5'; const kimiK26Model = '@cf/moonshotai/kimi-k2.6'; @@ -56,16 +64,22 @@ async function ensureMigrationTable() { `); } -async function runMigration(name) { - const filePath = path.join(migrationsDir, name); +async function runMigration(dir, fileName, trackedName) { + const filePath = path.join(dir, fileName); const migrationSql = await readFile(filePath, 'utf8'); - console.log(`Applying ${name}...`); + console.log(`Applying ${trackedName}...`); for (const statement of splitSqlStatements(migrationSql)) { await query(statement); } - await query('INSERT INTO schema_migrations (name) VALUES ($1)', [name]); - console.log(`Applied ${name}.`); + await query('INSERT INTO schema_migrations (name) VALUES ($1)', [trackedName]); + console.log(`Applied ${trackedName}.`); +} + +async function listMigrationFiles(dir) { + return (await readdir(dir)) + .filter((name) => /^\d+_.+\.sql$/.test(name)) + .sort(); } async function ensureModelCatalog() { @@ -111,9 +125,6 @@ async function ensureModelCatalog() { ('Vertex AI', 'vertex', NULL, FALSE) ON CONFLICT (name) DO UPDATE SET api_format = EXCLUDED.api_format, - -- COALESCE, not EXCLUDED: this seed re-runs on every deploy, and Vertex ships with a NULL - -- base_url because the endpoint is project- and region-specific. A bare assignment would - -- wipe the URL the operator configured in Settings every time they deployed. base_url = COALESCE(EXCLUDED.base_url, llm_providers.base_url), updated_at = now() `); @@ -277,24 +288,36 @@ async function main() { console.log('Starting database migrations...'); await query('BEGIN'); try { - // Transaction-scoped on purpose: a session-scoped pg_advisory_lock once leaked in production - // when the process died before its `finally` unlock, leaving the pooler holding it and - // blocking every later migrate until pg_terminate_backend. pg_advisory_xact_lock and SET LOCAL - // release automatically on COMMIT/ROLLBACK/disconnect. + // Transaction-scoped on purpose: a session-scoped lock survives a process that dies before unlocking and blocks every later migrate, while these release on COMMIT/ROLLBACK/disconnect. console.log('Acquiring advisory lock...'); await query("SET LOCAL lock_timeout = '30s'"); await query('SELECT pg_advisory_xact_lock($1)', [migrationLockId]); await ensureMigrationTable(); - const migrationFiles = (await readdir(migrationsDir)) - .filter((name) => /^\d+_.+\.sql$/.test(name)) - .sort(); + const migrationFiles = await listMigrationFiles(migrationsDir); const applied = await appliedMigrations(); for (const migration of migrationFiles) { if (!applied.has(migration)) { - await runMigration(migration); + await runMigration(migrationsDir, migration, migration); + } + } + + // Strictly after the core set, since extra migrations may reference core tables, and inside the same transaction and advisory lock so a failure rolls the core ones back too. + if (extraMigrationsDir) { + let extraFiles; + try { + extraFiles = await listMigrationFiles(extraMigrationsDir); + } catch (error) { + throw new Error(`Extra migrations directory not readable: ${extraMigrationsDir}`, { cause: error }); + } + + for (const migration of extraFiles) { + const trackedName = `${extraTrackingPrefix}${migration}`; + if (!applied.has(trackedName)) { + await runMigration(extraMigrationsDir, migration, trackedName); + } } } @@ -312,7 +335,6 @@ async function main() { console.log('Database migrations are up to date.'); } finally { - // No explicit unlock needed: COMMIT/ROLLBACK already released the transaction-scoped lock. await sql.end(); } } diff --git a/packages/db/src/comment-feedback.ts b/packages/db/src/comment-feedback.ts index e7f5ee6e..69d508f3 100644 --- a/packages/db/src/comment-feedback.ts +++ b/packages/db/src/comment-feedback.ts @@ -1,117 +1,117 @@ import type { DbEnv } from './env'; - -import { queryRows } from './client'; - -// 'deleted' and 'marked_wrong' are the negative signals; 'resolved' and 'marked_right' are MEASUREMENT only, since suppressing on them would train the system to stop reporting findings that worked. -// The ABSENCE of a row is not a signal either way, so precision is only ever `marked_right / (marked_right + marked_wrong)`, reported with n. -export type CommentOutcome = 'posted' | 'deleted' | 'resolved' | 'unresolved' | 'marked_wrong' | 'marked_right'; - -export type CommentFeedbackInput = { - repositoryId: number; - prNumber: number | null; - fingerprint: string; - anchorHash: string | null; - // Title-independent identity, carried so a reworded repeat of a rejected claim also matches. - fingerprintV2?: string | null; - githubCommentId: number; - outcome: CommentOutcome; -}; - -// Keyed by fingerprint, not review_comments.id: those rows are deleted and re-inserted on every re-review, so their ids anchor nothing long-lived. -export async function recordCommentFeedback( - env: DbEnv, - entries: CommentFeedbackInput[], -): Promise { - if (entries.length === 0) return 0; - - const rows = await queryRows<{ id: string }>( - env, - ` - INSERT INTO comment_feedback (repository_id, pr_number, fingerprint, anchor_hash, github_comment_id, outcome, fingerprint_v2) - SELECT * FROM UNNEST($1::int[], $2::int[], $3::text[], $4::text[], $5::bigint[], $6::text[], $7::text[]) - ON CONFLICT (repository_id, github_comment_id, outcome) DO NOTHING - RETURNING id - `, - [ - entries.map((e) => e.repositoryId), - entries.map((e) => e.prNumber ?? null), - entries.map((e) => e.fingerprint), - entries.map((e) => e.anchorHash ?? null), - entries.map((e) => e.githubCommentId), - entries.map((e) => e.outcome), - entries.map((e) => e.fingerprintV2 ?? null), - ], - ); - - return rows.length; -} - -// Targets the partial index on `(repository_id, fingerprint) WHERE source = 'dashboard'`, making a flip an UPDATE rather than two contradictory rows. -export async function upsertDashboardFeedback( - env: DbEnv, - input: { - repositoryId: number; - prNumber: number | null; - fingerprint: string; - anchorHash: string | null; - fingerprintV2?: string | null; - jobId: string; - labelledBy: number | null; - outcome: 'marked_wrong' | 'marked_right'; - }, -): Promise { - await queryRows( - env, - ` - INSERT INTO comment_feedback - (repository_id, pr_number, fingerprint, anchor_hash, github_comment_id, outcome, source, job_id, labelled_by, fingerprint_v2) - VALUES ($1::int, $2::int, $3::text, $4::text, NULL, $5::text, 'dashboard', $6::uuid, $7::bigint, $8::text) - ON CONFLICT (repository_id, fingerprint) WHERE source = 'dashboard' - DO UPDATE SET - outcome = EXCLUDED.outcome, - pr_number = EXCLUDED.pr_number, - anchor_hash = COALESCE(EXCLUDED.anchor_hash, comment_feedback.anchor_hash), - fingerprint_v2 = COALESCE(EXCLUDED.fingerprint_v2, comment_feedback.fingerprint_v2), - job_id = EXCLUDED.job_id, - labelled_by = EXCLUDED.labelled_by, - updated_at = now() - `, - [ - input.repositoryId, input.prNumber, input.fingerprint, input.anchorHash, - input.outcome, input.jobId, input.labelledBy, input.fingerprintV2 ?? null, - ], - ); -} - -// Scoped to `source = 'dashboard'`: a webhook-sourced row is ground truth from GitHub and must not be erasable here. -export async function clearDashboardFeedback( - env: DbEnv, - repositoryId: number, - fingerprint: string, -): Promise { - await queryRows( - env, - `DELETE FROM comment_feedback - WHERE repository_id = $1::int AND fingerprint = $2::text AND source = 'dashboard'`, - [repositoryId, fingerprint], - ); -} - -// Prevents a resolve -> unresolve round trip from leaving the finding permanently recorded as accepted. -export async function clearResolvedFeedback( - env: DbEnv, - repositoryId: number, - githubCommentIds: number[], -): Promise { - if (githubCommentIds.length === 0) return; - await queryRows( - env, - ` - DELETE FROM comment_feedback - WHERE repository_id = $1::int - AND outcome = 'resolved' - AND github_comment_id = ANY($2::bigint[]) - `, - [repositoryId, githubCommentIds], - ); -} + +import { queryRows } from './client'; + +// 'deleted' and 'marked_wrong' are the negative signals; 'resolved' and 'marked_right' are MEASUREMENT only, since suppressing on them would train the system to stop reporting findings that worked. +// The ABSENCE of a row is not a signal either way, so precision is only ever `marked_right / (marked_right + marked_wrong)`, reported with n. +export type CommentOutcome = 'posted' | 'deleted' | 'resolved' | 'unresolved' | 'marked_wrong' | 'marked_right'; + +export type CommentFeedbackInput = { + repositoryId: number; + prNumber: number | null; + fingerprint: string; + anchorHash: string | null; + // Title-independent identity, carried so a reworded repeat of a rejected claim also matches. + fingerprintV2?: string | null; + githubCommentId: number; + outcome: CommentOutcome; +}; + +// Keyed by fingerprint, not review_comments.id: those rows are deleted and re-inserted on every re-review, so their ids anchor nothing long-lived. +export async function recordCommentFeedback( + env: DbEnv, + entries: CommentFeedbackInput[], +): Promise { + if (entries.length === 0) return 0; + + const rows = await queryRows<{ id: string }>( + env, + ` + INSERT INTO comment_feedback (repository_id, pr_number, fingerprint, anchor_hash, github_comment_id, outcome, fingerprint_v2) + SELECT * FROM UNNEST($1::int[], $2::int[], $3::text[], $4::text[], $5::bigint[], $6::text[], $7::text[]) + ON CONFLICT (repository_id, github_comment_id, outcome) DO NOTHING + RETURNING id + `, + [ + entries.map((e) => e.repositoryId), + entries.map((e) => e.prNumber ?? null), + entries.map((e) => e.fingerprint), + entries.map((e) => e.anchorHash ?? null), + entries.map((e) => e.githubCommentId), + entries.map((e) => e.outcome), + entries.map((e) => e.fingerprintV2 ?? null), + ], + ); + + return rows.length; +} + +// Targets the partial index on `(repository_id, fingerprint) WHERE source = 'dashboard'`, making a flip an UPDATE rather than two contradictory rows. +export async function upsertDashboardFeedback( + env: DbEnv, + input: { + repositoryId: number; + prNumber: number | null; + fingerprint: string; + anchorHash: string | null; + fingerprintV2?: string | null; + jobId: string; + labelledBy: number | null; + outcome: 'marked_wrong' | 'marked_right'; + }, +): Promise { + await queryRows( + env, + ` + INSERT INTO comment_feedback + (repository_id, pr_number, fingerprint, anchor_hash, github_comment_id, outcome, source, job_id, labelled_by, fingerprint_v2) + VALUES ($1::int, $2::int, $3::text, $4::text, NULL, $5::text, 'dashboard', $6::uuid, $7::bigint, $8::text) + ON CONFLICT (repository_id, fingerprint) WHERE source = 'dashboard' + DO UPDATE SET + outcome = EXCLUDED.outcome, + pr_number = EXCLUDED.pr_number, + anchor_hash = COALESCE(EXCLUDED.anchor_hash, comment_feedback.anchor_hash), + fingerprint_v2 = COALESCE(EXCLUDED.fingerprint_v2, comment_feedback.fingerprint_v2), + job_id = EXCLUDED.job_id, + labelled_by = EXCLUDED.labelled_by, + updated_at = now() + `, + [ + input.repositoryId, input.prNumber, input.fingerprint, input.anchorHash, + input.outcome, input.jobId, input.labelledBy, input.fingerprintV2 ?? null, + ], + ); +} + +// Scoped to `source = 'dashboard'`: a webhook-sourced row is ground truth from GitHub and must not be erasable here. +export async function clearDashboardFeedback( + env: DbEnv, + repositoryId: number, + fingerprint: string, +): Promise { + await queryRows( + env, + `DELETE FROM comment_feedback + WHERE repository_id = $1::int AND fingerprint = $2::text AND source = 'dashboard'`, + [repositoryId, fingerprint], + ); +} + +// Prevents a resolve -> unresolve round trip from leaving the finding permanently recorded as accepted. +export async function clearResolvedFeedback( + env: DbEnv, + repositoryId: number, + githubCommentIds: number[], +): Promise { + if (githubCommentIds.length === 0) return; + await queryRows( + env, + ` + DELETE FROM comment_feedback + WHERE repository_id = $1::int + AND outcome = 'resolved' + AND github_comment_id = ANY($2::bigint[]) + `, + [repositoryId, githubCommentIds], + ); +} diff --git a/packages/db/src/file-reviews.ts b/packages/db/src/file-reviews.ts index effd1811..5edc769e 100644 --- a/packages/db/src/file-reviews.ts +++ b/packages/db/src/file-reviews.ts @@ -1,312 +1,312 @@ -import type { DbEnv } from './env'; -import type { ParsedReviewComment } from '@codraoss/schema'; - -import { parseJsonColumn, queryRows, queryTransaction } from './client'; -import { - REVIEW_COMMENT_INSERT_CASTS, - REVIEW_COMMENT_INSERT_COLUMNS, - reviewCommentInsertValues, - reviewCommentsAggregate, -} from './review-comment-sql'; -import { - type SuppressedFinding, - getSuppressedFindings, - getFindingLabelTarget, - markCommentsPosted, - markCommentDispositions, -} from './file-reviews-findings'; -import { - type BulkFileReviewInput, - bulkInheritFileReviews, - bulkMarkFilesFailed, - bulkRecordRetryableFileReviewFailures, - bulkUpsertFileReviews, -} from './file-reviews-bulk'; - -export { - type SuppressedFinding, - getSuppressedFindings, - getFindingLabelTarget, - markCommentsPosted, - markCommentDispositions, -}; - -// Multi-file writers live in their own module (max-lines); callers still import them from here. -export { - type BulkFileReviewInput, - bulkInheritFileReviews, - bulkMarkFilesFailed, - bulkRecordRetryableFileReviewFailures, - bulkUpsertFileReviews, -}; - -export async function upsertFileReview( - env: DbEnv, - jobId: string, - input: { - filePath: string; - fileStatus: 'pending' | 'done' | 'skipped' | 'failed'; - modelUsed: string; - modelProvider?: string | null; - diffLineCount: number; - diffInput: string | null; - rawAiOutput: string | null; - parsedComments: ParsedReviewComment[]; - inputTokens: number | null; - outputTokens: number | null; - durationMs: number | null; - verdict: 'approve' | 'comment' | null; - fileSummary: string | null; - overallCorrectness?: string | null; - confidenceScore?: number | null; - errorMessage: string | null; - // Findings dropped in the PARSER have no review_comments row to carry a disposition; without this, "everything was withheld" is indistinguishable from clean. - withheldCounts?: { evidence: number; claimDenied: number; contextOnly?: number; absenceRefuted?: number } | null; - // The call answered, but not cleanly: it ran without a response grammar, or its output was cut off - // and salvaged. Persisted rather than logged so "how often did this happen" is a query. - degraded?: string | null; - // Async batch bookkeeping: set on submit to the Workers AI queue, cleared once the batch completes. - asyncRequestId?: string | null; - asyncModel?: string | null; - }, -) { - await queryTransaction(env, async (tx) => { - const [review] = await tx.query<{ id: string }>( - ` - INSERT INTO file_reviews ( - job_id, - file_path, - file_status, - model_used, - diff_line_count, - diff_input, - raw_ai_output, - input_tokens, - output_tokens, - duration_ms, - verdict, - file_summary, - overall_correctness, - confidence_score, - error_msg, - model_provider, - async_request_id, - async_model, - withheld_counts, - degraded, - batch_size - ) - VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19::text::jsonb, $20, 1) - ON CONFLICT (job_id, file_path) DO UPDATE SET - file_status = EXCLUDED.file_status, - model_used = EXCLUDED.model_used, - diff_line_count = EXCLUDED.diff_line_count, - diff_input = EXCLUDED.diff_input, - raw_ai_output = EXCLUDED.raw_ai_output, - input_tokens = EXCLUDED.input_tokens, - output_tokens = EXCLUDED.output_tokens, - duration_ms = EXCLUDED.duration_ms, - verdict = EXCLUDED.verdict, - file_summary = EXCLUDED.file_summary, - overall_correctness = EXCLUDED.overall_correctness, - confidence_score = EXCLUDED.confidence_score, - error_msg = EXCLUDED.error_msg, - model_provider = EXCLUDED.model_provider, - async_request_id = EXCLUDED.async_request_id, - async_model = EXCLUDED.async_model, - withheld_counts = EXCLUDED.withheld_counts, - degraded = EXCLUDED.degraded, - batch_size = EXCLUDED.batch_size, - transient_error_count = 0 - RETURNING id - `, - [ - jobId, - input.filePath, - input.fileStatus, - input.modelUsed, - input.diffLineCount, - input.diffInput, - input.rawAiOutput, - input.inputTokens, - input.outputTokens, - input.durationMs, - input.verdict, - input.fileSummary, - input.overallCorrectness ?? null, - input.confidenceScore ?? null, - input.errorMessage, - input.modelProvider ?? null, - input.asyncRequestId ?? null, - input.asyncModel ?? null, - // JSON text to ::text::jsonb placeholder prevents string-scalar bugs. - input.withheldCounts ? JSON.stringify(input.withheldCounts) : null, - input.degraded ?? null, - ], - ); - - await tx.query('DELETE FROM review_comments WHERE file_review_id = $1::uuid', [review.id]); - - if (input.parsedComments.length > 0) { - await tx.query( - ` - INSERT INTO review_comments (file_review_id, ${REVIEW_COMMENT_INSERT_COLUMNS.join(', ')}) - SELECT $1::uuid, * FROM UNNEST(${REVIEW_COMMENT_INSERT_CASTS}) - `, - [review.id, ...reviewCommentInsertValues(input.parsedComments)], - ); - } - }); -} - -export async function recordRetryableFileReviewFailure( - env: DbEnv, - jobId: string, - input: { - filePath: string; - modelUsed: string; - modelProvider?: string | null; - diffLineCount: number; - diffInput: string | null; - durationMs: number | null; - errorMessage: string; - // False while the model chain still has untried entries: the deferral made progress (the next - // attempt resumes further down the chain), so it is not evidence of a repeated outage and must - // not spend one of MAX_RETRYABLE_FILE_REVIEW_FAILURES. - countsAsAttempt?: boolean; - }, -) { - return await queryTransaction(env, async (tx) => { - const [review] = await tx.query<{ id: string; transient_error_count: number }>( - ` - INSERT INTO file_reviews ( - job_id, - file_path, - file_status, - model_used, - model_provider, - diff_line_count, - diff_input, - raw_ai_output, - input_tokens, - output_tokens, - duration_ms, - verdict, - file_summary, - overall_correctness, - confidence_score, - error_msg, - transient_error_count - ) - VALUES ($1::uuid, $2, 'failed', $3, $4, $5, $6, NULL, NULL, NULL, $7, NULL, NULL, NULL, NULL, $8, $9::int) - ON CONFLICT (job_id, file_path) DO UPDATE SET - file_status = 'failed', - model_used = EXCLUDED.model_used, - model_provider = EXCLUDED.model_provider, - diff_line_count = EXCLUDED.diff_line_count, - diff_input = EXCLUDED.diff_input, - raw_ai_output = NULL, - input_tokens = NULL, - output_tokens = NULL, - duration_ms = EXCLUDED.duration_ms, - verdict = NULL, - file_summary = NULL, - overall_correctness = NULL, - confidence_score = NULL, - error_msg = EXCLUDED.error_msg, - transient_error_count = file_reviews.transient_error_count + $9::int - RETURNING id, transient_error_count - `, - [ - jobId, - input.filePath, - input.modelUsed, - input.modelProvider ?? null, - input.diffLineCount, - input.diffInput, - input.durationMs, - input.errorMessage, - input.countsAsAttempt === false ? 0 : 1, - ], - ); - - await tx.query('DELETE FROM review_comments WHERE file_review_id = $1::uuid', [review.id]); - return review.transient_error_count; - }); -} - - -export async function getModelUsageStats(env: DbEnv, days: number) { - return queryRows<{ - model_used: string; - model_provider: string | null; - calls: number; - input_tokens: number | null; - output_tokens: number | null; - }>( - env, - ` - SELECT - model_used, - MIN(model_provider) AS model_provider, - COUNT(*)::int AS calls, - COALESCE(SUM(input_tokens), 0)::int AS input_tokens, - COALESCE(SUM(output_tokens), 0)::int AS output_tokens - FROM file_reviews - WHERE created_at >= now() - ($1::int * interval '1 day') - GROUP BY model_used - ORDER BY calls DESC, model_used ASC - LIMIT 20 - `, - [days], - ); -} - -export async function getFileReviewsForJobs(env: DbEnv, jobIds: string[]) { - if (jobIds.length === 0) return []; - - const rows = await queryRows<{ - id: string; - job_id: string; - file_path: string; - file_status: 'pending' | 'done' | 'skipped' | 'failed'; - model_used: string; - diff_line_count: number; - diff_input: string | null; - raw_ai_output: string | null; - parsed_comments: ParsedReviewComment[] | string; - input_tokens: number | null; - output_tokens: number | null; - duration_ms: number | null; - verdict: 'approve' | 'comment' | null; - file_summary: string | null; - overall_correctness: string | null; - confidence_score: number | null; - error_msg: string | null; - model_provider: string | null; - transient_error_count: number; - async_request_id: string | null; - async_model: string | null; - withheld_counts: { evidence?: number; claimDenied?: number } | string | null; - // NULL pre-batching; 1 reviewed alone, N for a packed bin. - batch_size: number | null; - }>( - env, - ` - SELECT - fr.*, - ${reviewCommentsAggregate()} AS parsed_comments - FROM file_reviews fr - WHERE fr.job_id = ANY($1::uuid[]) - ORDER BY fr.created_at ASC - `, - [jobIds], - ); - - return rows.map((row) => ({ - ...row, - parsed_comments: parseJsonColumn(row.parsed_comments, []), - withheld_counts: parseJsonColumn(row.withheld_counts, {} as { evidence?: number; claimDenied?: number }), - })); -} - +import type { DbEnv } from './env'; +import type { ParsedReviewComment } from '@codraoss/schema'; + +import { parseJsonColumn, queryRows, queryTransaction } from './client'; +import { + REVIEW_COMMENT_INSERT_CASTS, + REVIEW_COMMENT_INSERT_COLUMNS, + reviewCommentInsertValues, + reviewCommentsAggregate, +} from './review-comment-sql'; +import { + type SuppressedFinding, + getSuppressedFindings, + getFindingLabelTarget, + markCommentsPosted, + markCommentDispositions, +} from './file-reviews-findings'; +import { + type BulkFileReviewInput, + bulkInheritFileReviews, + bulkMarkFilesFailed, + bulkRecordRetryableFileReviewFailures, + bulkUpsertFileReviews, +} from './file-reviews-bulk'; + +export { + type SuppressedFinding, + getSuppressedFindings, + getFindingLabelTarget, + markCommentsPosted, + markCommentDispositions, +}; + +// Multi-file writers live in their own module (max-lines); callers still import them from here. +export { + type BulkFileReviewInput, + bulkInheritFileReviews, + bulkMarkFilesFailed, + bulkRecordRetryableFileReviewFailures, + bulkUpsertFileReviews, +}; + +export async function upsertFileReview( + env: DbEnv, + jobId: string, + input: { + filePath: string; + fileStatus: 'pending' | 'done' | 'skipped' | 'failed'; + modelUsed: string; + modelProvider?: string | null; + diffLineCount: number; + diffInput: string | null; + rawAiOutput: string | null; + parsedComments: ParsedReviewComment[]; + inputTokens: number | null; + outputTokens: number | null; + durationMs: number | null; + verdict: 'approve' | 'comment' | null; + fileSummary: string | null; + overallCorrectness?: string | null; + confidenceScore?: number | null; + errorMessage: string | null; + // Findings dropped in the PARSER have no review_comments row to carry a disposition; without this, "everything was withheld" is indistinguishable from clean. + withheldCounts?: { evidence: number; claimDenied: number; contextOnly?: number; absenceRefuted?: number } | null; + // The call answered, but not cleanly: it ran without a response grammar, or its output was cut off + // and salvaged. Persisted rather than logged so "how often did this happen" is a query. + degraded?: string | null; + // Async batch bookkeeping: set on submit to the Workers AI queue, cleared once the batch completes. + asyncRequestId?: string | null; + asyncModel?: string | null; + }, +) { + await queryTransaction(env, async (tx) => { + const [review] = await tx.query<{ id: string }>( + ` + INSERT INTO file_reviews ( + job_id, + file_path, + file_status, + model_used, + diff_line_count, + diff_input, + raw_ai_output, + input_tokens, + output_tokens, + duration_ms, + verdict, + file_summary, + overall_correctness, + confidence_score, + error_msg, + model_provider, + async_request_id, + async_model, + withheld_counts, + degraded, + batch_size + ) + VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19::text::jsonb, $20, 1) + ON CONFLICT (job_id, file_path) DO UPDATE SET + file_status = EXCLUDED.file_status, + model_used = EXCLUDED.model_used, + diff_line_count = EXCLUDED.diff_line_count, + diff_input = EXCLUDED.diff_input, + raw_ai_output = EXCLUDED.raw_ai_output, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + duration_ms = EXCLUDED.duration_ms, + verdict = EXCLUDED.verdict, + file_summary = EXCLUDED.file_summary, + overall_correctness = EXCLUDED.overall_correctness, + confidence_score = EXCLUDED.confidence_score, + error_msg = EXCLUDED.error_msg, + model_provider = EXCLUDED.model_provider, + async_request_id = EXCLUDED.async_request_id, + async_model = EXCLUDED.async_model, + withheld_counts = EXCLUDED.withheld_counts, + degraded = EXCLUDED.degraded, + batch_size = EXCLUDED.batch_size, + transient_error_count = 0 + RETURNING id + `, + [ + jobId, + input.filePath, + input.fileStatus, + input.modelUsed, + input.diffLineCount, + input.diffInput, + input.rawAiOutput, + input.inputTokens, + input.outputTokens, + input.durationMs, + input.verdict, + input.fileSummary, + input.overallCorrectness ?? null, + input.confidenceScore ?? null, + input.errorMessage, + input.modelProvider ?? null, + input.asyncRequestId ?? null, + input.asyncModel ?? null, + // JSON text to ::text::jsonb placeholder prevents string-scalar bugs. + input.withheldCounts ? JSON.stringify(input.withheldCounts) : null, + input.degraded ?? null, + ], + ); + + await tx.query('DELETE FROM review_comments WHERE file_review_id = $1::uuid', [review.id]); + + if (input.parsedComments.length > 0) { + await tx.query( + ` + INSERT INTO review_comments (file_review_id, ${REVIEW_COMMENT_INSERT_COLUMNS.join(', ')}) + SELECT $1::uuid, * FROM UNNEST(${REVIEW_COMMENT_INSERT_CASTS}) + `, + [review.id, ...reviewCommentInsertValues(input.parsedComments)], + ); + } + }); +} + +export async function recordRetryableFileReviewFailure( + env: DbEnv, + jobId: string, + input: { + filePath: string; + modelUsed: string; + modelProvider?: string | null; + diffLineCount: number; + diffInput: string | null; + durationMs: number | null; + errorMessage: string; + // False while the model chain still has untried entries: the deferral made progress (the next + // attempt resumes further down the chain), so it is not evidence of a repeated outage and must + // not spend one of MAX_RETRYABLE_FILE_REVIEW_FAILURES. + countsAsAttempt?: boolean; + }, +) { + return await queryTransaction(env, async (tx) => { + const [review] = await tx.query<{ id: string; transient_error_count: number }>( + ` + INSERT INTO file_reviews ( + job_id, + file_path, + file_status, + model_used, + model_provider, + diff_line_count, + diff_input, + raw_ai_output, + input_tokens, + output_tokens, + duration_ms, + verdict, + file_summary, + overall_correctness, + confidence_score, + error_msg, + transient_error_count + ) + VALUES ($1::uuid, $2, 'failed', $3, $4, $5, $6, NULL, NULL, NULL, $7, NULL, NULL, NULL, NULL, $8, $9::int) + ON CONFLICT (job_id, file_path) DO UPDATE SET + file_status = 'failed', + model_used = EXCLUDED.model_used, + model_provider = EXCLUDED.model_provider, + diff_line_count = EXCLUDED.diff_line_count, + diff_input = EXCLUDED.diff_input, + raw_ai_output = NULL, + input_tokens = NULL, + output_tokens = NULL, + duration_ms = EXCLUDED.duration_ms, + verdict = NULL, + file_summary = NULL, + overall_correctness = NULL, + confidence_score = NULL, + error_msg = EXCLUDED.error_msg, + transient_error_count = file_reviews.transient_error_count + $9::int + RETURNING id, transient_error_count + `, + [ + jobId, + input.filePath, + input.modelUsed, + input.modelProvider ?? null, + input.diffLineCount, + input.diffInput, + input.durationMs, + input.errorMessage, + input.countsAsAttempt === false ? 0 : 1, + ], + ); + + await tx.query('DELETE FROM review_comments WHERE file_review_id = $1::uuid', [review.id]); + return review.transient_error_count; + }); +} + + +export async function getModelUsageStats(env: DbEnv, days: number) { + return queryRows<{ + model_used: string; + model_provider: string | null; + calls: number; + input_tokens: number | null; + output_tokens: number | null; + }>( + env, + ` + SELECT + model_used, + MIN(model_provider) AS model_provider, + COUNT(*)::int AS calls, + COALESCE(SUM(input_tokens), 0)::int AS input_tokens, + COALESCE(SUM(output_tokens), 0)::int AS output_tokens + FROM file_reviews + WHERE created_at >= now() - ($1::int * interval '1 day') + GROUP BY model_used + ORDER BY calls DESC, model_used ASC + LIMIT 20 + `, + [days], + ); +} + +export async function getFileReviewsForJobs(env: DbEnv, jobIds: string[]) { + if (jobIds.length === 0) return []; + + const rows = await queryRows<{ + id: string; + job_id: string; + file_path: string; + file_status: 'pending' | 'done' | 'skipped' | 'failed'; + model_used: string; + diff_line_count: number; + diff_input: string | null; + raw_ai_output: string | null; + parsed_comments: ParsedReviewComment[] | string; + input_tokens: number | null; + output_tokens: number | null; + duration_ms: number | null; + verdict: 'approve' | 'comment' | null; + file_summary: string | null; + overall_correctness: string | null; + confidence_score: number | null; + error_msg: string | null; + model_provider: string | null; + transient_error_count: number; + async_request_id: string | null; + async_model: string | null; + withheld_counts: { evidence?: number; claimDenied?: number } | string | null; + // NULL pre-batching; 1 reviewed alone, N for a packed bin. + batch_size: number | null; + }>( + env, + ` + SELECT + fr.*, + ${reviewCommentsAggregate()} AS parsed_comments + FROM file_reviews fr + WHERE fr.job_id = ANY($1::uuid[]) + ORDER BY fr.created_at ASC + `, + [jobIds], + ); + + return rows.map((row) => ({ + ...row, + parsed_comments: parseJsonColumn(row.parsed_comments, []), + withheld_counts: parseJsonColumn(row.withheld_counts, {} as { evidence?: number; claimDenied?: number }), + })); +} + diff --git a/packages/db/src/model-configs.ts b/packages/db/src/model-configs.ts index 05ecfaae..ec6353a7 100644 --- a/packages/db/src/model-configs.ts +++ b/packages/db/src/model-configs.ts @@ -1,386 +1,386 @@ import type { DbEnv } from './env'; - + import { queryRows } from './client'; -import { PROVIDER_COLUMNS, MODEL_SELECT } from './constants'; -import { - KIMI_K2_5_MODEL, - llmProviderSchema, - modelConfigSchema, - type LlmApiFormat, - type LlmProvider, - type ModelConfig, - type ResolvedModelConfig, - type LlmProviderSecret, -} from '@codraoss/schema'; - -export type { ResolvedModelConfig, LlmProviderSecret }; - -type ProviderRow = { - id: string; - name: string; - api_format: LlmApiFormat; - base_url: string | null; - encrypted_api_key: string | null; - enabled: boolean; - created_at: string; - updated_at: string; -}; - -type ModelConfigRow = { - model_id: string; - provider_id: string; - provider_name: string; - api_format: LlmApiFormat; - model_name: string; - updated_at: string; -}; - - - -function mapProvider(row: ProviderRow): LlmProvider { - return llmProviderSchema.parse({ - id: row.id, - name: row.name, - apiFormat: row.api_format, - baseUrl: row.base_url, - enabled: row.enabled, - hasApiKey: Boolean(row.encrypted_api_key), - createdAt: row.created_at, - updatedAt: row.updated_at, - }); -} - -function mapProviderSecret(row: ProviderRow): LlmProviderSecret { - return { - ...mapProvider(row), - encryptedApiKey: row.encrypted_api_key, - }; -} - -function mapModelConfig(row: ModelConfigRow): ModelConfig { - return modelConfigSchema.parse({ - modelId: row.model_id, - providerId: row.provider_id, - providerName: row.provider_name, - apiFormat: row.api_format, - modelName: row.model_name, - updatedAt: row.updated_at, - }); -} - -// The llm_providers column list, in one place, since it was inlined at six sites before, all needing updates for one new column. - - - - -export async function listLlmProviders(env: DbEnv): Promise { - const rows = await queryRows( - env, - `SELECT ${PROVIDER_COLUMNS} FROM llm_providers ORDER BY name ASC`, - ); - return rows.map(mapProvider); -} - -export async function listLlmProviderSecrets(env: DbEnv): Promise { - const rows = await queryRows( - env, - `SELECT ${PROVIDER_COLUMNS} FROM llm_providers ORDER BY name ASC`, - ); - return rows.map(mapProviderSecret); -} - -export async function getLlmProvider(env: DbEnv, id: string): Promise { - const [row] = await queryRows( - env, - `SELECT ${PROVIDER_COLUMNS} FROM llm_providers WHERE id = $1`, - [id], - ); - return row ? mapProviderSecret(row) : null; -} - -export async function createLlmProvider( - env: DbEnv, - input: { - name: string; - apiFormat: LlmApiFormat; - baseUrl: string | null; - encryptedApiKey: string | null; - enabled: boolean; - }, -) { - const [row] = await queryRows( - env, - ` - INSERT INTO llm_providers (name, api_format, base_url, encrypted_api_key, enabled, updated_at) - VALUES ($1, $2, $3, $4, $5, now()) - RETURNING ${PROVIDER_COLUMNS} - `, - [input.name, input.apiFormat, input.baseUrl, input.encryptedApiKey, input.enabled], - ); - return mapProvider(row); -} - -export async function findLlmProviderByName(env: DbEnv, name: string): Promise { - const [row] = await queryRows( - env, - `SELECT ${PROVIDER_COLUMNS} FROM llm_providers WHERE lower(name) = lower($1)`, - [name], - ); - return row ? mapProvider(row) : null; -} - -export async function updateLlmProvider( - env: DbEnv, - id: string, - input: { - name: string; - apiFormat: LlmApiFormat; - baseUrl: string | null; - encryptedApiKey?: string | null; - enabled: boolean; - }, -) { - const params: unknown[] = [id, input.name, input.apiFormat, input.baseUrl, input.enabled]; - let apiKeySql = ''; - if (input.encryptedApiKey !== undefined) { - params.push(input.encryptedApiKey); - apiKeySql = `, encrypted_api_key = $${params.length}`; - } - - const [row] = await queryRows( - env, - ` - UPDATE llm_providers - SET - name = $2, - api_format = $3, - base_url = $4, - enabled = $5, - updated_at = now() - ${apiKeySql} - WHERE id = $1 - RETURNING ${PROVIDER_COLUMNS} - `, - params, - ); - return row ? mapProvider(row) : null; -} - -export async function deleteLlmProvider(env: DbEnv, id: string) { - const [{ count }] = await queryRows<{ count: string }>( - env, - `SELECT COUNT(*)::text AS count FROM model_configs WHERE provider_id = $1`, - [id], - ); - if (Number(count) > 0) { - return { deleted: false, reason: 'Provider is still used by one or more models.' }; - } - - const rows = await queryRows<{ id: string }>( - env, - `DELETE FROM llm_providers WHERE id = $1 RETURNING id`, - [id], - ); - return { deleted: rows.length > 0, reason: null }; -} - -export async function listModelConfigs(env: DbEnv): Promise { - const rows = await queryRows( - env, - `${MODEL_SELECT} - WHERE mc.model_id <> $1 - ORDER BY mc.model_id ASC`, - [KIMI_K2_5_MODEL], - ); - return rows.map(mapModelConfig); -} - -export async function getResolvedModelConfig( - env: DbEnv, - modelId: string, -): Promise { - const [row] = await queryRows( - env, - ` - SELECT - mc.model_id, - mc.provider_id, - p.name AS provider_name, - p.api_format, - mc.model_name, - mc.updated_at, - p.enabled AS provider_enabled, - p.base_url, - p.encrypted_api_key - FROM model_configs mc - JOIN llm_providers p ON p.id = mc.provider_id - WHERE mc.model_id = $1 - `, - [modelId], - ); - - if (!row) return null; - return { - ...mapModelConfig(row), - providerEnabled: row.provider_enabled, - baseUrl: row.base_url, - encryptedApiKey: row.encrypted_api_key, - }; -} - -export async function updateModelConfig( - env: DbEnv, - config: Omit, -) { - const [row] = await queryRows( - env, - ` - WITH upserted AS ( - INSERT INTO model_configs (model_id, provider_id, model_name, provider, updated_at) - SELECT $1, p.id, $3, p.api_format, now() - FROM llm_providers p - WHERE p.id = $2 - ON CONFLICT (model_id) - DO UPDATE SET - provider_id = EXCLUDED.provider_id, - model_name = EXCLUDED.model_name, - provider = EXCLUDED.provider, - updated_at = now() - RETURNING model_id, provider_id, model_name, updated_at - ) - SELECT - u.model_id, - u.provider_id, - p.name AS provider_name, - p.api_format, - u.model_name, - u.updated_at - FROM upserted u - JOIN llm_providers p ON p.id = u.provider_id - `, - [config.modelId, config.providerId, config.modelName], - ); - return row ? mapModelConfig(row) : null; -} - -function slugify(value: string) { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, '-') - .replace(/^-+|-+$/g, '') || 'provider'; -} - -export async function upsertDiscoveredModelConfigs( - env: DbEnv, - input: { - providerId: string; - providerName: string; - apiFormat: LlmApiFormat; - modelNames: string[]; - }, -) { - const uniqueModelNames = Array.from(new Set(input.modelNames.flatMap(name => { - const trimmed = name.trim(); - return trimmed ? [trimmed] : []; - }))); - if (uniqueModelNames.length === 0) return []; - - const providerSlug = slugify(input.providerName); - const [existingForProvider, existingModelIds] = await Promise.all([ - queryRows<{ model_id: string; model_name: string }>( - env, - `SELECT model_id, model_name FROM model_configs WHERE provider_id = $1`, - [input.providerId], - ), - queryRows<{ model_id: string }>( - env, - `SELECT model_id FROM model_configs WHERE model_id LIKE $1`, - [`${providerSlug}:%`], - ), - ]); - - const existingModelNames = new Set(existingForProvider.map(row => row.model_name)); - const usedModelIds = new Set(existingModelIds.map(row => row.model_id)); - const rowsToInsert: Array<{ - model_id: string; - provider_id: string; - model_name: string; - provider: LlmApiFormat; - }> = []; - - for (const modelName of uniqueModelNames) { - if (existingModelNames.has(modelName)) continue; - - const base = `${providerSlug}:${modelName}`; - let candidate = base; - let suffix = 2; - while (usedModelIds.has(candidate)) { - candidate = `${base}-${suffix}`; - suffix++; - } - usedModelIds.add(candidate); - - rowsToInsert.push({ - model_id: candidate, - provider_id: input.providerId, - model_name: modelName, - provider: input.apiFormat, - }); - } - - if (rowsToInsert.length === 0) return []; - - const modelIds = rowsToInsert.map(row => row.model_id); - const providerIds = rowsToInsert.map(row => row.provider_id); - const modelNames = rowsToInsert.map(row => row.model_name); - const providers = rowsToInsert.map(row => row.provider); - - const rows = await queryRows( - env, - ` - WITH incoming AS ( - SELECT * - FROM unnest( - $1::text[], - $2::uuid[], - $3::text[], - $4::text[] - ) AS item(model_id, provider_id, model_name, provider) - ), - inserted AS ( - INSERT INTO model_configs (model_id, provider_id, model_name, provider, updated_at) - SELECT model_id, provider_id, model_name, provider, now() - FROM incoming - ON CONFLICT (model_id) DO NOTHING - RETURNING model_id, provider_id, model_name, updated_at - ) - SELECT - i.model_id, - i.provider_id, - p.name AS provider_name, - p.api_format, - i.model_name, - i.updated_at - FROM inserted i - JOIN llm_providers p ON p.id = i.provider_id - ORDER BY i.model_id ASC - `, - [modelIds, providerIds, modelNames, providers], - ); - - return rows.map(mapModelConfig); -} - -export async function deleteModelConfig(env: DbEnv, modelId: string) { - const rows = await queryRows<{ model_id: string }>( - env, - `DELETE FROM model_configs WHERE model_id = $1 RETURNING model_id`, - [modelId], - ); - return rows.length > 0; -} +import { PROVIDER_COLUMNS, MODEL_SELECT } from './constants'; +import { + KIMI_K2_5_MODEL, + llmProviderSchema, + modelConfigSchema, + type LlmApiFormat, + type LlmProvider, + type ModelConfig, + type ResolvedModelConfig, + type LlmProviderSecret, +} from '@codraoss/schema'; + +export type { ResolvedModelConfig, LlmProviderSecret }; + +type ProviderRow = { + id: string; + name: string; + api_format: LlmApiFormat; + base_url: string | null; + encrypted_api_key: string | null; + enabled: boolean; + created_at: string; + updated_at: string; +}; + +type ModelConfigRow = { + model_id: string; + provider_id: string; + provider_name: string; + api_format: LlmApiFormat; + model_name: string; + updated_at: string; +}; + + + +function mapProvider(row: ProviderRow): LlmProvider { + return llmProviderSchema.parse({ + id: row.id, + name: row.name, + apiFormat: row.api_format, + baseUrl: row.base_url, + enabled: row.enabled, + hasApiKey: Boolean(row.encrypted_api_key), + createdAt: row.created_at, + updatedAt: row.updated_at, + }); +} + +function mapProviderSecret(row: ProviderRow): LlmProviderSecret { + return { + ...mapProvider(row), + encryptedApiKey: row.encrypted_api_key, + }; +} + +function mapModelConfig(row: ModelConfigRow): ModelConfig { + return modelConfigSchema.parse({ + modelId: row.model_id, + providerId: row.provider_id, + providerName: row.provider_name, + apiFormat: row.api_format, + modelName: row.model_name, + updatedAt: row.updated_at, + }); +} + +// The llm_providers column list, in one place, since it was inlined at six sites before, all needing updates for one new column. + + + + +export async function listLlmProviders(env: DbEnv): Promise { + const rows = await queryRows( + env, + `SELECT ${PROVIDER_COLUMNS} FROM llm_providers ORDER BY name ASC`, + ); + return rows.map(mapProvider); +} + +export async function listLlmProviderSecrets(env: DbEnv): Promise { + const rows = await queryRows( + env, + `SELECT ${PROVIDER_COLUMNS} FROM llm_providers ORDER BY name ASC`, + ); + return rows.map(mapProviderSecret); +} + +export async function getLlmProvider(env: DbEnv, id: string): Promise { + const [row] = await queryRows( + env, + `SELECT ${PROVIDER_COLUMNS} FROM llm_providers WHERE id = $1`, + [id], + ); + return row ? mapProviderSecret(row) : null; +} + +export async function createLlmProvider( + env: DbEnv, + input: { + name: string; + apiFormat: LlmApiFormat; + baseUrl: string | null; + encryptedApiKey: string | null; + enabled: boolean; + }, +) { + const [row] = await queryRows( + env, + ` + INSERT INTO llm_providers (name, api_format, base_url, encrypted_api_key, enabled, updated_at) + VALUES ($1, $2, $3, $4, $5, now()) + RETURNING ${PROVIDER_COLUMNS} + `, + [input.name, input.apiFormat, input.baseUrl, input.encryptedApiKey, input.enabled], + ); + return mapProvider(row); +} + +export async function findLlmProviderByName(env: DbEnv, name: string): Promise { + const [row] = await queryRows( + env, + `SELECT ${PROVIDER_COLUMNS} FROM llm_providers WHERE lower(name) = lower($1)`, + [name], + ); + return row ? mapProvider(row) : null; +} + +export async function updateLlmProvider( + env: DbEnv, + id: string, + input: { + name: string; + apiFormat: LlmApiFormat; + baseUrl: string | null; + encryptedApiKey?: string | null; + enabled: boolean; + }, +) { + const params: unknown[] = [id, input.name, input.apiFormat, input.baseUrl, input.enabled]; + let apiKeySql = ''; + if (input.encryptedApiKey !== undefined) { + params.push(input.encryptedApiKey); + apiKeySql = `, encrypted_api_key = $${params.length}`; + } + + const [row] = await queryRows( + env, + ` + UPDATE llm_providers + SET + name = $2, + api_format = $3, + base_url = $4, + enabled = $5, + updated_at = now() + ${apiKeySql} + WHERE id = $1 + RETURNING ${PROVIDER_COLUMNS} + `, + params, + ); + return row ? mapProvider(row) : null; +} + +export async function deleteLlmProvider(env: DbEnv, id: string) { + const [{ count }] = await queryRows<{ count: string }>( + env, + `SELECT COUNT(*)::text AS count FROM model_configs WHERE provider_id = $1`, + [id], + ); + if (Number(count) > 0) { + return { deleted: false, reason: 'Provider is still used by one or more models.' }; + } + + const rows = await queryRows<{ id: string }>( + env, + `DELETE FROM llm_providers WHERE id = $1 RETURNING id`, + [id], + ); + return { deleted: rows.length > 0, reason: null }; +} + +export async function listModelConfigs(env: DbEnv): Promise { + const rows = await queryRows( + env, + `${MODEL_SELECT} + WHERE mc.model_id <> $1 + ORDER BY mc.model_id ASC`, + [KIMI_K2_5_MODEL], + ); + return rows.map(mapModelConfig); +} + +export async function getResolvedModelConfig( + env: DbEnv, + modelId: string, +): Promise { + const [row] = await queryRows( + env, + ` + SELECT + mc.model_id, + mc.provider_id, + p.name AS provider_name, + p.api_format, + mc.model_name, + mc.updated_at, + p.enabled AS provider_enabled, + p.base_url, + p.encrypted_api_key + FROM model_configs mc + JOIN llm_providers p ON p.id = mc.provider_id + WHERE mc.model_id = $1 + `, + [modelId], + ); + + if (!row) return null; + return { + ...mapModelConfig(row), + providerEnabled: row.provider_enabled, + baseUrl: row.base_url, + encryptedApiKey: row.encrypted_api_key, + }; +} + +export async function updateModelConfig( + env: DbEnv, + config: Omit, +) { + const [row] = await queryRows( + env, + ` + WITH upserted AS ( + INSERT INTO model_configs (model_id, provider_id, model_name, provider, updated_at) + SELECT $1, p.id, $3, p.api_format, now() + FROM llm_providers p + WHERE p.id = $2 + ON CONFLICT (model_id) + DO UPDATE SET + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + provider = EXCLUDED.provider, + updated_at = now() + RETURNING model_id, provider_id, model_name, updated_at + ) + SELECT + u.model_id, + u.provider_id, + p.name AS provider_name, + p.api_format, + u.model_name, + u.updated_at + FROM upserted u + JOIN llm_providers p ON p.id = u.provider_id + `, + [config.modelId, config.providerId, config.modelName], + ); + return row ? mapModelConfig(row) : null; +} + +function slugify(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') || 'provider'; +} + +export async function upsertDiscoveredModelConfigs( + env: DbEnv, + input: { + providerId: string; + providerName: string; + apiFormat: LlmApiFormat; + modelNames: string[]; + }, +) { + const uniqueModelNames = Array.from(new Set(input.modelNames.flatMap(name => { + const trimmed = name.trim(); + return trimmed ? [trimmed] : []; + }))); + if (uniqueModelNames.length === 0) return []; + + const providerSlug = slugify(input.providerName); + const [existingForProvider, existingModelIds] = await Promise.all([ + queryRows<{ model_id: string; model_name: string }>( + env, + `SELECT model_id, model_name FROM model_configs WHERE provider_id = $1`, + [input.providerId], + ), + queryRows<{ model_id: string }>( + env, + `SELECT model_id FROM model_configs WHERE model_id LIKE $1`, + [`${providerSlug}:%`], + ), + ]); + + const existingModelNames = new Set(existingForProvider.map(row => row.model_name)); + const usedModelIds = new Set(existingModelIds.map(row => row.model_id)); + const rowsToInsert: Array<{ + model_id: string; + provider_id: string; + model_name: string; + provider: LlmApiFormat; + }> = []; + + for (const modelName of uniqueModelNames) { + if (existingModelNames.has(modelName)) continue; + + const base = `${providerSlug}:${modelName}`; + let candidate = base; + let suffix = 2; + while (usedModelIds.has(candidate)) { + candidate = `${base}-${suffix}`; + suffix++; + } + usedModelIds.add(candidate); + + rowsToInsert.push({ + model_id: candidate, + provider_id: input.providerId, + model_name: modelName, + provider: input.apiFormat, + }); + } + + if (rowsToInsert.length === 0) return []; + + const modelIds = rowsToInsert.map(row => row.model_id); + const providerIds = rowsToInsert.map(row => row.provider_id); + const modelNames = rowsToInsert.map(row => row.model_name); + const providers = rowsToInsert.map(row => row.provider); + + const rows = await queryRows( + env, + ` + WITH incoming AS ( + SELECT * + FROM unnest( + $1::text[], + $2::uuid[], + $3::text[], + $4::text[] + ) AS item(model_id, provider_id, model_name, provider) + ), + inserted AS ( + INSERT INTO model_configs (model_id, provider_id, model_name, provider, updated_at) + SELECT model_id, provider_id, model_name, provider, now() + FROM incoming + ON CONFLICT (model_id) DO NOTHING + RETURNING model_id, provider_id, model_name, updated_at + ) + SELECT + i.model_id, + i.provider_id, + p.name AS provider_name, + p.api_format, + i.model_name, + i.updated_at + FROM inserted i + JOIN llm_providers p ON p.id = i.provider_id + ORDER BY i.model_id ASC + `, + [modelIds, providerIds, modelNames, providers], + ); + + return rows.map(mapModelConfig); +} + +export async function deleteModelConfig(env: DbEnv, modelId: string) { + const rows = await queryRows<{ model_id: string }>( + env, + `DELETE FROM model_configs WHERE model_id = $1 RETURNING model_id`, + [modelId], + ); + return rows.length > 0; +} diff --git a/packages/db/src/repo-configs.ts b/packages/db/src/repo-configs.ts index a6ce3291..b8afc175 100644 --- a/packages/db/src/repo-configs.ts +++ b/packages/db/src/repo-configs.ts @@ -1,212 +1,212 @@ import type { DbEnv } from './env'; - -import { parseJsonColumn, queryRows } from './client'; -import { defaultRepoConfig, normalizeRepoConfig, repoConfigRecordSchema, repoConfigSchema, type RepoConfig } from '@codraoss/schema'; -import { getOrCreateRepository } from './repositories'; - -type RepoConfigRow = { - installation_id: string; - owner: string; - repo: string; - parsed_json: RepoConfig | string | null; - updated_at: string; - main_model: string | null; - fallback_models: string[] | string | null; - size_overrides: any | string | null; - enabled: boolean; - last_job_created_at: string | null; - last_job_verdict: 'approve' | 'comment' | null; -}; - -function mapRepo(row: RepoConfigRow) { - const parsedJson = normalizeRepoConfig(repoConfigSchema.parse(parseJsonColumn(row.parsed_json, defaultRepoConfig))); - return repoConfigRecordSchema.parse({ - installationId: row.installation_id, - owner: row.owner, - repo: row.repo, - parsedJson, - updatedAt: row.updated_at, - lastJobCreatedAt: row.last_job_created_at, - lastJobVerdict: row.last_job_verdict, - mainModel: row.main_model, - fallbackModels: parseJsonColumn(row.fallback_models, null), - sizeOverrides: parseJsonColumn(row.size_overrides, null), - enabled: row.enabled, - }); -} - -export async function upsertRepoConfig( - env: DbEnv, - input: { - installationId: string; - owner: string; - repo: string; - parsedJson: RepoConfig; - enabled?: boolean; - }, -) { - const repositoryId = await getOrCreateRepository(env, { - installationId: input.installationId, - owner: input.owner, - repo: input.repo, - }); - - const parsedJson = normalizeRepoConfig(input.parsedJson); - const model = parsedJson.model; - await queryRows( - env, - ` - INSERT INTO repo_configs (repository_id, parsed_json, updated_at, main_model, fallback_models, size_overrides, enabled) - VALUES ($1, $2::text::jsonb, now(), $3, $4::text::jsonb, $5::text::jsonb, COALESCE($6, TRUE)) - ON CONFLICT (repository_id) - DO UPDATE - SET parsed_json = EXCLUDED.parsed_json, - updated_at = EXCLUDED.updated_at, - main_model = EXCLUDED.main_model, - fallback_models = EXCLUDED.fallback_models, - size_overrides = EXCLUDED.size_overrides, - enabled = COALESCE($6, repo_configs.enabled) - `, - [ - repositoryId, - JSON.stringify(parsedJson), - model?.main ?? null, - model?.fallbacks ? JSON.stringify(model.fallbacks) : null, - model?.size_overrides ? JSON.stringify(model.size_overrides) : null, - input.enabled ?? null - ], - ); -} - -// Creates record if missing; preserves model overrides. -export async function syncRepoConfig( - env: DbEnv, - input: { - installationId: string; - owner: string; - repo: string; - }, -) { - const repositoryId = await getOrCreateRepository(env, { - installationId: input.installationId, - owner: input.owner, - repo: input.repo, - }); - - await queryRows( - env, - ` - INSERT INTO repo_configs (repository_id, parsed_json, updated_at, main_model, fallback_models, size_overrides, enabled) - VALUES ($1, $2::text::jsonb, now(), NULL, NULL, NULL, TRUE) - ON CONFLICT (repository_id) DO NOTHING - `, - [repositoryId, JSON.stringify(defaultRepoConfig)], - ); -} - -export async function deleteStaleRepoConfigs( - env: DbEnv, - installationId: string, - activeRepoFullNames: string[] -) { - if (activeRepoFullNames.length === 0) { - await queryRows( - env, - ` - DELETE FROM repo_configs - WHERE repository_id IN ( - SELECT id FROM repositories WHERE installation_id = $1 - ) - `, - [installationId] - ); - return; - } - - await queryRows( - env, - ` - DELETE FROM repo_configs - WHERE repository_id IN ( - SELECT id FROM repositories - WHERE installation_id = $1 - AND owner || '/' || repo != ALL($2::text[]) - ) - `, - [installationId, activeRepoFullNames] - ); -} - -export async function updateRepoConfigEnabled( - env: DbEnv, - input: { - owner: string; - repo: string; - enabled: boolean; - }, -) { - await queryRows( - env, - ` - UPDATE repo_configs rc - SET enabled = $3, - updated_at = now() - FROM repositories r - WHERE rc.repository_id = r.id - AND r.owner = $1 - AND r.repo = $2 - `, - [input.owner, input.repo, input.enabled], - ); -} - -// Shared by the list and single-record queries, which differ only in their WHERE/ORDER BY. -const REPO_CONFIG_SELECT = ` - SELECT - r.installation_id, - r.owner, - r.repo, - rc.parsed_json, - rc.updated_at, - rc.main_model, - rc.fallback_models, - rc.size_overrides, - rc.enabled, - lj.created_at AS last_job_created_at, - lj.verdict AS last_job_verdict - FROM repo_configs rc - JOIN repositories r ON rc.repository_id = r.id - LEFT JOIN LATERAL ( - SELECT created_at, verdict - FROM jobs - WHERE repository_id = r.id - ORDER BY created_at DESC - LIMIT 1 - ) lj ON true -`; - -export async function listRepoConfigs(env: DbEnv) { - const rows = await queryRows( - env, - ` - ${REPO_CONFIG_SELECT} - ORDER BY r.owner ASC, r.repo ASC - `, - ); - - return rows.map(mapRepo); -} - -export async function getRepoConfigRecord(env: DbEnv, owner: string, repo: string) { - const [row] = await queryRows( - env, - ` - ${REPO_CONFIG_SELECT} - WHERE r.owner = $1 AND r.repo = $2 - LIMIT 1 - `, - [owner, repo], - ); - - return row ? mapRepo(row) : null; -} + +import { parseJsonColumn, queryRows } from './client'; +import { defaultRepoConfig, normalizeRepoConfig, repoConfigRecordSchema, repoConfigSchema, type RepoConfig } from '@codraoss/schema'; +import { getOrCreateRepository } from './repositories'; + +type RepoConfigRow = { + installation_id: string; + owner: string; + repo: string; + parsed_json: RepoConfig | string | null; + updated_at: string; + main_model: string | null; + fallback_models: string[] | string | null; + size_overrides: any | string | null; + enabled: boolean; + last_job_created_at: string | null; + last_job_verdict: 'approve' | 'comment' | null; +}; + +function mapRepo(row: RepoConfigRow) { + const parsedJson = normalizeRepoConfig(repoConfigSchema.parse(parseJsonColumn(row.parsed_json, defaultRepoConfig))); + return repoConfigRecordSchema.parse({ + installationId: row.installation_id, + owner: row.owner, + repo: row.repo, + parsedJson, + updatedAt: row.updated_at, + lastJobCreatedAt: row.last_job_created_at, + lastJobVerdict: row.last_job_verdict, + mainModel: row.main_model, + fallbackModels: parseJsonColumn(row.fallback_models, null), + sizeOverrides: parseJsonColumn(row.size_overrides, null), + enabled: row.enabled, + }); +} + +export async function upsertRepoConfig( + env: DbEnv, + input: { + installationId: string; + owner: string; + repo: string; + parsedJson: RepoConfig; + enabled?: boolean; + }, +) { + const repositoryId = await getOrCreateRepository(env, { + installationId: input.installationId, + owner: input.owner, + repo: input.repo, + }); + + const parsedJson = normalizeRepoConfig(input.parsedJson); + const model = parsedJson.model; + await queryRows( + env, + ` + INSERT INTO repo_configs (repository_id, parsed_json, updated_at, main_model, fallback_models, size_overrides, enabled) + VALUES ($1, $2::text::jsonb, now(), $3, $4::text::jsonb, $5::text::jsonb, COALESCE($6, TRUE)) + ON CONFLICT (repository_id) + DO UPDATE + SET parsed_json = EXCLUDED.parsed_json, + updated_at = EXCLUDED.updated_at, + main_model = EXCLUDED.main_model, + fallback_models = EXCLUDED.fallback_models, + size_overrides = EXCLUDED.size_overrides, + enabled = COALESCE($6, repo_configs.enabled) + `, + [ + repositoryId, + JSON.stringify(parsedJson), + model?.main ?? null, + model?.fallbacks ? JSON.stringify(model.fallbacks) : null, + model?.size_overrides ? JSON.stringify(model.size_overrides) : null, + input.enabled ?? null + ], + ); +} + +// Creates record if missing; preserves model overrides. +export async function syncRepoConfig( + env: DbEnv, + input: { + installationId: string; + owner: string; + repo: string; + }, +) { + const repositoryId = await getOrCreateRepository(env, { + installationId: input.installationId, + owner: input.owner, + repo: input.repo, + }); + + await queryRows( + env, + ` + INSERT INTO repo_configs (repository_id, parsed_json, updated_at, main_model, fallback_models, size_overrides, enabled) + VALUES ($1, $2::text::jsonb, now(), NULL, NULL, NULL, TRUE) + ON CONFLICT (repository_id) DO NOTHING + `, + [repositoryId, JSON.stringify(defaultRepoConfig)], + ); +} + +export async function deleteStaleRepoConfigs( + env: DbEnv, + installationId: string, + activeRepoFullNames: string[] +) { + if (activeRepoFullNames.length === 0) { + await queryRows( + env, + ` + DELETE FROM repo_configs + WHERE repository_id IN ( + SELECT id FROM repositories WHERE installation_id = $1 + ) + `, + [installationId] + ); + return; + } + + await queryRows( + env, + ` + DELETE FROM repo_configs + WHERE repository_id IN ( + SELECT id FROM repositories + WHERE installation_id = $1 + AND owner || '/' || repo != ALL($2::text[]) + ) + `, + [installationId, activeRepoFullNames] + ); +} + +export async function updateRepoConfigEnabled( + env: DbEnv, + input: { + owner: string; + repo: string; + enabled: boolean; + }, +) { + await queryRows( + env, + ` + UPDATE repo_configs rc + SET enabled = $3, + updated_at = now() + FROM repositories r + WHERE rc.repository_id = r.id + AND r.owner = $1 + AND r.repo = $2 + `, + [input.owner, input.repo, input.enabled], + ); +} + +// Shared by the list and single-record queries, which differ only in their WHERE/ORDER BY. +const REPO_CONFIG_SELECT = ` + SELECT + r.installation_id, + r.owner, + r.repo, + rc.parsed_json, + rc.updated_at, + rc.main_model, + rc.fallback_models, + rc.size_overrides, + rc.enabled, + lj.created_at AS last_job_created_at, + lj.verdict AS last_job_verdict + FROM repo_configs rc + JOIN repositories r ON rc.repository_id = r.id + LEFT JOIN LATERAL ( + SELECT created_at, verdict + FROM jobs + WHERE repository_id = r.id + ORDER BY created_at DESC + LIMIT 1 + ) lj ON true +`; + +export async function listRepoConfigs(env: DbEnv) { + const rows = await queryRows( + env, + ` + ${REPO_CONFIG_SELECT} + ORDER BY r.owner ASC, r.repo ASC + `, + ); + + return rows.map(mapRepo); +} + +export async function getRepoConfigRecord(env: DbEnv, owner: string, repo: string) { + const [row] = await queryRows( + env, + ` + ${REPO_CONFIG_SELECT} + WHERE r.owner = $1 AND r.repo = $2 + LIMIT 1 + `, + [owner, repo], + ); + + return row ? mapRepo(row) : null; +} diff --git a/packages/db/src/review-comment-sql.ts b/packages/db/src/review-comment-sql.ts index f23d4a7d..8dd63927 100644 --- a/packages/db/src/review-comment-sql.ts +++ b/packages/db/src/review-comment-sql.ts @@ -1,87 +1,87 @@ -import type { ParsedReviewComment } from '@codraoss/schema'; - -// Shared review_comments field list. Update bulkInheritFileReviews if changed. - -// Column order for INSERT INTO review_comments (...). Must match REVIEW_COMMENT_INSERT_CASTS. -export const REVIEW_COMMENT_INSERT_COLUMNS = [ - 'path', 'line', 'position', 'severity', 'category', 'title', 'body', 'code_suggestion', - 'confidence_score', 'evidence', 'fingerprint', 'anchor_hash', 'claim_type', 'context_snippet', - 'disposition', 'fingerprint_v2', 'source', 'rule_id', 'reviewer_model', -] as const; - -// Generated rather than written out so the cast count can never fall out of step with the column list. -export const REVIEW_COMMENT_INSERT_CASTS = REVIEW_COMMENT_INSERT_COLUMNS - .map((column, index) => { - const placeholder = `$${index + 2}`; - if (column === 'line' || column === 'position') return `${placeholder}::int[]`; - if (column === 'confidence_score') return `${placeholder}::real[]`; - return `${placeholder}::text[]`; - }) - .join(', '); - -// The bind values for those casts, in column order. Pass after the file_review_id. -export function reviewCommentInsertValues(comments: ParsedReviewComment[]) { - return [ - comments.map((c) => c.path), - comments.map((c) => c.line ?? null), - comments.map((c) => c.position ?? null), - comments.map((c) => c.severity), - comments.map((c) => c.category), - comments.map((c) => c.title), - comments.map((c) => c.body), - comments.map((c) => c.codeSuggestion ?? null), - comments.map((c) => c.confidenceScore ?? null), - comments.map((c) => c.evidence ?? null), - comments.map((c) => c.fingerprint ?? null), - comments.map((c) => c.anchorHash ?? null), - comments.map((c) => c.claimType ?? null), - comments.map((c) => c.contextSnippet ?? null), - comments.map((c) => c.disposition ?? null), - comments.map((c) => c.fingerprintV2 ?? null), - comments.map((c) => c.source ?? 'llm'), - comments.map((c) => c.ruleId ?? null), - comments.map((c) => c.reviewerModel ?? null), - ]; -} - -// The JSON_BUILD_OBJECT body used to project comments back out, keyed to the `rc` alias. -// `extraFields` is appended verbatim for projections that need more -- the job-detail query adds a -// correlated `humanLabel` lookup, which the file-review query has no use for. -export function reviewCommentJsonObject(extraFields = '') { - const fields = [ - `'path', rc.path`, - `'line', rc.line`, - `'position', rc.position`, - `'severity', rc.severity`, - `'category', rc.category`, - `'title', rc.title`, - `'body', rc.body`, - `'codeSuggestion', rc.code_suggestion`, - `'confidenceScore', rc.confidence_score`, - `'evidence', rc.evidence`, - `'fingerprint', rc.fingerprint`, - `'fingerprintV2', rc.fingerprint_v2`, - `'anchorHash', rc.anchor_hash`, - `'posted', rc.posted`, - `'claimType', rc.claim_type`, - `'contextSnippet', rc.context_snippet`, - `'disposition', rc.disposition`, - `'verifyReason', rc.verify_reason`, - `'source', rc.source`, - `'ruleId', rc.rule_id`, - `'reviewerModel', rc.reviewer_model`, - ].join(',\n '); - - return `JSON_BUILD_OBJECT(\n ${fields}${extraFields ? `,\n ${extraFields}` : ''}\n )`; -} - -// The full aggregate, including the empty-array fallback both call sites need. -export function reviewCommentsAggregate(extraFields = '') { - return `COALESCE( - ( - SELECT JSON_AGG(${reviewCommentJsonObject(extraFields)} ORDER BY rc.id ASC) - FROM review_comments rc WHERE rc.file_review_id = fr.id - ), - '[]'::json - )`; -} +import type { ParsedReviewComment } from '@codraoss/schema'; + +// Shared review_comments field list. Update bulkInheritFileReviews if changed. + +// Column order for INSERT INTO review_comments (...). Must match REVIEW_COMMENT_INSERT_CASTS. +export const REVIEW_COMMENT_INSERT_COLUMNS = [ + 'path', 'line', 'position', 'severity', 'category', 'title', 'body', 'code_suggestion', + 'confidence_score', 'evidence', 'fingerprint', 'anchor_hash', 'claim_type', 'context_snippet', + 'disposition', 'fingerprint_v2', 'source', 'rule_id', 'reviewer_model', +] as const; + +// Generated rather than written out so the cast count can never fall out of step with the column list. +export const REVIEW_COMMENT_INSERT_CASTS = REVIEW_COMMENT_INSERT_COLUMNS + .map((column, index) => { + const placeholder = `$${index + 2}`; + if (column === 'line' || column === 'position') return `${placeholder}::int[]`; + if (column === 'confidence_score') return `${placeholder}::real[]`; + return `${placeholder}::text[]`; + }) + .join(', '); + +// The bind values for those casts, in column order. Pass after the file_review_id. +export function reviewCommentInsertValues(comments: ParsedReviewComment[]) { + return [ + comments.map((c) => c.path), + comments.map((c) => c.line ?? null), + comments.map((c) => c.position ?? null), + comments.map((c) => c.severity), + comments.map((c) => c.category), + comments.map((c) => c.title), + comments.map((c) => c.body), + comments.map((c) => c.codeSuggestion ?? null), + comments.map((c) => c.confidenceScore ?? null), + comments.map((c) => c.evidence ?? null), + comments.map((c) => c.fingerprint ?? null), + comments.map((c) => c.anchorHash ?? null), + comments.map((c) => c.claimType ?? null), + comments.map((c) => c.contextSnippet ?? null), + comments.map((c) => c.disposition ?? null), + comments.map((c) => c.fingerprintV2 ?? null), + comments.map((c) => c.source ?? 'llm'), + comments.map((c) => c.ruleId ?? null), + comments.map((c) => c.reviewerModel ?? null), + ]; +} + +// The JSON_BUILD_OBJECT body used to project comments back out, keyed to the `rc` alias. +// `extraFields` is appended verbatim for projections that need more -- the job-detail query adds a +// correlated `humanLabel` lookup, which the file-review query has no use for. +export function reviewCommentJsonObject(extraFields = '') { + const fields = [ + `'path', rc.path`, + `'line', rc.line`, + `'position', rc.position`, + `'severity', rc.severity`, + `'category', rc.category`, + `'title', rc.title`, + `'body', rc.body`, + `'codeSuggestion', rc.code_suggestion`, + `'confidenceScore', rc.confidence_score`, + `'evidence', rc.evidence`, + `'fingerprint', rc.fingerprint`, + `'fingerprintV2', rc.fingerprint_v2`, + `'anchorHash', rc.anchor_hash`, + `'posted', rc.posted`, + `'claimType', rc.claim_type`, + `'contextSnippet', rc.context_snippet`, + `'disposition', rc.disposition`, + `'verifyReason', rc.verify_reason`, + `'source', rc.source`, + `'ruleId', rc.rule_id`, + `'reviewerModel', rc.reviewer_model`, + ].join(',\n '); + + return `JSON_BUILD_OBJECT(\n ${fields}${extraFields ? `,\n ${extraFields}` : ''}\n )`; +} + +// The full aggregate, including the empty-array fallback both call sites need. +export function reviewCommentsAggregate(extraFields = '') { + return `COALESCE( + ( + SELECT JSON_AGG(${reviewCommentJsonObject(extraFields)} ORDER BY rc.id ASC) + FROM review_comments rc WHERE rc.file_review_id = fr.id + ), + '[]'::json + )`; +} diff --git a/packages/models/src/internal/model-review-file.ts b/packages/models/src/internal/model-review-file.ts index 8f73b830..78145ec9 100644 --- a/packages/models/src/internal/model-review-file.ts +++ b/packages/models/src/internal/model-review-file.ts @@ -1,245 +1,245 @@ -import { - buildBatchReviewPrompts, - buildBatchReviewResponseSchema, - buildFileReviewPrompts, - buildReviewResponseSchema, - type RejectedExemplar, -} from '@codraoss/core/prompts/file-review'; -import { isNonAnswerReview, parseBatchReviewResponse, parseFileReviewResponse, type BatchReviewResult } from '@codraoss/core/model-output'; -import { UnparseableModelResponseError } from '../types'; -import { chunkFileDiff, type FileDiff } from '@codraoss/core/diff'; -import { adaptiveModelTimeoutMs, reviewOutputBudgetTokens } from '../limits'; -import { reviewBreadth } from '@codraoss/core/prompts/file-review'; -import { mergeCounts } from './model-support'; -import { type ModelReviewContext, runModelChain } from './model-review-chain'; -import { logger } from '@codraoss/core/logger'; -import type { RepoConfig } from '@codraoss/schema'; -import type { ModelResponse } from '../types'; - -// vi.mock targets services/model barrel; import model from there, not here. - -export const COMPACT_REVIEW_PROMPT_LINE_CAP = 400; -// Reserve so tail chunks only run if budget still fits another whole file. -const EXTRA_CHUNK_BUDGET_RESERVE = 8; -export type { ModelReviewContext }; - -export async function reviewFile(ctx: ModelReviewContext, params: { - file: any; - fileContext?: string | null; - prTitle: string | null; - prDescription: string | null; - changelogExcerpt?: string | null; - config: RepoConfig; - totalLineCount: number; - compactPrompt?: boolean; - rejectedExemplars?: readonly RejectedExemplar[]; -}) { - const configuredLineCap = params.config.review.max_diff_lines_per_file; - const modelLineCap = params.compactPrompt - ? Math.min(configuredLineCap, COMPACT_REVIEW_PROMPT_LINE_CAP) - : configuredLineCap; - - let chunks = chunkFileDiff(params.file, modelLineCap); - const totalChunkCount = chunks.length; - - const BASE_CHUNKS = 4; - const MAX_CHUNKS = 8; - if (chunks.length > MAX_CHUNKS) { - chunks = chunks.slice(0, MAX_CHUNKS); - } - - if (chunks.length === 1) { - return reviewFileChunk(ctx, { ...params, file: chunks[0] }); - } - - const results: Array, reviewedLineCount: number, wasPromptTruncated: boolean, userPrompt: string }> = []; - const { path: filePath } = params.file; - - for (const [chunkIndex, chunk] of chunks.entries()) { - // isNearLimit guards the ~50-subrequest cap. - if (results.length > 0 && ctx.tracker?.isNearLimit()) { - logger.warn(`Stopping chunk processing for ${filePath} early due to subrequest budget limits.`); - break; - } - - // Needs spare budget, not just remaining: isNearLimit only trips once already starved. - if (chunkIndex >= BASE_CHUNKS) { - const remaining = ctx.tracker?.remainingSafeBudget() ?? Number.POSITIVE_INFINITY; - if (remaining < EXTRA_CHUNK_BUDGET_RESERVE) { - logger.info(`Skipping the opportunistic chunk tail for ${filePath}; budget is committed elsewhere.`, { - chunkIndex, - totalChunks: chunks.length, - remainingSafeBudget: remaining, - }); - break; - } - } - - - try { - const res = await reviewFileChunk(ctx, { ...params, file: chunk }); - results.push(res as any); - } catch (error) { - if (results.length === 0) { - throw error; - } - logger.warn(`Chunk review failed for ${filePath}, returning partial results to avoid stalling the job.`, { error: error instanceof Error ? error.message : String(error) }); - break; - } - } - - const combinedFindings = results.flatMap(r => r.parsed.comments); - // Most serious verdict, not last: a clean final chunk would mask earlier findings. - const primaryResult = results.find(r => r.parsed.verdict === 'comment') ?? results[results.length - 1]; - - return { - ...primaryResult, - inputTokens: results.reduce((sum, r) => sum + r.inputTokens, 0), - outputTokens: results.reduce((sum, r) => sum + r.outputTokens, 0), - parsed: { - ...primaryResult.parsed, - comments: combinedFindings, - evidenceStats: results.reduce((acc, r) => ({ - total: acc.total + (r.parsed.evidenceStats?.total ?? 0), - matched: acc.matched + (r.parsed.evidenceStats?.matched ?? 0), - unmatched: acc.unmatched + (r.parsed.evidenceStats?.unmatched ?? 0), - weak: acc.weak + (r.parsed.evidenceStats?.weak ?? 0), - absent: acc.absent + (r.parsed.evidenceStats?.absent ?? 0), - contextOnly: acc.contextOnly + (r.parsed.evidenceStats?.contextOnly ?? 0), - }), { total: 0, matched: 0, unmatched: 0, weak: 0, absent: 0, contextOnly: 0 }), - claimTypeCounts: mergeCounts(results.map((r) => r.parsed.claimTypeCounts)), - deniedClaimCounts: mergeCounts(results.map((r) => r.parsed.deniedClaimCounts)), - absenceCheckStats: results.reduce((acc, r) => ({ - absenceShaped: acc.absenceShaped + (r.parsed.absenceCheckStats?.absenceShaped ?? 0), - identifierExtracted: acc.identifierExtracted + (r.parsed.absenceCheckStats?.identifierExtracted ?? 0), - refuted: acc.refuted + (r.parsed.absenceCheckStats?.refuted ?? 0), - }), { absenceShaped: 0, identifierExtracted: 0, refuted: 0 }), - }, - reviewedLineCount: results.reduce((sum, r) => sum + r.reviewedLineCount, 0), - wasPromptTruncated: chunks.length < totalChunkCount || results.length < chunks.length, - degraded: results.find((r) => r.degraded)?.degraded, - }; -} - - -async function reviewFileChunk(ctx: ModelReviewContext, params: { - file: any; - fileContext?: string | null; - prTitle: string | null; - prDescription: string | null; - changelogExcerpt?: string | null; - config: RepoConfig; - totalLineCount: number; - compactPrompt?: boolean; - rejectedExemplars?: readonly RejectedExemplar[]; -}) { - const { systemPrompt, userPrompt } = buildFileReviewPrompts({ - ...params, - file: params.file, - config: params.config.review, - rejectedExemplars: params.rejectedExemplars, - }); - - const outputBudgetTokens = reviewOutputBudgetTokens({ - findingCap: reviewBreadth(params.config.review), - fileCount: 1, - }); - - const response = await runModelChain(ctx, { - systemPrompt, - userPrompt, - responseSchema: buildReviewResponseSchema(reviewBreadth(params.config.review)), - timeoutMs: adaptiveModelTimeoutMs(params.file.lineCount, outputBudgetTokens), - outputBudgetTokens, - // Partial output reads as "no findings left", not truncation; treat as untrusted. - truncationIntolerant: true, - label: params.file.path, - totalLineCount: params.totalLineCount, - config: params.config, - parse: (rawText, { isLastModel }) => { - const parsed = parseFileReviewResponse(rawText, params.file, { - deniedClaimTypes: params.config.review.deny_claim_types, - fileContent: params.fileContext, - }); - - // A one-sentence reply to a substantive diff means the model declined; escalate except on the last model, where failing beats an unearned clean. - if (!isLastModel && isNonAnswerReview({ - rawText, - file: params.file, - findingCount: parsed.comments.length, - })) { - logger.warn('Model returned a non-answer for a substantive diff; escalating to the next model', { - path: params.file.path, - diffLineCount: params.file.lineCount, - responseChars: rawText.trim().length, - }); - throw new UnparseableModelResponseError( - params.config.model?.main ?? 'unconfigured', - `no findings and only ${rawText.trim().length} characters of response for a ${params.file.lineCount}-line diff`, - ); - } - - return parsed; - }, - }); - - return { - ...response, - reviewedLineCount: params.file.lineCount, - wasPromptTruncated: params.file.isTruncated === true, - }; -} - -export type BatchReviewOutcome = ModelResponse & { - batch: BatchReviewResult; - userPrompt: string; -}; - -// Caller must not record batch.missing as reviewed when fanning out to file rows. -export async function reviewFiles(ctx: ModelReviewContext, params: { - files: readonly FileDiff[]; - prTitle: string | null; - prDescription: string | null; - changelogExcerpt?: string | null; - config: RepoConfig; - totalLineCount: number; - rejectedExemplars?: readonly RejectedExemplar[]; -}): Promise { - const { systemPrompt, userPrompt } = buildBatchReviewPrompts({ - files: params.files, - prTitle: params.prTitle, - prDescription: params.prDescription, - changelogExcerpt: params.changelogExcerpt, - config: params.config.review, - rejectedExemplars: params.rejectedExemplars, - }); - - // Bin's total lines; a small-file timeout on a 400-line bin kills the whole call. - const binLineCount = params.files.reduce((sum, file) => sum + file.lineCount, 0); - - // Whole-bin response shares one maxOutputTokens (overrun leaves tail files looking falsely clean); same figure sizes the timeout since a packed bin is the slowest call. - const outputBudgetTokens = reviewOutputBudgetTokens({ - findingCap: reviewBreadth(params.config.review), - fileCount: params.files.length, - }); - - const response = await runModelChain(ctx, { - systemPrompt, - userPrompt, - responseSchema: buildBatchReviewResponseSchema(reviewBreadth(params.config.review), params.files.length), - timeoutMs: adaptiveModelTimeoutMs(binLineCount, outputBudgetTokens), - outputBudgetTokens, - truncationIntolerant: true, - label: `${params.files.length} files (${params.files[0]?.path ?? 'unknown'} …)`, - progressLabels: params.files.map((file) => file.path), - totalLineCount: params.totalLineCount, - config: params.config, - parse: (rawText) => parseBatchReviewResponse(rawText, params.files, { - deniedClaimTypes: params.config.review.deny_claim_types, - maxCommentsPerFile: reviewBreadth(params.config.review), - }), - }); - - return { ...response, batch: response.parsed }; -} - +import { + buildBatchReviewPrompts, + buildBatchReviewResponseSchema, + buildFileReviewPrompts, + buildReviewResponseSchema, + type RejectedExemplar, +} from '@codraoss/core/prompts/file-review'; +import { isNonAnswerReview, parseBatchReviewResponse, parseFileReviewResponse, type BatchReviewResult } from '@codraoss/core/model-output'; +import { UnparseableModelResponseError } from '../types'; +import { chunkFileDiff, type FileDiff } from '@codraoss/core/diff'; +import { adaptiveModelTimeoutMs, reviewOutputBudgetTokens } from '../limits'; +import { reviewBreadth } from '@codraoss/core/prompts/file-review'; +import { mergeCounts } from './model-support'; +import { type ModelReviewContext, runModelChain } from './model-review-chain'; +import { logger } from '@codraoss/core/logger'; +import type { RepoConfig } from '@codraoss/schema'; +import type { ModelResponse } from '../types'; + +// vi.mock targets services/model barrel; import model from there, not here. + +export const COMPACT_REVIEW_PROMPT_LINE_CAP = 400; +// Reserve so tail chunks only run if budget still fits another whole file. +const EXTRA_CHUNK_BUDGET_RESERVE = 8; +export type { ModelReviewContext }; + +export async function reviewFile(ctx: ModelReviewContext, params: { + file: any; + fileContext?: string | null; + prTitle: string | null; + prDescription: string | null; + changelogExcerpt?: string | null; + config: RepoConfig; + totalLineCount: number; + compactPrompt?: boolean; + rejectedExemplars?: readonly RejectedExemplar[]; +}) { + const configuredLineCap = params.config.review.max_diff_lines_per_file; + const modelLineCap = params.compactPrompt + ? Math.min(configuredLineCap, COMPACT_REVIEW_PROMPT_LINE_CAP) + : configuredLineCap; + + let chunks = chunkFileDiff(params.file, modelLineCap); + const totalChunkCount = chunks.length; + + const BASE_CHUNKS = 4; + const MAX_CHUNKS = 8; + if (chunks.length > MAX_CHUNKS) { + chunks = chunks.slice(0, MAX_CHUNKS); + } + + if (chunks.length === 1) { + return reviewFileChunk(ctx, { ...params, file: chunks[0] }); + } + + const results: Array, reviewedLineCount: number, wasPromptTruncated: boolean, userPrompt: string }> = []; + const { path: filePath } = params.file; + + for (const [chunkIndex, chunk] of chunks.entries()) { + // isNearLimit guards the ~50-subrequest cap. + if (results.length > 0 && ctx.tracker?.isNearLimit()) { + logger.warn(`Stopping chunk processing for ${filePath} early due to subrequest budget limits.`); + break; + } + + // Needs spare budget, not just remaining: isNearLimit only trips once already starved. + if (chunkIndex >= BASE_CHUNKS) { + const remaining = ctx.tracker?.remainingSafeBudget() ?? Number.POSITIVE_INFINITY; + if (remaining < EXTRA_CHUNK_BUDGET_RESERVE) { + logger.info(`Skipping the opportunistic chunk tail for ${filePath}; budget is committed elsewhere.`, { + chunkIndex, + totalChunks: chunks.length, + remainingSafeBudget: remaining, + }); + break; + } + } + + + try { + const res = await reviewFileChunk(ctx, { ...params, file: chunk }); + results.push(res as any); + } catch (error) { + if (results.length === 0) { + throw error; + } + logger.warn(`Chunk review failed for ${filePath}, returning partial results to avoid stalling the job.`, { error: error instanceof Error ? error.message : String(error) }); + break; + } + } + + const combinedFindings = results.flatMap(r => r.parsed.comments); + // Most serious verdict, not last: a clean final chunk would mask earlier findings. + const primaryResult = results.find(r => r.parsed.verdict === 'comment') ?? results[results.length - 1]; + + return { + ...primaryResult, + inputTokens: results.reduce((sum, r) => sum + r.inputTokens, 0), + outputTokens: results.reduce((sum, r) => sum + r.outputTokens, 0), + parsed: { + ...primaryResult.parsed, + comments: combinedFindings, + evidenceStats: results.reduce((acc, r) => ({ + total: acc.total + (r.parsed.evidenceStats?.total ?? 0), + matched: acc.matched + (r.parsed.evidenceStats?.matched ?? 0), + unmatched: acc.unmatched + (r.parsed.evidenceStats?.unmatched ?? 0), + weak: acc.weak + (r.parsed.evidenceStats?.weak ?? 0), + absent: acc.absent + (r.parsed.evidenceStats?.absent ?? 0), + contextOnly: acc.contextOnly + (r.parsed.evidenceStats?.contextOnly ?? 0), + }), { total: 0, matched: 0, unmatched: 0, weak: 0, absent: 0, contextOnly: 0 }), + claimTypeCounts: mergeCounts(results.map((r) => r.parsed.claimTypeCounts)), + deniedClaimCounts: mergeCounts(results.map((r) => r.parsed.deniedClaimCounts)), + absenceCheckStats: results.reduce((acc, r) => ({ + absenceShaped: acc.absenceShaped + (r.parsed.absenceCheckStats?.absenceShaped ?? 0), + identifierExtracted: acc.identifierExtracted + (r.parsed.absenceCheckStats?.identifierExtracted ?? 0), + refuted: acc.refuted + (r.parsed.absenceCheckStats?.refuted ?? 0), + }), { absenceShaped: 0, identifierExtracted: 0, refuted: 0 }), + }, + reviewedLineCount: results.reduce((sum, r) => sum + r.reviewedLineCount, 0), + wasPromptTruncated: chunks.length < totalChunkCount || results.length < chunks.length, + degraded: results.find((r) => r.degraded)?.degraded, + }; +} + + +async function reviewFileChunk(ctx: ModelReviewContext, params: { + file: any; + fileContext?: string | null; + prTitle: string | null; + prDescription: string | null; + changelogExcerpt?: string | null; + config: RepoConfig; + totalLineCount: number; + compactPrompt?: boolean; + rejectedExemplars?: readonly RejectedExemplar[]; +}) { + const { systemPrompt, userPrompt } = buildFileReviewPrompts({ + ...params, + file: params.file, + config: params.config.review, + rejectedExemplars: params.rejectedExemplars, + }); + + const outputBudgetTokens = reviewOutputBudgetTokens({ + findingCap: reviewBreadth(params.config.review), + fileCount: 1, + }); + + const response = await runModelChain(ctx, { + systemPrompt, + userPrompt, + responseSchema: buildReviewResponseSchema(reviewBreadth(params.config.review)), + timeoutMs: adaptiveModelTimeoutMs(params.file.lineCount, outputBudgetTokens), + outputBudgetTokens, + // Partial output reads as "no findings left", not truncation; treat as untrusted. + truncationIntolerant: true, + label: params.file.path, + totalLineCount: params.totalLineCount, + config: params.config, + parse: (rawText, { isLastModel }) => { + const parsed = parseFileReviewResponse(rawText, params.file, { + deniedClaimTypes: params.config.review.deny_claim_types, + fileContent: params.fileContext, + }); + + // A one-sentence reply to a substantive diff means the model declined; escalate except on the last model, where failing beats an unearned clean. + if (!isLastModel && isNonAnswerReview({ + rawText, + file: params.file, + findingCount: parsed.comments.length, + })) { + logger.warn('Model returned a non-answer for a substantive diff; escalating to the next model', { + path: params.file.path, + diffLineCount: params.file.lineCount, + responseChars: rawText.trim().length, + }); + throw new UnparseableModelResponseError( + params.config.model?.main ?? 'unconfigured', + `no findings and only ${rawText.trim().length} characters of response for a ${params.file.lineCount}-line diff`, + ); + } + + return parsed; + }, + }); + + return { + ...response, + reviewedLineCount: params.file.lineCount, + wasPromptTruncated: params.file.isTruncated === true, + }; +} + +export type BatchReviewOutcome = ModelResponse & { + batch: BatchReviewResult; + userPrompt: string; +}; + +// Caller must not record batch.missing as reviewed when fanning out to file rows. +export async function reviewFiles(ctx: ModelReviewContext, params: { + files: readonly FileDiff[]; + prTitle: string | null; + prDescription: string | null; + changelogExcerpt?: string | null; + config: RepoConfig; + totalLineCount: number; + rejectedExemplars?: readonly RejectedExemplar[]; +}): Promise { + const { systemPrompt, userPrompt } = buildBatchReviewPrompts({ + files: params.files, + prTitle: params.prTitle, + prDescription: params.prDescription, + changelogExcerpt: params.changelogExcerpt, + config: params.config.review, + rejectedExemplars: params.rejectedExemplars, + }); + + // Bin's total lines; a small-file timeout on a 400-line bin kills the whole call. + const binLineCount = params.files.reduce((sum, file) => sum + file.lineCount, 0); + + // Whole-bin response shares one maxOutputTokens (overrun leaves tail files looking falsely clean); same figure sizes the timeout since a packed bin is the slowest call. + const outputBudgetTokens = reviewOutputBudgetTokens({ + findingCap: reviewBreadth(params.config.review), + fileCount: params.files.length, + }); + + const response = await runModelChain(ctx, { + systemPrompt, + userPrompt, + responseSchema: buildBatchReviewResponseSchema(reviewBreadth(params.config.review), params.files.length), + timeoutMs: adaptiveModelTimeoutMs(binLineCount, outputBudgetTokens), + outputBudgetTokens, + truncationIntolerant: true, + label: `${params.files.length} files (${params.files[0]?.path ?? 'unknown'} …)`, + progressLabels: params.files.map((file) => file.path), + totalLineCount: params.totalLineCount, + config: params.config, + parse: (rawText) => parseBatchReviewResponse(rawText, params.files, { + deniedClaimTypes: params.config.review.deny_claim_types, + maxCommentsPerFile: reviewBreadth(params.config.review), + }), + }); + + return { ...response, batch: response.parsed }; +} + diff --git a/packages/models/src/internal/model-support.ts b/packages/models/src/internal/model-support.ts index d767cbc9..bcb8443e 100644 --- a/packages/models/src/internal/model-support.ts +++ b/packages/models/src/internal/model-support.ts @@ -1,134 +1,134 @@ -import { normalizeModelId } from '@codraoss/schema'; -import { isTimeoutMessage, matchesAnyTransientSubstring } from '@codraoss/schema/transient-errors'; -import { UnparseableModelResponseError } from '../types'; - -// Hook for future legacy ID rewrites, applied before resolution. -const MODEL_ALIASES: Record = {}; - -export function mergeCounts(sources: Array | undefined>): Record { - const merged: Record = {}; - for (const source of sources) { - for (const [key, count] of Object.entries(source ?? {})) { - merged[key] = (merged[key] ?? 0) + count; - } - } - return merged; -} - -// 4 chars/token estimate; overestimating is safe. -export function estimatePromptTokens(systemPrompt: string, userPrompt: string): number { - return Math.ceil((systemPrompt.length + userPrompt.length) / 4); -} - -export const PROMPT_FIT_SAFETY_FACTOR = 0.8; - -// Floor to reject misparsed request quotas, not real token buckets. -export const MIN_PLAUSIBLE_TOKEN_BUCKET = 1_000; - -export function isPlausibleTokenBucket(limitTokens: number | undefined): boolean { - return typeof limitTokens === 'number' && limitTokens >= MIN_PLAUSIBLE_TOKEN_BUCKET; -} - -// Lives here (not with callers) to avoid vi.mock TypeError in specs. -export function nextChainIndexOf(error: unknown): number | null { - const value = (error as { nextChainIndex?: unknown } | null)?.nextChainIndex; - return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null; -} - -export function isSchemaDroppedError(error: unknown): boolean { - return (error as { schemaDropped?: unknown } | null)?.schemaDropped === true; -} - -export const MAX_METERED_QUEUE_DEPTH = 2; - -const QUOTA_VIOLATION_PATTERN = /metric:\s*(\S+?),\s*limit:\s*(\d[\d_,]*)/gi; - -// Excludes request-count quotas, which would otherwise disable the model. -const TOKEN_QUOTA_METRIC = /(?:input_token|output_token|token_count|_tokens)/i; - -export function parseRateLimitFromError(error: unknown): { limitTokens?: number; retryAfterMs?: number } { - const message = error instanceof Error ? error.message : String(error ?? ''); - - let limitTokens: number | undefined; - for (const [, metric, limit] of message.matchAll(QUOTA_VIOLATION_PATTERN)) { - if (!TOKEN_QUOTA_METRIC.test(metric)) continue; - const parsed = Number(limit.replace(/[_,]/g, '')); - if (!Number.isFinite(parsed) || !isPlausibleTokenBucket(parsed)) continue; - if (limitTokens === undefined || parsed < limitTokens) limitTokens = parsed; - } - - const retryMatch = /retry in ([\d.]+)\s*s/i.exec(message); - const retryAfterMs = retryMatch ? Number(retryMatch[1]) * 1000 : undefined; - - return { - limitTokens, - retryAfterMs: Number.isFinite(retryAfterMs) && retryAfterMs! > 0 ? retryAfterMs : undefined, - }; -} - -export class RetryableModelError extends Error { - readonly retryable = true; - - constructor(message: string, cause?: unknown) { - super(message); - this.name = 'RetryableModelError'; - if (cause !== undefined) { - Object.defineProperty(this, 'cause', { - value: cause, - writable: true, - configurable: true, - }); - } - } -} - -export function isRetryableModelError(error: unknown) { - return Boolean(error && typeof error === 'object' && 'retryable' in error && error.retryable === true); -} - -export function normalizeModel(model: string) { - return normalizeModelId(MODEL_ALIASES[model] ?? model); -} - -export function uniqueModels(models: string[]) { - return Array.from(new Set(models.map(normalizeModel))); -} - -export function isCloudflareAllocationError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message.includes('4006') || message.toLowerCase().includes('daily free allocation'); -} - -export function isGoogleRateLimitError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - const lower = message.toLowerCase(); - - if (lower.includes('timed out') || lower.includes('timeout')) { - return false; - } - - return lower.includes('429') || lower.includes('resource_exhausted') || lower.includes('quota exceeded'); -} - -export function isTransientModelFailure(error: unknown) { - if (isRetryableModelError(error)) return true; - // Unparseable output is deterministic, not transient. - if (error instanceof UnparseableModelResponseError) return false; - if (isCloudflareAllocationError(error)) return false; - const message = error instanceof Error ? error.message : String(error); - const lower = message.toLowerCase(); - - if (isTimeoutMessage(lower)) { - return false; - } - - return ( - isGoogleRateLimitError(error) || - matchesAnyTransientSubstring(lower) || - lower.includes('fetch failed') || - lower.includes('network') || - lower.includes('temporar') || - /\b50[0-9]\b/.test(lower) || - lower.includes('internal error') - ); -} +import { normalizeModelId } from '@codraoss/schema'; +import { isTimeoutMessage, matchesAnyTransientSubstring } from '@codraoss/schema/transient-errors'; +import { UnparseableModelResponseError } from '../types'; + +// Hook for future legacy ID rewrites, applied before resolution. +const MODEL_ALIASES: Record = {}; + +export function mergeCounts(sources: Array | undefined>): Record { + const merged: Record = {}; + for (const source of sources) { + for (const [key, count] of Object.entries(source ?? {})) { + merged[key] = (merged[key] ?? 0) + count; + } + } + return merged; +} + +// 4 chars/token estimate; overestimating is safe. +export function estimatePromptTokens(systemPrompt: string, userPrompt: string): number { + return Math.ceil((systemPrompt.length + userPrompt.length) / 4); +} + +export const PROMPT_FIT_SAFETY_FACTOR = 0.8; + +// Floor to reject misparsed request quotas, not real token buckets. +export const MIN_PLAUSIBLE_TOKEN_BUCKET = 1_000; + +export function isPlausibleTokenBucket(limitTokens: number | undefined): boolean { + return typeof limitTokens === 'number' && limitTokens >= MIN_PLAUSIBLE_TOKEN_BUCKET; +} + +// Lives here (not with callers) to avoid vi.mock TypeError in specs. +export function nextChainIndexOf(error: unknown): number | null { + const value = (error as { nextChainIndex?: unknown } | null)?.nextChainIndex; + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null; +} + +export function isSchemaDroppedError(error: unknown): boolean { + return (error as { schemaDropped?: unknown } | null)?.schemaDropped === true; +} + +export const MAX_METERED_QUEUE_DEPTH = 2; + +const QUOTA_VIOLATION_PATTERN = /metric:\s*(\S+?),\s*limit:\s*(\d[\d_,]*)/gi; + +// Excludes request-count quotas, which would otherwise disable the model. +const TOKEN_QUOTA_METRIC = /(?:input_token|output_token|token_count|_tokens)/i; + +export function parseRateLimitFromError(error: unknown): { limitTokens?: number; retryAfterMs?: number } { + const message = error instanceof Error ? error.message : String(error ?? ''); + + let limitTokens: number | undefined; + for (const [, metric, limit] of message.matchAll(QUOTA_VIOLATION_PATTERN)) { + if (!TOKEN_QUOTA_METRIC.test(metric)) continue; + const parsed = Number(limit.replace(/[_,]/g, '')); + if (!Number.isFinite(parsed) || !isPlausibleTokenBucket(parsed)) continue; + if (limitTokens === undefined || parsed < limitTokens) limitTokens = parsed; + } + + const retryMatch = /retry in ([\d.]+)\s*s/i.exec(message); + const retryAfterMs = retryMatch ? Number(retryMatch[1]) * 1000 : undefined; + + return { + limitTokens, + retryAfterMs: Number.isFinite(retryAfterMs) && retryAfterMs! > 0 ? retryAfterMs : undefined, + }; +} + +export class RetryableModelError extends Error { + readonly retryable = true; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'RetryableModelError'; + if (cause !== undefined) { + Object.defineProperty(this, 'cause', { + value: cause, + writable: true, + configurable: true, + }); + } + } +} + +export function isRetryableModelError(error: unknown) { + return Boolean(error && typeof error === 'object' && 'retryable' in error && error.retryable === true); +} + +export function normalizeModel(model: string) { + return normalizeModelId(MODEL_ALIASES[model] ?? model); +} + +export function uniqueModels(models: string[]) { + return Array.from(new Set(models.map(normalizeModel))); +} + +export function isCloudflareAllocationError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return message.includes('4006') || message.toLowerCase().includes('daily free allocation'); +} + +export function isGoogleRateLimitError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const lower = message.toLowerCase(); + + if (lower.includes('timed out') || lower.includes('timeout')) { + return false; + } + + return lower.includes('429') || lower.includes('resource_exhausted') || lower.includes('quota exceeded'); +} + +export function isTransientModelFailure(error: unknown) { + if (isRetryableModelError(error)) return true; + // Unparseable output is deterministic, not transient. + if (error instanceof UnparseableModelResponseError) return false; + if (isCloudflareAllocationError(error)) return false; + const message = error instanceof Error ? error.message : String(error); + const lower = message.toLowerCase(); + + if (isTimeoutMessage(lower)) { + return false; + } + + return ( + isGoogleRateLimitError(error) || + matchesAnyTransientSubstring(lower) || + lower.includes('fetch failed') || + lower.includes('network') || + lower.includes('temporar') || + /\b50[0-9]\b/.test(lower) || + lower.includes('internal error') + ); +} diff --git a/packages/models/src/limits.ts b/packages/models/src/limits.ts index 2f31c352..59300bc7 100644 --- a/packages/models/src/limits.ts +++ b/packages/models/src/limits.ts @@ -1,133 +1,133 @@ -export const MODEL_TIMEOUT_BASE_MS = 20_000; -const MODEL_TIMEOUT_PER_LINE_MS = 100; -const MODEL_TIMEOUT_FREE_LINES = 100; -// Ceiling stays under 120s exceededCpu limit, leaving room to fail over. -export const MODEL_TIMEOUT_MAX_MS = 50_000; - -// Slightly above MODEL_TIMEOUT_MAX_MS so a large diff can use the full ceiling before deferring. -export const MODEL_FALLBACK_CHAIN_BUDGET_MS = 55_000; - -export const MODEL_TIMEOUT_PER_1K_OUTPUT_MS = 1_200; - -export function adaptiveModelTimeoutMs( - diffLineCount: number | null | undefined, - outputBudgetTokens?: number | null, -): number { - const lines = typeof diffLineCount === 'number' && Number.isFinite(diffLineCount) ? Math.max(0, diffLineCount) : 0; - const scaled = MODEL_TIMEOUT_BASE_MS + Math.max(0, lines - MODEL_TIMEOUT_FREE_LINES) * MODEL_TIMEOUT_PER_LINE_MS; - - const budget = typeof outputBudgetTokens === 'number' && Number.isFinite(outputBudgetTokens) - ? Math.max(0, outputBudgetTokens) - : 0; - const answerAllowance = Math.max(0, budget - OUTPUT_TOKENS_FLOOR) / 1_000 * MODEL_TIMEOUT_PER_1K_OUTPUT_MS; - - return Math.min(MODEL_TIMEOUT_MAX_MS, scaled + answerAllowance); -} - -// Per-candidate, not the old `candidates * 8` diff-line proxy: 12 findings fell under the 100-line free allowance and collapsed to the 20s base, so verification timed out and later chain rungs were skipped. -export const VERIFY_TIMEOUT_FLOOR_MS = 30_000; -const VERIFY_TIMEOUT_FREE_CANDIDATES = 10; -const VERIFY_TIMEOUT_PER_CANDIDATE_MS = 1_200; - -export function verifyTimeoutMs(candidateCount: number): number { - const extra = Math.max(0, candidateCount - VERIFY_TIMEOUT_FREE_CANDIDATES); - return Math.min(MODEL_TIMEOUT_MAX_MS, VERIFY_TIMEOUT_FLOOR_MS + extra * VERIFY_TIMEOUT_PER_CANDIDATE_MS); -} - -export function clampTimeoutToChainBudget(timeoutMs: number): number { - return Math.min(timeoutMs, MODEL_FALLBACK_CHAIN_BUDGET_MS); -} - -export const MODEL_MIN_VIABLE_ATTEMPT_MS = 8_000; - -export const MODEL_FALLBACK_RESERVE_MS = 20_000; - -// Returns 0 to defer the file to a fresh invocation. -export function chainAttemptTimeoutMs(input: { - requestedMs: number; - remainingChainMs: number; - hasAnotherModel: boolean; -}): number { - const { requestedMs, remainingChainMs, hasAnotherModel } = input; - if (remainingChainMs < MODEL_MIN_VIABLE_ATTEMPT_MS) return 0; - if (!hasAnotherModel) return Math.min(requestedMs, remainingChainMs); - - const withReserve = remainingChainMs - MODEL_FALLBACK_RESERVE_MS; - return Math.min(requestedMs, withReserve >= MODEL_MIN_VIABLE_ATTEMPT_MS ? withReserve : remainingChainMs); -} - -// 3 of the 6 pool connections reserved for KV/GitHub. -export const MAX_CONCURRENT_MODEL_CALLS = 3; - -const OUTPUT_TOKENS_PER_FINDING = 340; -const OUTPUT_TOKENS_PER_FILE_ENTRY = 160; -export const OUTPUT_TOKENS_FLOOR = 8_192; - -export function reviewOutputBudgetTokens(input: { findingCap: number; fileCount: number }): number { - const files = Math.max(1, input.fileCount); - const findings = Math.max(1, input.findingCap) * files; - return Math.max( - OUTPUT_TOKENS_FLOOR, - findings * OUTPUT_TOKENS_PER_FINDING + files * OUTPUT_TOKENS_PER_FILE_ENTRY, - ); -} - -export function resolveOutputTokenCeiling( - requested: number | undefined, - providerMax: number, - providerDefault: number, -): number { - if (typeof requested !== 'number' || !Number.isFinite(requested) || requested <= 0) { - return Math.min(providerDefault, providerMax); - } - return Math.min(providerMax, Math.max(providerDefault, Math.ceil(requested))); -} - -// Gemini thinking budget counts against maxOutputTokens, so it must stay bounded to 1024-8192. -export function geminiThinkingBudgetTokens(answerBudgetTokens: number): number { - return Math.min(8_192, Math.max(1_024, Math.floor(answerBudgetTokens / 4))); -} - -const SUBREQUESTS_PER_MODEL_ATTEMPT = 3; - -export const SUBREQUEST_HEADROOM_FOR_MODEL_CALL = SUBREQUESTS_PER_MODEL_ATTEMPT * MAX_CONCURRENT_MODEL_CALLS; - -// Queue wait time is excluded from the caller's timeout budget. -export class ModelCallGate { - private active = 0; - private readonly waiters: Array<() => void> = []; - - constructor(private readonly limit = MAX_CONCURRENT_MODEL_CALLS) {} - - async run(fn: () => Promise, onAcquired?: (waitedMs: number) => void): Promise { - const startedWaiting = Date.now(); - await this.acquire(); - onAcquired?.(Date.now() - startedWaiting); - try { - return await fn(); - } finally { - this.release(); - } - } - - get queueDepth() { - return this.waiters.length; - } - - private acquire(): Promise { - if (this.active < this.limit) { - this.active++; - return Promise.resolve(); - } - return new Promise((resolve) => this.waiters.push(resolve)); - } - - private release() { - const next = this.waiters.shift(); - if (next) { - next(); - } else { - this.active--; - } - } -} +export const MODEL_TIMEOUT_BASE_MS = 20_000; +const MODEL_TIMEOUT_PER_LINE_MS = 100; +const MODEL_TIMEOUT_FREE_LINES = 100; +// Ceiling stays under 120s exceededCpu limit, leaving room to fail over. +export const MODEL_TIMEOUT_MAX_MS = 50_000; + +// Slightly above MODEL_TIMEOUT_MAX_MS so a large diff can use the full ceiling before deferring. +export const MODEL_FALLBACK_CHAIN_BUDGET_MS = 55_000; + +export const MODEL_TIMEOUT_PER_1K_OUTPUT_MS = 1_200; + +export function adaptiveModelTimeoutMs( + diffLineCount: number | null | undefined, + outputBudgetTokens?: number | null, +): number { + const lines = typeof diffLineCount === 'number' && Number.isFinite(diffLineCount) ? Math.max(0, diffLineCount) : 0; + const scaled = MODEL_TIMEOUT_BASE_MS + Math.max(0, lines - MODEL_TIMEOUT_FREE_LINES) * MODEL_TIMEOUT_PER_LINE_MS; + + const budget = typeof outputBudgetTokens === 'number' && Number.isFinite(outputBudgetTokens) + ? Math.max(0, outputBudgetTokens) + : 0; + const answerAllowance = Math.max(0, budget - OUTPUT_TOKENS_FLOOR) / 1_000 * MODEL_TIMEOUT_PER_1K_OUTPUT_MS; + + return Math.min(MODEL_TIMEOUT_MAX_MS, scaled + answerAllowance); +} + +// Per-candidate, not the old `candidates * 8` diff-line proxy: 12 findings fell under the 100-line free allowance and collapsed to the 20s base, so verification timed out and later chain rungs were skipped. +export const VERIFY_TIMEOUT_FLOOR_MS = 30_000; +const VERIFY_TIMEOUT_FREE_CANDIDATES = 10; +const VERIFY_TIMEOUT_PER_CANDIDATE_MS = 1_200; + +export function verifyTimeoutMs(candidateCount: number): number { + const extra = Math.max(0, candidateCount - VERIFY_TIMEOUT_FREE_CANDIDATES); + return Math.min(MODEL_TIMEOUT_MAX_MS, VERIFY_TIMEOUT_FLOOR_MS + extra * VERIFY_TIMEOUT_PER_CANDIDATE_MS); +} + +export function clampTimeoutToChainBudget(timeoutMs: number): number { + return Math.min(timeoutMs, MODEL_FALLBACK_CHAIN_BUDGET_MS); +} + +export const MODEL_MIN_VIABLE_ATTEMPT_MS = 8_000; + +export const MODEL_FALLBACK_RESERVE_MS = 20_000; + +// Returns 0 to defer the file to a fresh invocation. +export function chainAttemptTimeoutMs(input: { + requestedMs: number; + remainingChainMs: number; + hasAnotherModel: boolean; +}): number { + const { requestedMs, remainingChainMs, hasAnotherModel } = input; + if (remainingChainMs < MODEL_MIN_VIABLE_ATTEMPT_MS) return 0; + if (!hasAnotherModel) return Math.min(requestedMs, remainingChainMs); + + const withReserve = remainingChainMs - MODEL_FALLBACK_RESERVE_MS; + return Math.min(requestedMs, withReserve >= MODEL_MIN_VIABLE_ATTEMPT_MS ? withReserve : remainingChainMs); +} + +// 3 of the 6 pool connections reserved for KV/GitHub. +export const MAX_CONCURRENT_MODEL_CALLS = 3; + +const OUTPUT_TOKENS_PER_FINDING = 340; +const OUTPUT_TOKENS_PER_FILE_ENTRY = 160; +export const OUTPUT_TOKENS_FLOOR = 8_192; + +export function reviewOutputBudgetTokens(input: { findingCap: number; fileCount: number }): number { + const files = Math.max(1, input.fileCount); + const findings = Math.max(1, input.findingCap) * files; + return Math.max( + OUTPUT_TOKENS_FLOOR, + findings * OUTPUT_TOKENS_PER_FINDING + files * OUTPUT_TOKENS_PER_FILE_ENTRY, + ); +} + +export function resolveOutputTokenCeiling( + requested: number | undefined, + providerMax: number, + providerDefault: number, +): number { + if (typeof requested !== 'number' || !Number.isFinite(requested) || requested <= 0) { + return Math.min(providerDefault, providerMax); + } + return Math.min(providerMax, Math.max(providerDefault, Math.ceil(requested))); +} + +// Gemini thinking budget counts against maxOutputTokens, so it must stay bounded to 1024-8192. +export function geminiThinkingBudgetTokens(answerBudgetTokens: number): number { + return Math.min(8_192, Math.max(1_024, Math.floor(answerBudgetTokens / 4))); +} + +const SUBREQUESTS_PER_MODEL_ATTEMPT = 3; + +export const SUBREQUEST_HEADROOM_FOR_MODEL_CALL = SUBREQUESTS_PER_MODEL_ATTEMPT * MAX_CONCURRENT_MODEL_CALLS; + +// Queue wait time is excluded from the caller's timeout budget. +export class ModelCallGate { + private active = 0; + private readonly waiters: Array<() => void> = []; + + constructor(private readonly limit = MAX_CONCURRENT_MODEL_CALLS) {} + + async run(fn: () => Promise, onAcquired?: (waitedMs: number) => void): Promise { + const startedWaiting = Date.now(); + await this.acquire(); + onAcquired?.(Date.now() - startedWaiting); + try { + return await fn(); + } finally { + this.release(); + } + } + + get queueDepth() { + return this.waiters.length; + } + + private acquire(): Promise { + if (this.active < this.limit) { + this.active++; + return Promise.resolve(); + } + return new Promise((resolve) => this.waiters.push(resolve)); + } + + private release() { + const next = this.waiters.shift(); + if (next) { + next(); + } else { + this.active--; + } + } +} diff --git a/packages/models/src/providers/anthropic.ts b/packages/models/src/providers/anthropic.ts index e4fb8159..57379183 100644 --- a/packages/models/src/providers/anthropic.ts +++ b/packages/models/src/providers/anthropic.ts @@ -1,89 +1,89 @@ -import { logger } from '@codraoss/core/logger'; -import { withTimeout } from '@codraoss/core/timeout'; -import { ProviderRequestError, providerErrorMessage, jsonOnlyPrompts, type ModelResponse } from '../types'; -import { assertPublicBaseUrl } from '../url-guard'; -import { MODEL_TIMEOUT_MAX_MS, resolveOutputTokenCeiling } from '../limits'; - -// Fallback when the caller supplies no diff-size-aware budget. Shares the review ceiling so an -// omitting caller can never outlast the chain budget that governs everything else. -const ANTHROPIC_TIMEOUT_MS = MODEL_TIMEOUT_MAX_MS; -const ANTHROPIC_DEFAULT_OUTPUT_TOKENS = 4096; -const ANTHROPIC_MAX_OUTPUT_TOKENS = 16_384; -const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1'; - -export interface AnthropicResponse { - content?: Array<{ text?: string }>; - usage?: { - input_tokens?: number; - output_tokens?: number; - }; -} - -export async function reviewWithAnthropic( - config: { apiKey: string; baseUrl?: string | null; providerName: string; timeoutMs?: number }, - model: string, - input: { systemPrompt: string; userPrompt: string; outputBudgetTokens?: number }, - tracker?: { incrementSubrequests(count?: number): void }, -): Promise { - logger.info(`Calling Anthropic model: ${model}`); - assertPublicBaseUrl(config.baseUrl, config.providerName); - const prompts = jsonOnlyPrompts(input); - let baseUrl = config.baseUrl || DEFAULT_ANTHROPIC_BASE_URL; - while (baseUrl.endsWith('/')) { - baseUrl = baseUrl.slice(0, -1); - } - const timeoutMs = config.timeoutMs ?? ANTHROPIC_TIMEOUT_MS; - - if (tracker) tracker.incrementSubrequests(1); - const response = await withTimeout('Anthropic API', timeoutMs, (signal) => - fetch(`${baseUrl}/messages`, { - method: 'POST', - signal, - headers: { - 'content-type': 'application/json', - 'x-api-key': config.apiKey, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model, - system: prompts.system, - messages: [ - { role: 'user', content: prompts.user }, - { role: 'assistant', content: '{' } - ], - max_tokens: resolveOutputTokenCeiling( - input.outputBudgetTokens, - ANTHROPIC_MAX_OUTPUT_TOKENS, - ANTHROPIC_DEFAULT_OUTPUT_TOKENS, - ), - // 0.6 of a 0-1 scale. - temperature: 0.6, - }), - }), - ); - - if (!response.ok) { - const errorText = await response.text(); - throw new ProviderRequestError(config.providerName, response.status, providerErrorMessage(errorText)); - } - - const data = (await response.json()) as AnthropicResponse; - let rawText = Array.isArray(data.content) - ? data.content.map((part) => typeof part?.text === 'string' ? part.text : '').join('').trim() - : ''; - - if (!rawText && (!data.content || data.content.length === 0)) { - throw new Error('Anthropic provider returned an empty response.'); - } - - // Restore the '{' used to prime JSON output; Anthropic doesn't echo the prefill back. - rawText = '{' + rawText; - - return { - rawText, - inputTokens: data?.usage?.input_tokens ?? 0, - outputTokens: data?.usage?.output_tokens ?? 0, - modelUsed: model, - provider: config.providerName, - }; -} +import { logger } from '@codraoss/core/logger'; +import { withTimeout } from '@codraoss/core/timeout'; +import { ProviderRequestError, providerErrorMessage, jsonOnlyPrompts, type ModelResponse } from '../types'; +import { assertPublicBaseUrl } from '../url-guard'; +import { MODEL_TIMEOUT_MAX_MS, resolveOutputTokenCeiling } from '../limits'; + +// Fallback when the caller supplies no diff-size-aware budget. Shares the review ceiling so an +// omitting caller can never outlast the chain budget that governs everything else. +const ANTHROPIC_TIMEOUT_MS = MODEL_TIMEOUT_MAX_MS; +const ANTHROPIC_DEFAULT_OUTPUT_TOKENS = 4096; +const ANTHROPIC_MAX_OUTPUT_TOKENS = 16_384; +const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1'; + +export interface AnthropicResponse { + content?: Array<{ text?: string }>; + usage?: { + input_tokens?: number; + output_tokens?: number; + }; +} + +export async function reviewWithAnthropic( + config: { apiKey: string; baseUrl?: string | null; providerName: string; timeoutMs?: number }, + model: string, + input: { systemPrompt: string; userPrompt: string; outputBudgetTokens?: number }, + tracker?: { incrementSubrequests(count?: number): void }, +): Promise { + logger.info(`Calling Anthropic model: ${model}`); + assertPublicBaseUrl(config.baseUrl, config.providerName); + const prompts = jsonOnlyPrompts(input); + let baseUrl = config.baseUrl || DEFAULT_ANTHROPIC_BASE_URL; + while (baseUrl.endsWith('/')) { + baseUrl = baseUrl.slice(0, -1); + } + const timeoutMs = config.timeoutMs ?? ANTHROPIC_TIMEOUT_MS; + + if (tracker) tracker.incrementSubrequests(1); + const response = await withTimeout('Anthropic API', timeoutMs, (signal) => + fetch(`${baseUrl}/messages`, { + method: 'POST', + signal, + headers: { + 'content-type': 'application/json', + 'x-api-key': config.apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model, + system: prompts.system, + messages: [ + { role: 'user', content: prompts.user }, + { role: 'assistant', content: '{' } + ], + max_tokens: resolveOutputTokenCeiling( + input.outputBudgetTokens, + ANTHROPIC_MAX_OUTPUT_TOKENS, + ANTHROPIC_DEFAULT_OUTPUT_TOKENS, + ), + // 0.6 of a 0-1 scale. + temperature: 0.6, + }), + }), + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new ProviderRequestError(config.providerName, response.status, providerErrorMessage(errorText)); + } + + const data = (await response.json()) as AnthropicResponse; + let rawText = Array.isArray(data.content) + ? data.content.map((part) => typeof part?.text === 'string' ? part.text : '').join('').trim() + : ''; + + if (!rawText && (!data.content || data.content.length === 0)) { + throw new Error('Anthropic provider returned an empty response.'); + } + + // Restore the '{' used to prime JSON output; Anthropic doesn't echo the prefill back. + rawText = '{' + rawText; + + return { + rawText, + inputTokens: data?.usage?.input_tokens ?? 0, + outputTokens: data?.usage?.output_tokens ?? 0, + modelUsed: model, + provider: config.providerName, + }; +} diff --git a/packages/models/src/providers/cloudflare.ts b/packages/models/src/providers/cloudflare.ts index 8381267b..48c191ed 100644 --- a/packages/models/src/providers/cloudflare.ts +++ b/packages/models/src/providers/cloudflare.ts @@ -1,284 +1,284 @@ -import { logger } from '@codraoss/core/logger'; - -import { TimeoutError } from '@codraoss/core/timeout'; -import { ProviderRequestError, UnparseableModelResponseError, jsonOnlyPrompts, type ModelInput, type ModelResponse } from '../types'; -import { MODEL_TIMEOUT_MAX_MS, OUTPUT_TOKENS_FLOOR, resolveOutputTokenCeiling } from '../limits'; - -export interface CloudflareAiBinding { - run(model: string, args: unknown, options?: unknown): Promise; -} - -// Reasoning models under strict-JSON can burn the token budget thinking and never emit; fail fast and defer. -const CLOUDFLARE_TIMEOUT_MS = MODEL_TIMEOUT_MAX_MS; -const CLOUDFLARE_DEFAULT_OUTPUT_TOKENS = OUTPUT_TOKENS_FLOOR; -// Workers AI context windows vary widely by model, so this stays modest next to Gemini's: an over-large -// `max_completion_tokens` is refused by the smaller models rather than clamped. -const CLOUDFLARE_MAX_OUTPUT_TOKENS = 16_384; - -type UnknownRecord = Record; - -function isRecord(value: unknown): value is UnknownRecord { - return typeof value === 'object' && value !== null; -} - -function isText(value: unknown): value is string { - return typeof value === 'string' && value.trim().length > 0; -} - -function getRecord(value: unknown, key: string): UnknownRecord | null { - if (!isRecord(value)) return null; - const child = value[key]; - return isRecord(child) ? child : null; -} - -function getNumber(value: unknown, key: string) { - if (!isRecord(value)) return null; - const child = value[key]; - return typeof child === 'number' ? child : null; -} - -function isLocalWorkersAiBindingError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - const normalized = message.toLowerCase(); - return normalized.includes('binding ai') && normalized.includes('run remotely'); -} - -function failUnparseable(model: string, reason: string): never { - logger.warn(`Cloudflare model ${model} returned no parseable review content; failing the file review`, { reason }); - throw new UnparseableModelResponseError(model, reason); -} - -function extractMessageContent(content: unknown): string | null { - if (isText(content)) return content.trim(); - - if (Array.isArray(content)) { - const text = content - .map((part) => { - if (isText(part)) return part; - if (isRecord(part) && isText(part.text)) return part.text; - return ''; - }) - .join('') - .trim(); - return text || null; - } - - return null; -} - -// `response` is a string on most models, a parsed object/array on structured-output ones; accept both or a good review is discarded as empty. -function extractResponseField(container: unknown): string | null { - if (!isRecord(container)) return null; - const value = container.response; - if (isText(value)) return value.trim(); - if (value && typeof value === 'object') { - try { - return JSON.stringify(value); - } catch { - return null; - } - } - return null; -} - -function extractCloudflareText(result: unknown, model: string): string { - if (isText(result)) return result.trim(); - const response = extractResponseField(result); - if (response) return response; - - const nestedResult = getRecord(result, 'result'); - const nestedResponse = extractResponseField(nestedResult); - if (nestedResponse) return nestedResponse; - - const choices = isRecord(result) && Array.isArray(result.choices) ? result.choices : null; - const choice = choices?.[0]; - const message = getRecord(choice, 'message'); - const content = extractMessageContent(message?.content); - if (content) return content; - - const finishReason = isRecord(choice) ? choice.finish_reason ?? choice.stop_reason : null; - const reasoning = isText(message?.reasoning) ? message.reasoning : isText(message?.reasoning_content) ? message.reasoning_content : null; - if (reasoning) { - return failUnparseable(model, `reasoning-only response${finishReason ? `, finish_reason=${String(finishReason)}` : ''}`); - } - - if (finishReason) { - return failUnparseable(model, `finish_reason=${String(finishReason)}`); - } - - return failUnparseable(model, 'empty response'); -} - -function extractCloudflareUsage(result: unknown) { - const usage = getRecord(result, 'usage') ?? getRecord(getRecord(result, 'result'), 'usage'); - return { - inputTokens: getNumber(usage, 'prompt_tokens') ?? 0, - outputTokens: getNumber(usage, 'completion_tokens') ?? 0, - }; -} - -// Grammar comes from the CALLER: hardcoding the file-review schema here once forced the verifier to emit a file-review object, silently defaulting `results` to `[]`. -function buildCloudflareInferenceRequest(input: ModelInput) { - const prompts = jsonOnlyPrompts(input); - return { - messages: [ - { role: 'system', content: prompts.system }, - { role: 'user', content: prompts.user }, - ], - max_completion_tokens: resolveOutputTokenCeiling( - input.outputBudgetTokens, - CLOUDFLARE_MAX_OUTPUT_TOKENS, - CLOUDFLARE_DEFAULT_OUTPUT_TOKENS, - ), - ...(input.responseSchema - ? { - response_format: { - type: 'json_schema', - json_schema: { - name: input.responseSchema.name, - strict: true, - schema: input.responseSchema.schema, - }, - }, - } - : {}), - // 0.6 on Workers AI's 0-5 scale; top_p moves with it, else pinning it low would cancel the raise. - temperature: 0.6, - top_p: 0.9, - }; -} - -// `pending` covers both queued and running. -export type CloudflareBatchPollResult = - | { status: 'pending' } - | { status: 'done'; response: ModelResponse }; - -function extractBatchStatus(result: unknown): string | null { - if (!isRecord(result)) return null; - const status = result.status ?? getRecord(result, 'result')?.status; - return typeof status === 'string' ? status.toLowerCase() : null; -} - -// Workers AI has returned several shapes here (`responses`, `result.responses`, or a bare result); probe defensively and fall back to the whole payload. -function extractBatchInnerResult(result: unknown): unknown { - const containers = [result, isRecord(result) ? result.result : undefined]; - for (const container of containers) { - if (!isRecord(container)) continue; - const responses = container.responses ?? container.results; - if (Array.isArray(responses) && responses.length > 0) { - const first = responses[0]; - // Entries may wrap output under `result`/`response`, or be it directly. - if (isRecord(first)) return first.result ?? first; - return first; - } - } - return result; -} - -// Throws if unsupported; the caller falls back to the synchronous path. -export async function submitCloudflareBatch( - aiBinding: CloudflareAiBinding, - model: string, - input: ModelInput, - tracker?: { incrementSubrequests(count?: number): void }, -): Promise { - if (tracker) tracker.incrementSubrequests(1); - logger.info(`Submitting async batch request to Cloudflare model: ${model}`); - const result = await aiBinding.run( - model as any, - { requests: [buildCloudflareInferenceRequest(input)] } as any, - { queueRequest: true } as any, - ); - - const requestId = isRecord(result) - ? (result.request_id ?? getRecord(result, 'result')?.request_id) - : undefined; - if (typeof requestId !== 'string' || !requestId) { - throw new Error(`Cloudflare model ${model} did not return an async batch request_id (async queueing unsupported).`); - } - return requestId; -} - -export async function pollCloudflareBatch( - aiBinding: CloudflareAiBinding, - model: string, - requestId: string, - tracker?: { incrementSubrequests(count?: number): void }, - providerName = 'Cloudflare', -): Promise { - if (tracker) tracker.incrementSubrequests(1); - const result = await aiBinding.run(model, { request_id: requestId }); - - const status = extractBatchStatus(result); - if (status === 'queued' || status === 'running') { - return { status: 'pending' }; - } - - const inner = extractBatchInnerResult(result); - const rawText = extractCloudflareText(inner, model); - const usage = extractCloudflareUsage(inner); - return { - status: 'done', - response: { - rawText, - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - modelUsed: model, - provider: providerName, - }, - }; -} - -export async function reviewWithCloudflare( - aiBinding: CloudflareAiBinding, - model: string, - input: ModelInput, - tracker?: { incrementSubrequests(count?: number): void }, - providerName = 'Cloudflare', - options?: { timeoutMs?: number }, -): Promise { - // Single attempt: a retry would spend another subrequest on a model that just failed, when the fallback chain is about to try another. - const timeoutMs = options?.timeoutMs ?? CLOUDFLARE_TIMEOUT_MS; - let timer: ReturnType | undefined; - - // Promise.race only stops us awaiting; the binding's abort signal is what actually cancels the still-running subrequest. - const controller = new AbortController(); - const timeoutPromise = new Promise((_, reject) => { - timer = setTimeout(() => { - controller.abort(); - reject(new TimeoutError(`Cloudflare (${model})`, timeoutMs)); - }, timeoutMs); - }); - - try { - if (tracker) tracker.incrementSubrequests(1); - - logger.info(`Calling Cloudflare model: ${model}`); - const startTime = Date.now(); - const runPromise = aiBinding.run(model, buildCloudflareInferenceRequest(input), { signal: controller.signal }); - // The aborted run still settles as a rejection; a no-op handler stops it surfacing as unhandled. - runPromise.catch(() => {}); - const result = await Promise.race([runPromise, timeoutPromise]); - logger.info(`AI model ${model} responded in ${Date.now() - startTime}ms`); - - const usage = extractCloudflareUsage(result); - return { - rawText: extractCloudflareText(result, model), - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - modelUsed: model, - provider: providerName, - }; - } catch (error) { - if (isLocalWorkersAiBindingError(error)) { - const message = 'Cloudflare Workers AI is not available in local Wrangler. Run with remote bindings or deploy the Worker to test Cloudflare models.'; - logger.warn(message, { model }); - throw new ProviderRequestError(providerName, 400, message); - } - - logger.error('Cloudflare request failed', { model, error: error instanceof Error ? error.message : String(error) }); - throw error; - } finally { - clearTimeout(timer); - } -} +import { logger } from '@codraoss/core/logger'; + +import { TimeoutError } from '@codraoss/core/timeout'; +import { ProviderRequestError, UnparseableModelResponseError, jsonOnlyPrompts, type ModelInput, type ModelResponse } from '../types'; +import { MODEL_TIMEOUT_MAX_MS, OUTPUT_TOKENS_FLOOR, resolveOutputTokenCeiling } from '../limits'; + +export interface CloudflareAiBinding { + run(model: string, args: unknown, options?: unknown): Promise; +} + +// Reasoning models under strict-JSON can burn the token budget thinking and never emit; fail fast and defer. +const CLOUDFLARE_TIMEOUT_MS = MODEL_TIMEOUT_MAX_MS; +const CLOUDFLARE_DEFAULT_OUTPUT_TOKENS = OUTPUT_TOKENS_FLOOR; +// Workers AI context windows vary widely by model, so this stays modest next to Gemini's: an over-large +// `max_completion_tokens` is refused by the smaller models rather than clamped. +const CLOUDFLARE_MAX_OUTPUT_TOKENS = 16_384; + +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null; +} + +function isText(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function getRecord(value: unknown, key: string): UnknownRecord | null { + if (!isRecord(value)) return null; + const child = value[key]; + return isRecord(child) ? child : null; +} + +function getNumber(value: unknown, key: string) { + if (!isRecord(value)) return null; + const child = value[key]; + return typeof child === 'number' ? child : null; +} + +function isLocalWorkersAiBindingError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const normalized = message.toLowerCase(); + return normalized.includes('binding ai') && normalized.includes('run remotely'); +} + +function failUnparseable(model: string, reason: string): never { + logger.warn(`Cloudflare model ${model} returned no parseable review content; failing the file review`, { reason }); + throw new UnparseableModelResponseError(model, reason); +} + +function extractMessageContent(content: unknown): string | null { + if (isText(content)) return content.trim(); + + if (Array.isArray(content)) { + const text = content + .map((part) => { + if (isText(part)) return part; + if (isRecord(part) && isText(part.text)) return part.text; + return ''; + }) + .join('') + .trim(); + return text || null; + } + + return null; +} + +// `response` is a string on most models, a parsed object/array on structured-output ones; accept both or a good review is discarded as empty. +function extractResponseField(container: unknown): string | null { + if (!isRecord(container)) return null; + const value = container.response; + if (isText(value)) return value.trim(); + if (value && typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + return null; + } + } + return null; +} + +function extractCloudflareText(result: unknown, model: string): string { + if (isText(result)) return result.trim(); + const response = extractResponseField(result); + if (response) return response; + + const nestedResult = getRecord(result, 'result'); + const nestedResponse = extractResponseField(nestedResult); + if (nestedResponse) return nestedResponse; + + const choices = isRecord(result) && Array.isArray(result.choices) ? result.choices : null; + const choice = choices?.[0]; + const message = getRecord(choice, 'message'); + const content = extractMessageContent(message?.content); + if (content) return content; + + const finishReason = isRecord(choice) ? choice.finish_reason ?? choice.stop_reason : null; + const reasoning = isText(message?.reasoning) ? message.reasoning : isText(message?.reasoning_content) ? message.reasoning_content : null; + if (reasoning) { + return failUnparseable(model, `reasoning-only response${finishReason ? `, finish_reason=${String(finishReason)}` : ''}`); + } + + if (finishReason) { + return failUnparseable(model, `finish_reason=${String(finishReason)}`); + } + + return failUnparseable(model, 'empty response'); +} + +function extractCloudflareUsage(result: unknown) { + const usage = getRecord(result, 'usage') ?? getRecord(getRecord(result, 'result'), 'usage'); + return { + inputTokens: getNumber(usage, 'prompt_tokens') ?? 0, + outputTokens: getNumber(usage, 'completion_tokens') ?? 0, + }; +} + +// Grammar comes from the CALLER: hardcoding the file-review schema here once forced the verifier to emit a file-review object, silently defaulting `results` to `[]`. +function buildCloudflareInferenceRequest(input: ModelInput) { + const prompts = jsonOnlyPrompts(input); + return { + messages: [ + { role: 'system', content: prompts.system }, + { role: 'user', content: prompts.user }, + ], + max_completion_tokens: resolveOutputTokenCeiling( + input.outputBudgetTokens, + CLOUDFLARE_MAX_OUTPUT_TOKENS, + CLOUDFLARE_DEFAULT_OUTPUT_TOKENS, + ), + ...(input.responseSchema + ? { + response_format: { + type: 'json_schema', + json_schema: { + name: input.responseSchema.name, + strict: true, + schema: input.responseSchema.schema, + }, + }, + } + : {}), + // 0.6 on Workers AI's 0-5 scale; top_p moves with it, else pinning it low would cancel the raise. + temperature: 0.6, + top_p: 0.9, + }; +} + +// `pending` covers both queued and running. +export type CloudflareBatchPollResult = + | { status: 'pending' } + | { status: 'done'; response: ModelResponse }; + +function extractBatchStatus(result: unknown): string | null { + if (!isRecord(result)) return null; + const status = result.status ?? getRecord(result, 'result')?.status; + return typeof status === 'string' ? status.toLowerCase() : null; +} + +// Workers AI has returned several shapes here (`responses`, `result.responses`, or a bare result); probe defensively and fall back to the whole payload. +function extractBatchInnerResult(result: unknown): unknown { + const containers = [result, isRecord(result) ? result.result : undefined]; + for (const container of containers) { + if (!isRecord(container)) continue; + const responses = container.responses ?? container.results; + if (Array.isArray(responses) && responses.length > 0) { + const first = responses[0]; + // Entries may wrap output under `result`/`response`, or be it directly. + if (isRecord(first)) return first.result ?? first; + return first; + } + } + return result; +} + +// Throws if unsupported; the caller falls back to the synchronous path. +export async function submitCloudflareBatch( + aiBinding: CloudflareAiBinding, + model: string, + input: ModelInput, + tracker?: { incrementSubrequests(count?: number): void }, +): Promise { + if (tracker) tracker.incrementSubrequests(1); + logger.info(`Submitting async batch request to Cloudflare model: ${model}`); + const result = await aiBinding.run( + model as any, + { requests: [buildCloudflareInferenceRequest(input)] } as any, + { queueRequest: true } as any, + ); + + const requestId = isRecord(result) + ? (result.request_id ?? getRecord(result, 'result')?.request_id) + : undefined; + if (typeof requestId !== 'string' || !requestId) { + throw new Error(`Cloudflare model ${model} did not return an async batch request_id (async queueing unsupported).`); + } + return requestId; +} + +export async function pollCloudflareBatch( + aiBinding: CloudflareAiBinding, + model: string, + requestId: string, + tracker?: { incrementSubrequests(count?: number): void }, + providerName = 'Cloudflare', +): Promise { + if (tracker) tracker.incrementSubrequests(1); + const result = await aiBinding.run(model, { request_id: requestId }); + + const status = extractBatchStatus(result); + if (status === 'queued' || status === 'running') { + return { status: 'pending' }; + } + + const inner = extractBatchInnerResult(result); + const rawText = extractCloudflareText(inner, model); + const usage = extractCloudflareUsage(inner); + return { + status: 'done', + response: { + rawText, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + modelUsed: model, + provider: providerName, + }, + }; +} + +export async function reviewWithCloudflare( + aiBinding: CloudflareAiBinding, + model: string, + input: ModelInput, + tracker?: { incrementSubrequests(count?: number): void }, + providerName = 'Cloudflare', + options?: { timeoutMs?: number }, +): Promise { + // Single attempt: a retry would spend another subrequest on a model that just failed, when the fallback chain is about to try another. + const timeoutMs = options?.timeoutMs ?? CLOUDFLARE_TIMEOUT_MS; + let timer: ReturnType | undefined; + + // Promise.race only stops us awaiting; the binding's abort signal is what actually cancels the still-running subrequest. + const controller = new AbortController(); + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new TimeoutError(`Cloudflare (${model})`, timeoutMs)); + }, timeoutMs); + }); + + try { + if (tracker) tracker.incrementSubrequests(1); + + logger.info(`Calling Cloudflare model: ${model}`); + const startTime = Date.now(); + const runPromise = aiBinding.run(model, buildCloudflareInferenceRequest(input), { signal: controller.signal }); + // The aborted run still settles as a rejection; a no-op handler stops it surfacing as unhandled. + runPromise.catch(() => {}); + const result = await Promise.race([runPromise, timeoutPromise]); + logger.info(`AI model ${model} responded in ${Date.now() - startTime}ms`); + + const usage = extractCloudflareUsage(result); + return { + rawText: extractCloudflareText(result, model), + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + modelUsed: model, + provider: providerName, + }; + } catch (error) { + if (isLocalWorkersAiBindingError(error)) { + const message = 'Cloudflare Workers AI is not available in local Wrangler. Run with remote bindings or deploy the Worker to test Cloudflare models.'; + logger.warn(message, { model }); + throw new ProviderRequestError(providerName, 400, message); + } + + logger.error('Cloudflare request failed', { model, error: error instanceof Error ? error.message : String(error) }); + throw error; + } finally { + clearTimeout(timer); + } +} diff --git a/packages/models/src/providers/google.ts b/packages/models/src/providers/google.ts index f2857256..0c80b877 100644 --- a/packages/models/src/providers/google.ts +++ b/packages/models/src/providers/google.ts @@ -1,349 +1,349 @@ -import { logger } from '@codraoss/core/logger'; -import { withTimeout } from '@codraoss/core/timeout'; -import { ProviderRequestError, UnparseableModelResponseError, providerErrorMessage, jsonOnlyPrompts, isThinkingRejection, attachPartialResponse, type ModelInput, type ModelResponse } from '../types'; -import { toGeminiResponseJsonSchema } from '../gemini-schema'; -import { assertPublicBaseUrl } from '../url-guard'; -import { - MODEL_TIMEOUT_MAX_MS, - MODEL_TIMEOUT_PER_1K_OUTPUT_MS, - OUTPUT_TOKENS_FLOOR, - geminiThinkingBudgetTokens, - resolveOutputTokenCeiling, -} from '../limits'; - -const GEMINI_TIMEOUT_MS = MODEL_TIMEOUT_MAX_MS; -const GEMINI_MAX_RETRIES = 2; -const GEMINI_DEFAULT_OUTPUT_TOKENS = OUTPUT_TOKENS_FLOOR; -// 65k leaves room for thinking tokens plus dense multi-file output. -const GEMINI_MAX_OUTPUT_TOKENS = 65_536; -const GEMINI_MAX_RETRY_DELAY_MS = 5_000; -const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'; - -// 429 handled separately; only retryable if a cool-off is stated. -function isRetryableGeminiStatus(status: number) { - return status === 408 || status === 500 || status === 502 || status === 503 || status === 504 || status === 524; -} - -function defaultRetryDelayMs(attempt: number) { - return Math.pow(2, attempt) * 800 + Math.random() * 400; -} - -function retryAfterDelayMs(value: string | null) { - if (!value) return null; - const seconds = Number(value); - if (Number.isFinite(seconds) && seconds >= 0) { - return seconds * 1000; - } - - const dateMs = Date.parse(value); - if (Number.isFinite(dateMs)) { - return Math.max(0, dateMs - Date.now()); - } - - return null; -} - -function requestedRetryDelayFromBody(message: string): number | null { - const match = /retry in ([\d.]+)s/i.exec(message); - if (!match) return null; - const seconds = Number(match[1]); - return Number.isFinite(seconds) ? seconds * 1000 : null; -} - -export function classifySchemaRejection(status: number, message: string): 'confident' | 'catchall' | null { - if (status !== 400) return null; - const lower = message.toLowerCase(); - - const namesTheGrammar = - lower.includes('responsejsonschema') || - lower.includes('response_json_schema') || - lower.includes('responseschema') || - lower.includes('response_schema') || - lower.includes('invalid json payload') || - lower.includes('unknown name') || - lower.includes('schema'); - if (namesTheGrammar) return 'confident'; - - const grammarAdjacent = - lower.includes('generation_config') || - lower.includes('generationconfig') || - lower.includes('json') || - lower.includes('constrained') || - lower.includes('too many states'); - if (lower.includes('invalid argument') && grammarAdjacent) return 'catchall'; - - return null; -} - -function isRetryableTransportError(error: unknown) { - if (!(error instanceof Error)) return false; - // Skip retrying timeouts (caller already grants up to 2m); defer to fallback chain. - if (error.name === 'TimeoutError' || error.message.toLowerCase().includes('timed out')) return false; - if (error.message.includes('fetch failed')) return true; - return error instanceof TypeError; -} - -export async function reviewWithGoogle( - config: { apiKey: string; baseUrl?: string | null; providerName?: string; timeoutMs?: number }, - model: string, - input: ModelInput, - tracker?: { incrementSubrequests(count?: number): void }, -): Promise { - const timeoutMs = config.timeoutMs ?? GEMINI_TIMEOUT_MS; - logger.info(`Calling Google model: ${model}`); - - assertPublicBaseUrl(config.baseUrl, config.providerName ?? 'Google'); - const prompts = jsonOnlyPrompts(input); - const responseJsonSchema = input.responseSchema - ? toGeminiResponseJsonSchema(input.responseSchema.schema) - : null; - let schemaRejected = false; - let schemaRejectionBranch: 'confident' | 'catchall' = 'confident'; - let thinkingRejected = false; - - const answerBudget = resolveOutputTokenCeiling( - input.outputBudgetTokens, - GEMINI_MAX_OUTPUT_TOKENS, - GEMINI_DEFAULT_OUTPUT_TOKENS, - ); - const thinkingBudget = geminiThinkingBudgetTokens(answerBudget); - let currentCeiling = Math.min(GEMINI_MAX_OUTPUT_TOKENS, answerBudget + thinkingBudget); - let ceilingRaised = false; - const fail = (error: unknown): never => { - // Confident rejections only: a probe that failed anyway proves nothing, and latching would strip the schema from every later call in the job. A successful probe latches via `degraded` instead. - if (schemaRejected && schemaRejectionBranch === 'confident' && typeof error === 'object' && error !== null) { - Object.defineProperty(error, 'schemaDropped', { value: true, configurable: true }); - } - throw error; - }; - - const startTime = Date.now(); - let baseUrl = config.baseUrl || DEFAULT_GEMINI_BASE_URL; - while (baseUrl.endsWith('/')) { - baseUrl = baseUrl.slice(0, -1); - } - const url = `${baseUrl}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(config.apiKey)}`; - const maxRetries = GEMINI_MAX_RETRIES; - let lastError: unknown; - let delayBeforeAttemptMs = 0; - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - if (delayBeforeAttemptMs > 0) { - logger.info(`Retrying Gemini request (attempt ${attempt}/${maxRetries}) in ${Math.round(delayBeforeAttemptMs)}ms`); - await new Promise(resolve => setTimeout(resolve, delayBeforeAttemptMs)); - delayBeforeAttemptMs = 0; - } - - let response: Response; - try { - if (tracker) tracker.incrementSubrequests(1); - response = await withTimeout('Gemini API', timeoutMs, (signal) => - fetch(url, { - method: 'POST', - signal, - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - systemInstruction: { - role: 'system', - parts: [{ text: prompts.system }], - }, - contents: [ - { role: 'user', parts: [{ text: prompts.user }] }, - ], - generationConfig: { - responseMimeType: 'application/json', - ...(responseJsonSchema && !schemaRejected ? { responseJsonSchema } : {}), - maxOutputTokens: currentCeiling, - ...(thinkingRejected ? {} : { thinkingConfig: { thinkingBudget } }), - // Gemini's temperature scale is 0-2, not 0-1. - temperature: 0.9, - }, - }), - }), - ); - } catch (error) { - lastError = error; - if (isRetryableTransportError(error) && attempt < maxRetries) { - delayBeforeAttemptMs = defaultRetryDelayMs(attempt); - continue; - } - return fail(error); - } - - if (!response.ok) { - const errorText = await response.text(); - const message = providerErrorMessage(errorText); - - // Check thinking rejection first; isSchemaRejection below is broad. - if (!thinkingRejected && isThinkingRejection(response.status, message)) { - thinkingRejected = true; - logger.warn('Gemini rejected thinkingConfig; retrying without an explicit thinking budget', { - model, - error: message, - }); - lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message); - attempt--; - continue; - } - - const schemaRejection = responseJsonSchema && !schemaRejected - ? classifySchemaRejection(response.status, message) - : null; - if (schemaRejection) { - schemaRejected = true; - schemaRejectionBranch = schemaRejection; - // Inferred from message; real cause surfaces below if 400 recurs. - logger.warn('Gemini returned a 400 that looks like a response-grammar rejection; retrying without constrained decoding', { - model, - branch: schemaRejection, - error: message, - }); - lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message); - attempt--; - continue; - } - - // Unexplained invalid-argument 400: strip optional features one at a time -- grammar, then thinking budget -- refunding the attempt each time. The latches bound this ladder to two extra probes. - if (response.status === 400 && /invalid argument/i.test(message)) { - if (responseJsonSchema && !schemaRejected) { - schemaRejected = true; - schemaRejectionBranch = 'catchall'; - logger.warn('Gemini returned an unexplained 400; probing without constrained decoding', { - model, - error: message, - }); - lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message); - attempt--; - continue; - } - if (!thinkingRejected) { - thinkingRejected = true; - logger.warn('Gemini returned an unexplained 400 with the grammar already off; probing without an explicit thinking budget', { - model, - error: message, - }); - lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message); - attempt--; - continue; - } - } - - const requestedDelayMs = response.status === 429 - ? retryAfterDelayMs(response.headers.get('retry-after')) ?? requestedRetryDelayFromBody(message) - : null; - // Unstated 429s back off ~60s, making them unretryable here; retry only short, stated cool-offs. - const isRetryable = response.status === 429 - ? requestedDelayMs !== null && requestedDelayMs <= GEMINI_MAX_RETRY_DELAY_MS - : isRetryableGeminiStatus(response.status); - const retryDelayMs = Math.min( - GEMINI_MAX_RETRY_DELAY_MS, - requestedDelayMs ?? defaultRetryDelayMs(attempt), - ); - - const logData = { - error: message, - attempt, - willRetry: isRetryable && attempt < maxRetries, - requestedDelayMs: requestedDelayMs ?? undefined, - retryDelayMs: isRetryable && attempt < maxRetries ? retryDelayMs : undefined, - rawBody: response.status >= 400 && response.status < 500 && !(isRetryable && attempt < maxRetries) - ? errorText.slice(0, 2_000) - : undefined, - }; - if (isRetryable && attempt < maxRetries) { - logger.warn(`Gemini request failed with ${response.status}; retrying`, logData); - lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message); - delayBeforeAttemptMs = retryDelayMs; - continue; - } - - logger.error(`Gemini request failed with ${response.status}`, logData); - return fail(new ProviderRequestError(config.providerName ?? 'Google', response.status, message)); - } - - const durationMs = Date.now() - startTime; - logger.info(`AI model ${model} responded in ${durationMs}ms`); - - const data = (await response.json()) as { - candidates?: Array<{ content?: { parts?: Array<{ text?: string }> }; finishReason?: string }>; - usageMetadata?: { - promptTokenCount?: number; - candidatesTokenCount?: number; - // Billed against maxOutputTokens. - thoughtsTokenCount?: number; - }; - }; - - const candidate = data.candidates?.[0]; - const rawText = candidate?.content?.parts?.map((part) => part.text ?? '').join('')?.trim(); - const finishReason = candidate?.finishReason; - const truncated = finishReason === 'MAX_TOKENS'; - - if (finishReason && finishReason !== 'STOP') { - logger.warn(`Gemini response for ${model} ended with finishReason=${finishReason}; output is likely incomplete`, { - // Avoid a `Tokens` key name; the logger redacts it. - outputSpend: (data.usageMetadata?.candidatesTokenCount ?? 0) + (data.usageMetadata?.thoughtsTokenCount ?? 0), - thoughtSpend: data.usageMetadata?.thoughtsTokenCount ?? 0, - outputCeiling: currentCeiling, - thinkingBudget: thinkingRejected ? undefined : thinkingBudget, - schemaDropped: schemaRejected, - }); - } - - if (truncated && input.truncationIntolerant && !ceilingRaised) { - const elapsed = Date.now() - startTime; - const raisedCeiling = Math.min(GEMINI_MAX_OUTPUT_TOKENS, 2 * answerBudget + thinkingBudget); - const extraMs = ((raisedCeiling - currentCeiling) / 1_000) * MODEL_TIMEOUT_PER_1K_OUTPUT_MS; - - if (elapsed + extraMs < timeoutMs) { - ceilingRaised = true; - currentCeiling = raisedCeiling; - logger.warn(`Gemini ran out of output room on ${model}; resending once with a larger ceiling`, { - outputCeiling: raisedCeiling, - thoughtSpend: data.usageMetadata?.thoughtsTokenCount ?? 0, - hadPartialText: Boolean(rawText), - }); - attempt--; - continue; - } - } - - if (!rawText) { - // Non-STOP finish fails permanently; empty STOP is transient. - if (finishReason && finishReason !== 'STOP') { - return fail(new UnparseableModelResponseError(model, `finishReason=${finishReason}`)); - } - return fail(new Error('Gemini returned an empty response.')); - } - - // Attach partial text so a later fallback model can salvage it. - if (truncated && input.truncationIntolerant) { - const error = new UnparseableModelResponseError(model, 'finishReason=MAX_TOKENS'); - attachPartialResponse(error, { - rawText, - inputTokens: data.usageMetadata?.promptTokenCount ?? 0, - outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0, - modelUsed: model, - provider: config.providerName ?? 'Google', - }); - return fail(error); - } - - return { - rawText, - inputTokens: data.usageMetadata?.promptTokenCount ?? 0, - outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0, - modelUsed: model, - provider: config.providerName ?? 'Google', - ...(schemaRejected - ? { degraded: schemaRejectionBranch === 'catchall' - ? ('schema-dropped-catchall' as const) - : ('schema-dropped' as const) } - : {}), - }; - } - - return fail(lastError); -} +import { logger } from '@codraoss/core/logger'; +import { withTimeout } from '@codraoss/core/timeout'; +import { ProviderRequestError, UnparseableModelResponseError, providerErrorMessage, jsonOnlyPrompts, isThinkingRejection, attachPartialResponse, type ModelInput, type ModelResponse } from '../types'; +import { toGeminiResponseJsonSchema } from '../gemini-schema'; +import { assertPublicBaseUrl } from '../url-guard'; +import { + MODEL_TIMEOUT_MAX_MS, + MODEL_TIMEOUT_PER_1K_OUTPUT_MS, + OUTPUT_TOKENS_FLOOR, + geminiThinkingBudgetTokens, + resolveOutputTokenCeiling, +} from '../limits'; + +const GEMINI_TIMEOUT_MS = MODEL_TIMEOUT_MAX_MS; +const GEMINI_MAX_RETRIES = 2; +const GEMINI_DEFAULT_OUTPUT_TOKENS = OUTPUT_TOKENS_FLOOR; +// 65k leaves room for thinking tokens plus dense multi-file output. +const GEMINI_MAX_OUTPUT_TOKENS = 65_536; +const GEMINI_MAX_RETRY_DELAY_MS = 5_000; +const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'; + +// 429 handled separately; only retryable if a cool-off is stated. +function isRetryableGeminiStatus(status: number) { + return status === 408 || status === 500 || status === 502 || status === 503 || status === 504 || status === 524; +} + +function defaultRetryDelayMs(attempt: number) { + return Math.pow(2, attempt) * 800 + Math.random() * 400; +} + +function retryAfterDelayMs(value: string | null) { + if (!value) return null; + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) { + return seconds * 1000; + } + + const dateMs = Date.parse(value); + if (Number.isFinite(dateMs)) { + return Math.max(0, dateMs - Date.now()); + } + + return null; +} + +function requestedRetryDelayFromBody(message: string): number | null { + const match = /retry in ([\d.]+)s/i.exec(message); + if (!match) return null; + const seconds = Number(match[1]); + return Number.isFinite(seconds) ? seconds * 1000 : null; +} + +export function classifySchemaRejection(status: number, message: string): 'confident' | 'catchall' | null { + if (status !== 400) return null; + const lower = message.toLowerCase(); + + const namesTheGrammar = + lower.includes('responsejsonschema') || + lower.includes('response_json_schema') || + lower.includes('responseschema') || + lower.includes('response_schema') || + lower.includes('invalid json payload') || + lower.includes('unknown name') || + lower.includes('schema'); + if (namesTheGrammar) return 'confident'; + + const grammarAdjacent = + lower.includes('generation_config') || + lower.includes('generationconfig') || + lower.includes('json') || + lower.includes('constrained') || + lower.includes('too many states'); + if (lower.includes('invalid argument') && grammarAdjacent) return 'catchall'; + + return null; +} + +function isRetryableTransportError(error: unknown) { + if (!(error instanceof Error)) return false; + // Skip retrying timeouts (caller already grants up to 2m); defer to fallback chain. + if (error.name === 'TimeoutError' || error.message.toLowerCase().includes('timed out')) return false; + if (error.message.includes('fetch failed')) return true; + return error instanceof TypeError; +} + +export async function reviewWithGoogle( + config: { apiKey: string; baseUrl?: string | null; providerName?: string; timeoutMs?: number }, + model: string, + input: ModelInput, + tracker?: { incrementSubrequests(count?: number): void }, +): Promise { + const timeoutMs = config.timeoutMs ?? GEMINI_TIMEOUT_MS; + logger.info(`Calling Google model: ${model}`); + + assertPublicBaseUrl(config.baseUrl, config.providerName ?? 'Google'); + const prompts = jsonOnlyPrompts(input); + const responseJsonSchema = input.responseSchema + ? toGeminiResponseJsonSchema(input.responseSchema.schema) + : null; + let schemaRejected = false; + let schemaRejectionBranch: 'confident' | 'catchall' = 'confident'; + let thinkingRejected = false; + + const answerBudget = resolveOutputTokenCeiling( + input.outputBudgetTokens, + GEMINI_MAX_OUTPUT_TOKENS, + GEMINI_DEFAULT_OUTPUT_TOKENS, + ); + const thinkingBudget = geminiThinkingBudgetTokens(answerBudget); + let currentCeiling = Math.min(GEMINI_MAX_OUTPUT_TOKENS, answerBudget + thinkingBudget); + let ceilingRaised = false; + const fail = (error: unknown): never => { + // Confident rejections only: a probe that failed anyway proves nothing, and latching would strip the schema from every later call in the job. A successful probe latches via `degraded` instead. + if (schemaRejected && schemaRejectionBranch === 'confident' && typeof error === 'object' && error !== null) { + Object.defineProperty(error, 'schemaDropped', { value: true, configurable: true }); + } + throw error; + }; + + const startTime = Date.now(); + let baseUrl = config.baseUrl || DEFAULT_GEMINI_BASE_URL; + while (baseUrl.endsWith('/')) { + baseUrl = baseUrl.slice(0, -1); + } + const url = `${baseUrl}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(config.apiKey)}`; + const maxRetries = GEMINI_MAX_RETRIES; + let lastError: unknown; + let delayBeforeAttemptMs = 0; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (delayBeforeAttemptMs > 0) { + logger.info(`Retrying Gemini request (attempt ${attempt}/${maxRetries}) in ${Math.round(delayBeforeAttemptMs)}ms`); + await new Promise(resolve => setTimeout(resolve, delayBeforeAttemptMs)); + delayBeforeAttemptMs = 0; + } + + let response: Response; + try { + if (tracker) tracker.incrementSubrequests(1); + response = await withTimeout('Gemini API', timeoutMs, (signal) => + fetch(url, { + method: 'POST', + signal, + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + systemInstruction: { + role: 'system', + parts: [{ text: prompts.system }], + }, + contents: [ + { role: 'user', parts: [{ text: prompts.user }] }, + ], + generationConfig: { + responseMimeType: 'application/json', + ...(responseJsonSchema && !schemaRejected ? { responseJsonSchema } : {}), + maxOutputTokens: currentCeiling, + ...(thinkingRejected ? {} : { thinkingConfig: { thinkingBudget } }), + // Gemini's temperature scale is 0-2, not 0-1. + temperature: 0.9, + }, + }), + }), + ); + } catch (error) { + lastError = error; + if (isRetryableTransportError(error) && attempt < maxRetries) { + delayBeforeAttemptMs = defaultRetryDelayMs(attempt); + continue; + } + return fail(error); + } + + if (!response.ok) { + const errorText = await response.text(); + const message = providerErrorMessage(errorText); + + // Check thinking rejection first; isSchemaRejection below is broad. + if (!thinkingRejected && isThinkingRejection(response.status, message)) { + thinkingRejected = true; + logger.warn('Gemini rejected thinkingConfig; retrying without an explicit thinking budget', { + model, + error: message, + }); + lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message); + attempt--; + continue; + } + + const schemaRejection = responseJsonSchema && !schemaRejected + ? classifySchemaRejection(response.status, message) + : null; + if (schemaRejection) { + schemaRejected = true; + schemaRejectionBranch = schemaRejection; + // Inferred from message; real cause surfaces below if 400 recurs. + logger.warn('Gemini returned a 400 that looks like a response-grammar rejection; retrying without constrained decoding', { + model, + branch: schemaRejection, + error: message, + }); + lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message); + attempt--; + continue; + } + + // Unexplained invalid-argument 400: strip optional features one at a time -- grammar, then thinking budget -- refunding the attempt each time. The latches bound this ladder to two extra probes. + if (response.status === 400 && /invalid argument/i.test(message)) { + if (responseJsonSchema && !schemaRejected) { + schemaRejected = true; + schemaRejectionBranch = 'catchall'; + logger.warn('Gemini returned an unexplained 400; probing without constrained decoding', { + model, + error: message, + }); + lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message); + attempt--; + continue; + } + if (!thinkingRejected) { + thinkingRejected = true; + logger.warn('Gemini returned an unexplained 400 with the grammar already off; probing without an explicit thinking budget', { + model, + error: message, + }); + lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message); + attempt--; + continue; + } + } + + const requestedDelayMs = response.status === 429 + ? retryAfterDelayMs(response.headers.get('retry-after')) ?? requestedRetryDelayFromBody(message) + : null; + // Unstated 429s back off ~60s, making them unretryable here; retry only short, stated cool-offs. + const isRetryable = response.status === 429 + ? requestedDelayMs !== null && requestedDelayMs <= GEMINI_MAX_RETRY_DELAY_MS + : isRetryableGeminiStatus(response.status); + const retryDelayMs = Math.min( + GEMINI_MAX_RETRY_DELAY_MS, + requestedDelayMs ?? defaultRetryDelayMs(attempt), + ); + + const logData = { + error: message, + attempt, + willRetry: isRetryable && attempt < maxRetries, + requestedDelayMs: requestedDelayMs ?? undefined, + retryDelayMs: isRetryable && attempt < maxRetries ? retryDelayMs : undefined, + rawBody: response.status >= 400 && response.status < 500 && !(isRetryable && attempt < maxRetries) + ? errorText.slice(0, 2_000) + : undefined, + }; + if (isRetryable && attempt < maxRetries) { + logger.warn(`Gemini request failed with ${response.status}; retrying`, logData); + lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message); + delayBeforeAttemptMs = retryDelayMs; + continue; + } + + logger.error(`Gemini request failed with ${response.status}`, logData); + return fail(new ProviderRequestError(config.providerName ?? 'Google', response.status, message)); + } + + const durationMs = Date.now() - startTime; + logger.info(`AI model ${model} responded in ${durationMs}ms`); + + const data = (await response.json()) as { + candidates?: Array<{ content?: { parts?: Array<{ text?: string }> }; finishReason?: string }>; + usageMetadata?: { + promptTokenCount?: number; + candidatesTokenCount?: number; + // Billed against maxOutputTokens. + thoughtsTokenCount?: number; + }; + }; + + const candidate = data.candidates?.[0]; + const rawText = candidate?.content?.parts?.map((part) => part.text ?? '').join('')?.trim(); + const finishReason = candidate?.finishReason; + const truncated = finishReason === 'MAX_TOKENS'; + + if (finishReason && finishReason !== 'STOP') { + logger.warn(`Gemini response for ${model} ended with finishReason=${finishReason}; output is likely incomplete`, { + // Avoid a `Tokens` key name; the logger redacts it. + outputSpend: (data.usageMetadata?.candidatesTokenCount ?? 0) + (data.usageMetadata?.thoughtsTokenCount ?? 0), + thoughtSpend: data.usageMetadata?.thoughtsTokenCount ?? 0, + outputCeiling: currentCeiling, + thinkingBudget: thinkingRejected ? undefined : thinkingBudget, + schemaDropped: schemaRejected, + }); + } + + if (truncated && input.truncationIntolerant && !ceilingRaised) { + const elapsed = Date.now() - startTime; + const raisedCeiling = Math.min(GEMINI_MAX_OUTPUT_TOKENS, 2 * answerBudget + thinkingBudget); + const extraMs = ((raisedCeiling - currentCeiling) / 1_000) * MODEL_TIMEOUT_PER_1K_OUTPUT_MS; + + if (elapsed + extraMs < timeoutMs) { + ceilingRaised = true; + currentCeiling = raisedCeiling; + logger.warn(`Gemini ran out of output room on ${model}; resending once with a larger ceiling`, { + outputCeiling: raisedCeiling, + thoughtSpend: data.usageMetadata?.thoughtsTokenCount ?? 0, + hadPartialText: Boolean(rawText), + }); + attempt--; + continue; + } + } + + if (!rawText) { + // Non-STOP finish fails permanently; empty STOP is transient. + if (finishReason && finishReason !== 'STOP') { + return fail(new UnparseableModelResponseError(model, `finishReason=${finishReason}`)); + } + return fail(new Error('Gemini returned an empty response.')); + } + + // Attach partial text so a later fallback model can salvage it. + if (truncated && input.truncationIntolerant) { + const error = new UnparseableModelResponseError(model, 'finishReason=MAX_TOKENS'); + attachPartialResponse(error, { + rawText, + inputTokens: data.usageMetadata?.promptTokenCount ?? 0, + outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0, + modelUsed: model, + provider: config.providerName ?? 'Google', + }); + return fail(error); + } + + return { + rawText, + inputTokens: data.usageMetadata?.promptTokenCount ?? 0, + outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0, + modelUsed: model, + provider: config.providerName ?? 'Google', + ...(schemaRejected + ? { degraded: schemaRejectionBranch === 'catchall' + ? ('schema-dropped-catchall' as const) + : ('schema-dropped' as const) } + : {}), + }; + } + + return fail(lastError); +} diff --git a/packages/models/src/providers/openai.ts b/packages/models/src/providers/openai.ts index 4c5e1a85..c218fa18 100644 --- a/packages/models/src/providers/openai.ts +++ b/packages/models/src/providers/openai.ts @@ -1,103 +1,103 @@ -import { logger } from '@codraoss/core/logger'; -import { withTimeout } from '@codraoss/core/timeout'; -import { ProviderRequestError, providerErrorMessage, jsonOnlyPrompts, type ModelResponse } from '../types'; -import { assertPublicBaseUrl } from '../url-guard'; -import { MODEL_TIMEOUT_MAX_MS, resolveOutputTokenCeiling } from '../limits'; - -// Fallback when the caller supplies no diff-size-aware budget. Shares the review ceiling so an -// omitting caller can never outlast the chain budget that governs everything else. -const OPENAI_TIMEOUT_MS = MODEL_TIMEOUT_MAX_MS; -const OPENAI_DEFAULT_OUTPUT_TOKENS = 4096; -const OPENAI_MAX_OUTPUT_TOKENS = 16_384; - -export interface OpenAIResponse { - choices?: Array<{ - message?: { - content?: string | Array<{ text?: string }>; - }; - }>; - output_text?: string; - usage?: { - prompt_tokens?: number; - completion_tokens?: number; - input_tokens?: number; - output_tokens?: number; - }; -} - -function extractOpenAiText(data: OpenAIResponse) { - const messageContent = data?.choices?.[0]?.message?.content; - if (typeof messageContent === 'string') return messageContent.trim(); - if (Array.isArray(messageContent)) { - return messageContent.map((part) => typeof part?.text === 'string' ? part.text : '').join('').trim(); - } - const outputText = data?.output_text; - if (typeof outputText === 'string') return outputText.trim(); - return ''; -} - -export async function reviewWithOpenAI( - config: { apiKey: string | null; baseUrl: string; providerName: string; timeoutMs?: number }, - model: string, - input: { systemPrompt: string; userPrompt: string; outputBudgetTokens?: number }, - tracker?: { incrementSubrequests(count?: number): void }, -): Promise { - logger.info(`Calling OpenAI-format model: ${model}`); - const timeoutMs = config.timeoutMs ?? OPENAI_TIMEOUT_MS; - const outputCeiling = resolveOutputTokenCeiling( - input.outputBudgetTokens, - OPENAI_MAX_OUTPUT_TOKENS, - OPENAI_DEFAULT_OUTPUT_TOKENS, - ); - - assertPublicBaseUrl(config.baseUrl, config.providerName); - const prompts = jsonOnlyPrompts(input); - - let baseUrl = config.baseUrl; - while (baseUrl.endsWith('/')) { - baseUrl = baseUrl.slice(0, -1); - } - const url = `${baseUrl}/chat/completions`; - - if (tracker) tracker.incrementSubrequests(1); - const response = await withTimeout('OpenAI API', timeoutMs, (signal) => - fetch(url, { - method: 'POST', - signal, - headers: { - 'content-type': 'application/json', - ...(config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {}), - }, - body: JSON.stringify({ - model, - messages: [ - { role: 'system', content: prompts.system }, - { role: 'user', content: prompts.user }, - ], - // 0.9 of a 0-2 scale. - temperature: 0.9, - max_tokens: outputCeiling, - response_format: { type: 'json_object' }, - }), - }), - ); - - if (!response.ok) { - const errorText = await response.text(); - throw new ProviderRequestError(config.providerName, response.status, providerErrorMessage(errorText)); - } - - const data = await response.json() as OpenAIResponse; - const rawText = extractOpenAiText(data); - if (!rawText) { - throw new Error('OpenAI provider returned an empty response.'); - } - - return { - rawText, - inputTokens: data?.usage?.prompt_tokens ?? data?.usage?.input_tokens ?? 0, - outputTokens: data?.usage?.completion_tokens ?? data?.usage?.output_tokens ?? 0, - modelUsed: model, - provider: config.providerName, - }; -} +import { logger } from '@codraoss/core/logger'; +import { withTimeout } from '@codraoss/core/timeout'; +import { ProviderRequestError, providerErrorMessage, jsonOnlyPrompts, type ModelResponse } from '../types'; +import { assertPublicBaseUrl } from '../url-guard'; +import { MODEL_TIMEOUT_MAX_MS, resolveOutputTokenCeiling } from '../limits'; + +// Fallback when the caller supplies no diff-size-aware budget. Shares the review ceiling so an +// omitting caller can never outlast the chain budget that governs everything else. +const OPENAI_TIMEOUT_MS = MODEL_TIMEOUT_MAX_MS; +const OPENAI_DEFAULT_OUTPUT_TOKENS = 4096; +const OPENAI_MAX_OUTPUT_TOKENS = 16_384; + +export interface OpenAIResponse { + choices?: Array<{ + message?: { + content?: string | Array<{ text?: string }>; + }; + }>; + output_text?: string; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + input_tokens?: number; + output_tokens?: number; + }; +} + +function extractOpenAiText(data: OpenAIResponse) { + const messageContent = data?.choices?.[0]?.message?.content; + if (typeof messageContent === 'string') return messageContent.trim(); + if (Array.isArray(messageContent)) { + return messageContent.map((part) => typeof part?.text === 'string' ? part.text : '').join('').trim(); + } + const outputText = data?.output_text; + if (typeof outputText === 'string') return outputText.trim(); + return ''; +} + +export async function reviewWithOpenAI( + config: { apiKey: string | null; baseUrl: string; providerName: string; timeoutMs?: number }, + model: string, + input: { systemPrompt: string; userPrompt: string; outputBudgetTokens?: number }, + tracker?: { incrementSubrequests(count?: number): void }, +): Promise { + logger.info(`Calling OpenAI-format model: ${model}`); + const timeoutMs = config.timeoutMs ?? OPENAI_TIMEOUT_MS; + const outputCeiling = resolveOutputTokenCeiling( + input.outputBudgetTokens, + OPENAI_MAX_OUTPUT_TOKENS, + OPENAI_DEFAULT_OUTPUT_TOKENS, + ); + + assertPublicBaseUrl(config.baseUrl, config.providerName); + const prompts = jsonOnlyPrompts(input); + + let baseUrl = config.baseUrl; + while (baseUrl.endsWith('/')) { + baseUrl = baseUrl.slice(0, -1); + } + const url = `${baseUrl}/chat/completions`; + + if (tracker) tracker.incrementSubrequests(1); + const response = await withTimeout('OpenAI API', timeoutMs, (signal) => + fetch(url, { + method: 'POST', + signal, + headers: { + 'content-type': 'application/json', + ...(config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {}), + }, + body: JSON.stringify({ + model, + messages: [ + { role: 'system', content: prompts.system }, + { role: 'user', content: prompts.user }, + ], + // 0.9 of a 0-2 scale. + temperature: 0.9, + max_tokens: outputCeiling, + response_format: { type: 'json_object' }, + }), + }), + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new ProviderRequestError(config.providerName, response.status, providerErrorMessage(errorText)); + } + + const data = await response.json() as OpenAIResponse; + const rawText = extractOpenAiText(data); + if (!rawText) { + throw new Error('OpenAI provider returned an empty response.'); + } + + return { + rawText, + inputTokens: data?.usage?.prompt_tokens ?? data?.usage?.input_tokens ?? 0, + outputTokens: data?.usage?.completion_tokens ?? data?.usage?.output_tokens ?? 0, + modelUsed: model, + provider: config.providerName, + }; +} diff --git a/packages/models/src/providers/vertex.ts b/packages/models/src/providers/vertex.ts index 3dd77bcb..40f1d21c 100644 --- a/packages/models/src/providers/vertex.ts +++ b/packages/models/src/providers/vertex.ts @@ -1,313 +1,313 @@ -import { logger } from '@codraoss/core/logger'; -import { withTimeout } from '@codraoss/core/timeout'; -import { ProviderRequestError, UnparseableModelResponseError, providerErrorMessage, jsonOnlyPrompts, isThinkingRejection, attachPartialResponse, type ModelResponse } from '../types'; -import { assertPublicBaseUrl } from '../url-guard'; -import { - MODEL_TIMEOUT_MAX_MS, - MODEL_TIMEOUT_PER_1K_OUTPUT_MS, - OUTPUT_TOKENS_FLOOR, - geminiThinkingBudgetTokens, - resolveOutputTokenCeiling, -} from '../limits'; - -// Vertex's REST API rejects plain API keys and requires an OAuth2 token via RFC 7523 JWT-bearer grant, so `apiKey` here holds the full service-account JSON key, not a short API key string. -const VERTEX_TIMEOUT_MS = MODEL_TIMEOUT_MAX_MS; -const VERTEX_DEFAULT_OUTPUT_TOKENS = OUTPUT_TOKENS_FLOOR; -// Same Gemini models as the Google adapter, so the same ceiling and thinkingConfig (unbounded reasoning -// would otherwise consume the whole ceiling and leave a truncated answer). -const VERTEX_MAX_OUTPUT_TOKENS = 65_536; -// Retries for a 429 only, and only while the caller's own timeout still has room. See the loop below -// for why resending an unchanged request is the correct response to this particular refusal. -const VERTEX_QUOTA_RETRIES = 2; -const VERTEX_QUOTA_BACKOFF_MS = 4_000; -// Room a resend needs to be worth starting at all; a Vertex 429 itself comes back in ~7s. -const VERTEX_MIN_ATTEMPT_MS = 8_000; -const OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token'; -const OAUTH_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'; -const ACCESS_TOKEN_LIFETIME_S = 3600; -// Refresh before real expiry so an in-flight review never starts a call with a token that expires mid-request. -const TOKEN_REFRESH_MARGIN_MS = 60_000; - -interface VertexGenerateResponse { - candidates?: Array<{ content?: { parts?: Array<{ text?: string }> }; finishReason?: string }>; - usageMetadata?: { - promptTokenCount?: number; - candidatesTokenCount?: number; - thoughtsTokenCount?: number; // billed against maxOutputTokens - }; -} - -interface ServiceAccountKey { - client_email: string; - private_key: string; -} - -interface CachedToken { - accessToken: string; - expiresAt: number; -} - -// Per-isolate cache, not per-request: saves a token mint (and a subrequest) on every file review after the first to hit a warm isolate. -const tokenCache = new Map(); - -function parseServiceAccountKey(raw: string): ServiceAccountKey { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - throw new Error('Vertex AI credentials must be the full service-account JSON key (paste the downloaded .json file contents), not an API key.'); - } - - const obj = parsed as Partial | null; - if (!obj || typeof obj.client_email !== 'string' || typeof obj.private_key !== 'string') { - throw new Error('Vertex AI service-account JSON is missing client_email or private_key.'); - } - return { client_email: obj.client_email, private_key: obj.private_key }; -} - -function base64Url(bytes: Uint8Array) { - return Buffer.from(bytes) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); -} - -async function importPrivateKey(pem: string) { - const der = Buffer.from( - pem.replace(/-----BEGIN PRIVATE KEY-----/, '').replace(/-----END PRIVATE KEY-----/, '').replace(/\s+/g, ''), - 'base64', - ); - return crypto.subtle.importKey('pkcs8', der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']); -} - -async function mintAccessToken(serviceAccount: ServiceAccountKey): Promise { - const nowSeconds = Math.floor(Date.now() / 1000); - const encoder = new TextEncoder(); - const header = base64Url(encoder.encode(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))); - const claimSet = base64Url(encoder.encode(JSON.stringify({ - iss: serviceAccount.client_email, - scope: OAUTH_SCOPE, - aud: OAUTH_TOKEN_URL, - iat: nowSeconds, - exp: nowSeconds + ACCESS_TOKEN_LIFETIME_S, - }))); - const signingInput = `${header}.${claimSet}`; - - const key = await importPrivateKey(serviceAccount.private_key); - const signature = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, encoder.encode(signingInput)); - const assertion = `${signingInput}.${base64Url(new Uint8Array(signature))}`; - - const response = await withTimeout('Google OAuth token', 10_000, (signal) => - fetch(OAUTH_TOKEN_URL, { - method: 'POST', - signal, - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', - assertion, - }), - }), - ); - - if (!response.ok) { - const message = providerErrorMessage(await response.text()); - throw new ProviderRequestError('Google Vertex AI', response.status, `Could not mint an access token for the service account -- check that the JSON key is valid and the Vertex AI API is enabled (${message})`); - } - - const data = (await response.json()) as { access_token?: string; expires_in?: number }; - if (!data.access_token) throw new Error('Google OAuth token endpoint returned no access_token.'); - - return { - accessToken: data.access_token, - expiresAt: Date.now() + (data.expires_in ?? ACCESS_TOKEN_LIFETIME_S) * 1000, - }; -} - -async function getAccessToken( - serviceAccount: ServiceAccountKey, - tracker?: { incrementSubrequests(count?: number): void }, -) { - const cached = tokenCache.get(serviceAccount.client_email); - if (cached && cached.expiresAt - TOKEN_REFRESH_MARGIN_MS > Date.now()) { - return cached.accessToken; - } - - if (tracker) tracker.incrementSubrequests(1); - const token = await mintAccessToken(serviceAccount); - tokenCache.set(serviceAccount.client_email, token); - return token.accessToken; -} - -export async function reviewWithVertex( - config: { apiKey: string; baseUrl?: string | null; providerName?: string; timeoutMs?: number }, - model: string, - input: { systemPrompt: string; userPrompt: string; outputBudgetTokens?: number; truncationIntolerant?: boolean }, - tracker?: { incrementSubrequests(count?: number): void }, -): Promise { - const providerName = config.providerName ?? 'Google Vertex AI'; - const timeoutMs = config.timeoutMs ?? VERTEX_TIMEOUT_MS; - const answerBudget = resolveOutputTokenCeiling( - input.outputBudgetTokens, - VERTEX_MAX_OUTPUT_TOKENS, - VERTEX_DEFAULT_OUTPUT_TOKENS, - ); - const thinkingBudget = geminiThinkingBudgetTokens(answerBudget); - let currentCeiling = Math.min(VERTEX_MAX_OUTPUT_TOKENS, answerBudget + thinkingBudget); - let ceilingRaised = false; - logger.info(`Calling Vertex AI model: ${model}`); - - assertPublicBaseUrl(config.baseUrl, providerName); - if (!config.baseUrl) { - throw new ProviderRequestError( - providerName, - 400, - 'Vertex AI requires a base URL with your project and region, e.g. https://us-central1-aiplatform.googleapis.com/v1/projects/YOUR_PROJECT_ID/locations/us-central1', - ); - } - - const serviceAccount = parseServiceAccountKey(config.apiKey); - const accessToken = await getAccessToken(serviceAccount, tracker); - const prompts = jsonOnlyPrompts(input); - - const startTime = Date.now(); - let baseUrl = config.baseUrl; - while (baseUrl.endsWith('/')) { - baseUrl = baseUrl.slice(0, -1); - } - const url = `${baseUrl}/publishers/google/models/${encodeURIComponent(model)}:generateContent`; - - const buildBody = (includeThinking: boolean, ceiling: number) => JSON.stringify({ - systemInstruction: { - role: 'system', - parts: [{ text: prompts.system }], - }, - contents: [ - { role: 'user', parts: [{ text: prompts.user }] }, - ], - generationConfig: { - responseMimeType: 'application/json', - // No `responseJsonSchema`: this adapter cannot drop a schema mid-flight, so a rejection would fail the file outright. - maxOutputTokens: ceiling, - ...(includeThinking ? { thinkingConfig: { thinkingBudget } } : {}), - // Same models as the Google adapter, so the same value keeps the two paths comparable. - temperature: 0.9, - }, - }); - - const attempt = (body: string) => - withTimeout('Vertex AI', timeoutMs, (signal) => - fetch(url, { - method: 'POST', - signal, - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${accessToken}`, - }, - body, - }), - ); - - let thinkingRejected = false; - let response: Response; - let data: VertexGenerateResponse; - let rawText: string | undefined; - let finishReason: string | undefined; - - for (;;) { - const body = buildBody(!thinkingRejected, currentCeiling); - - if (tracker) tracker.incrementSubrequests(1); - response = await attempt(body); - - // A Vertex 429 here is queueing, not a rate bucket: resending the identical request works (~3/4 of - // ~900 sampled calls). Bounded by the caller's timeout, already clamped to the fallback-chain budget. - for (let retry = 0; retry < VERTEX_QUOTA_RETRIES && response.status === 429; retry++) { - const waitMs = VERTEX_QUOTA_BACKOFF_MS * (retry + 1); - if (Date.now() - startTime + waitMs + VERTEX_MIN_ATTEMPT_MS > timeoutMs) break; - - logger.warn(`Vertex AI refused with 429; resending unchanged in ${waitMs}ms`, { model, retry: retry + 1 }); - await new Promise((resolve) => setTimeout(resolve, waitMs)); - if (tracker) tracker.incrementSubrequests(1); - response = await attempt(body); - } - - if (response.ok) { - data = (await response.json()) as VertexGenerateResponse; - const candidate = data.candidates?.[0]; - rawText = candidate?.content?.parts?.map((part) => part.text ?? '').join('')?.trim(); - finishReason = candidate?.finishReason; - - if (finishReason && finishReason !== 'STOP') { - logger.warn(`Vertex AI response for ${model} ended with finishReason=${finishReason}; output is likely incomplete`, { - outputSpend: (data.usageMetadata?.candidatesTokenCount ?? 0) + (data.usageMetadata?.thoughtsTokenCount ?? 0), - thoughtSpend: data.usageMetadata?.thoughtsTokenCount ?? 0, - outputCeiling: currentCeiling, - thinkingBudget: thinkingRejected ? undefined : thinkingBudget, - }); - } - - // thinkingConfig stays on here: dropping it switches to unbounded dynamic thinking, the opposite of the fix. - if (finishReason === 'MAX_TOKENS' && input.truncationIntolerant && !ceilingRaised) { - const raisedCeiling = Math.min(VERTEX_MAX_OUTPUT_TOKENS, 2 * answerBudget + thinkingBudget); - const extraMs = ((raisedCeiling - currentCeiling) / 1_000) * MODEL_TIMEOUT_PER_1K_OUTPUT_MS; - if (Date.now() - startTime + extraMs < timeoutMs) { - ceilingRaised = true; - currentCeiling = raisedCeiling; - logger.warn(`Vertex AI ran out of output room on ${model}; resending once with a larger ceiling`, { - outputCeiling: raisedCeiling, - thoughtSpend: data.usageMetadata?.thoughtsTokenCount ?? 0, - hadPartialText: Boolean(rawText), - }); - continue; - } - } - - break; - } - - const message = providerErrorMessage(await response.text()); - - if (!thinkingRejected && isThinkingRejection(response.status, message)) { - thinkingRejected = true; - logger.warn('Vertex AI rejected thinkingConfig; resending without an explicit thinking budget', { - model, - error: message, - }); - continue; - } - - throw new ProviderRequestError(providerName, response.status, message); - } - - const durationMs = Date.now() - startTime; - logger.info(`AI model ${model} responded in ${durationMs}ms`); - - if (!rawText) { - if (finishReason && finishReason !== 'STOP') { - throw new UnparseableModelResponseError(model, `finishReason=${finishReason}`); - } - throw new Error('Vertex AI returned an empty response.'); - } - - // Still truncated after the re-probe; fail but attach the partial text so the chain's last model can salvage it. - if (finishReason === 'MAX_TOKENS' && input.truncationIntolerant) { - const error = new UnparseableModelResponseError(model, 'finishReason=MAX_TOKENS'); - attachPartialResponse(error, { - rawText, - inputTokens: data.usageMetadata?.promptTokenCount ?? 0, - outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0, - modelUsed: model, - provider: providerName, - }); - throw error; - } - - return { - rawText, - inputTokens: data.usageMetadata?.promptTokenCount ?? 0, - outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0, - modelUsed: model, - provider: providerName, - }; -} +import { logger } from '@codraoss/core/logger'; +import { withTimeout } from '@codraoss/core/timeout'; +import { ProviderRequestError, UnparseableModelResponseError, providerErrorMessage, jsonOnlyPrompts, isThinkingRejection, attachPartialResponse, type ModelResponse } from '../types'; +import { assertPublicBaseUrl } from '../url-guard'; +import { + MODEL_TIMEOUT_MAX_MS, + MODEL_TIMEOUT_PER_1K_OUTPUT_MS, + OUTPUT_TOKENS_FLOOR, + geminiThinkingBudgetTokens, + resolveOutputTokenCeiling, +} from '../limits'; + +// Vertex's REST API rejects plain API keys and requires an OAuth2 token via RFC 7523 JWT-bearer grant, so `apiKey` here holds the full service-account JSON key, not a short API key string. +const VERTEX_TIMEOUT_MS = MODEL_TIMEOUT_MAX_MS; +const VERTEX_DEFAULT_OUTPUT_TOKENS = OUTPUT_TOKENS_FLOOR; +// Same Gemini models as the Google adapter, so the same ceiling and thinkingConfig (unbounded reasoning +// would otherwise consume the whole ceiling and leave a truncated answer). +const VERTEX_MAX_OUTPUT_TOKENS = 65_536; +// Retries for a 429 only, and only while the caller's own timeout still has room. See the loop below +// for why resending an unchanged request is the correct response to this particular refusal. +const VERTEX_QUOTA_RETRIES = 2; +const VERTEX_QUOTA_BACKOFF_MS = 4_000; +// Room a resend needs to be worth starting at all; a Vertex 429 itself comes back in ~7s. +const VERTEX_MIN_ATTEMPT_MS = 8_000; +const OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const OAUTH_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'; +const ACCESS_TOKEN_LIFETIME_S = 3600; +// Refresh before real expiry so an in-flight review never starts a call with a token that expires mid-request. +const TOKEN_REFRESH_MARGIN_MS = 60_000; + +interface VertexGenerateResponse { + candidates?: Array<{ content?: { parts?: Array<{ text?: string }> }; finishReason?: string }>; + usageMetadata?: { + promptTokenCount?: number; + candidatesTokenCount?: number; + thoughtsTokenCount?: number; // billed against maxOutputTokens + }; +} + +interface ServiceAccountKey { + client_email: string; + private_key: string; +} + +interface CachedToken { + accessToken: string; + expiresAt: number; +} + +// Per-isolate cache, not per-request: saves a token mint (and a subrequest) on every file review after the first to hit a warm isolate. +const tokenCache = new Map(); + +function parseServiceAccountKey(raw: string): ServiceAccountKey { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error('Vertex AI credentials must be the full service-account JSON key (paste the downloaded .json file contents), not an API key.'); + } + + const obj = parsed as Partial | null; + if (!obj || typeof obj.client_email !== 'string' || typeof obj.private_key !== 'string') { + throw new Error('Vertex AI service-account JSON is missing client_email or private_key.'); + } + return { client_email: obj.client_email, private_key: obj.private_key }; +} + +function base64Url(bytes: Uint8Array) { + return Buffer.from(bytes) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +async function importPrivateKey(pem: string) { + const der = Buffer.from( + pem.replace(/-----BEGIN PRIVATE KEY-----/, '').replace(/-----END PRIVATE KEY-----/, '').replace(/\s+/g, ''), + 'base64', + ); + return crypto.subtle.importKey('pkcs8', der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']); +} + +async function mintAccessToken(serviceAccount: ServiceAccountKey): Promise { + const nowSeconds = Math.floor(Date.now() / 1000); + const encoder = new TextEncoder(); + const header = base64Url(encoder.encode(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))); + const claimSet = base64Url(encoder.encode(JSON.stringify({ + iss: serviceAccount.client_email, + scope: OAUTH_SCOPE, + aud: OAUTH_TOKEN_URL, + iat: nowSeconds, + exp: nowSeconds + ACCESS_TOKEN_LIFETIME_S, + }))); + const signingInput = `${header}.${claimSet}`; + + const key = await importPrivateKey(serviceAccount.private_key); + const signature = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, encoder.encode(signingInput)); + const assertion = `${signingInput}.${base64Url(new Uint8Array(signature))}`; + + const response = await withTimeout('Google OAuth token', 10_000, (signal) => + fetch(OAUTH_TOKEN_URL, { + method: 'POST', + signal, + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion, + }), + }), + ); + + if (!response.ok) { + const message = providerErrorMessage(await response.text()); + throw new ProviderRequestError('Google Vertex AI', response.status, `Could not mint an access token for the service account -- check that the JSON key is valid and the Vertex AI API is enabled (${message})`); + } + + const data = (await response.json()) as { access_token?: string; expires_in?: number }; + if (!data.access_token) throw new Error('Google OAuth token endpoint returned no access_token.'); + + return { + accessToken: data.access_token, + expiresAt: Date.now() + (data.expires_in ?? ACCESS_TOKEN_LIFETIME_S) * 1000, + }; +} + +async function getAccessToken( + serviceAccount: ServiceAccountKey, + tracker?: { incrementSubrequests(count?: number): void }, +) { + const cached = tokenCache.get(serviceAccount.client_email); + if (cached && cached.expiresAt - TOKEN_REFRESH_MARGIN_MS > Date.now()) { + return cached.accessToken; + } + + if (tracker) tracker.incrementSubrequests(1); + const token = await mintAccessToken(serviceAccount); + tokenCache.set(serviceAccount.client_email, token); + return token.accessToken; +} + +export async function reviewWithVertex( + config: { apiKey: string; baseUrl?: string | null; providerName?: string; timeoutMs?: number }, + model: string, + input: { systemPrompt: string; userPrompt: string; outputBudgetTokens?: number; truncationIntolerant?: boolean }, + tracker?: { incrementSubrequests(count?: number): void }, +): Promise { + const providerName = config.providerName ?? 'Google Vertex AI'; + const timeoutMs = config.timeoutMs ?? VERTEX_TIMEOUT_MS; + const answerBudget = resolveOutputTokenCeiling( + input.outputBudgetTokens, + VERTEX_MAX_OUTPUT_TOKENS, + VERTEX_DEFAULT_OUTPUT_TOKENS, + ); + const thinkingBudget = geminiThinkingBudgetTokens(answerBudget); + let currentCeiling = Math.min(VERTEX_MAX_OUTPUT_TOKENS, answerBudget + thinkingBudget); + let ceilingRaised = false; + logger.info(`Calling Vertex AI model: ${model}`); + + assertPublicBaseUrl(config.baseUrl, providerName); + if (!config.baseUrl) { + throw new ProviderRequestError( + providerName, + 400, + 'Vertex AI requires a base URL with your project and region, e.g. https://us-central1-aiplatform.googleapis.com/v1/projects/YOUR_PROJECT_ID/locations/us-central1', + ); + } + + const serviceAccount = parseServiceAccountKey(config.apiKey); + const accessToken = await getAccessToken(serviceAccount, tracker); + const prompts = jsonOnlyPrompts(input); + + const startTime = Date.now(); + let baseUrl = config.baseUrl; + while (baseUrl.endsWith('/')) { + baseUrl = baseUrl.slice(0, -1); + } + const url = `${baseUrl}/publishers/google/models/${encodeURIComponent(model)}:generateContent`; + + const buildBody = (includeThinking: boolean, ceiling: number) => JSON.stringify({ + systemInstruction: { + role: 'system', + parts: [{ text: prompts.system }], + }, + contents: [ + { role: 'user', parts: [{ text: prompts.user }] }, + ], + generationConfig: { + responseMimeType: 'application/json', + // No `responseJsonSchema`: this adapter cannot drop a schema mid-flight, so a rejection would fail the file outright. + maxOutputTokens: ceiling, + ...(includeThinking ? { thinkingConfig: { thinkingBudget } } : {}), + // Same models as the Google adapter, so the same value keeps the two paths comparable. + temperature: 0.9, + }, + }); + + const attempt = (body: string) => + withTimeout('Vertex AI', timeoutMs, (signal) => + fetch(url, { + method: 'POST', + signal, + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + }, + body, + }), + ); + + let thinkingRejected = false; + let response: Response; + let data: VertexGenerateResponse; + let rawText: string | undefined; + let finishReason: string | undefined; + + for (;;) { + const body = buildBody(!thinkingRejected, currentCeiling); + + if (tracker) tracker.incrementSubrequests(1); + response = await attempt(body); + + // A Vertex 429 here is queueing, not a rate bucket: resending the identical request works (~3/4 of + // ~900 sampled calls). Bounded by the caller's timeout, already clamped to the fallback-chain budget. + for (let retry = 0; retry < VERTEX_QUOTA_RETRIES && response.status === 429; retry++) { + const waitMs = VERTEX_QUOTA_BACKOFF_MS * (retry + 1); + if (Date.now() - startTime + waitMs + VERTEX_MIN_ATTEMPT_MS > timeoutMs) break; + + logger.warn(`Vertex AI refused with 429; resending unchanged in ${waitMs}ms`, { model, retry: retry + 1 }); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + if (tracker) tracker.incrementSubrequests(1); + response = await attempt(body); + } + + if (response.ok) { + data = (await response.json()) as VertexGenerateResponse; + const candidate = data.candidates?.[0]; + rawText = candidate?.content?.parts?.map((part) => part.text ?? '').join('')?.trim(); + finishReason = candidate?.finishReason; + + if (finishReason && finishReason !== 'STOP') { + logger.warn(`Vertex AI response for ${model} ended with finishReason=${finishReason}; output is likely incomplete`, { + outputSpend: (data.usageMetadata?.candidatesTokenCount ?? 0) + (data.usageMetadata?.thoughtsTokenCount ?? 0), + thoughtSpend: data.usageMetadata?.thoughtsTokenCount ?? 0, + outputCeiling: currentCeiling, + thinkingBudget: thinkingRejected ? undefined : thinkingBudget, + }); + } + + // thinkingConfig stays on here: dropping it switches to unbounded dynamic thinking, the opposite of the fix. + if (finishReason === 'MAX_TOKENS' && input.truncationIntolerant && !ceilingRaised) { + const raisedCeiling = Math.min(VERTEX_MAX_OUTPUT_TOKENS, 2 * answerBudget + thinkingBudget); + const extraMs = ((raisedCeiling - currentCeiling) / 1_000) * MODEL_TIMEOUT_PER_1K_OUTPUT_MS; + if (Date.now() - startTime + extraMs < timeoutMs) { + ceilingRaised = true; + currentCeiling = raisedCeiling; + logger.warn(`Vertex AI ran out of output room on ${model}; resending once with a larger ceiling`, { + outputCeiling: raisedCeiling, + thoughtSpend: data.usageMetadata?.thoughtsTokenCount ?? 0, + hadPartialText: Boolean(rawText), + }); + continue; + } + } + + break; + } + + const message = providerErrorMessage(await response.text()); + + if (!thinkingRejected && isThinkingRejection(response.status, message)) { + thinkingRejected = true; + logger.warn('Vertex AI rejected thinkingConfig; resending without an explicit thinking budget', { + model, + error: message, + }); + continue; + } + + throw new ProviderRequestError(providerName, response.status, message); + } + + const durationMs = Date.now() - startTime; + logger.info(`AI model ${model} responded in ${durationMs}ms`); + + if (!rawText) { + if (finishReason && finishReason !== 'STOP') { + throw new UnparseableModelResponseError(model, `finishReason=${finishReason}`); + } + throw new Error('Vertex AI returned an empty response.'); + } + + // Still truncated after the re-probe; fail but attach the partial text so the chain's last model can salvage it. + if (finishReason === 'MAX_TOKENS' && input.truncationIntolerant) { + const error = new UnparseableModelResponseError(model, 'finishReason=MAX_TOKENS'); + attachPartialResponse(error, { + rawText, + inputTokens: data.usageMetadata?.promptTokenCount ?? 0, + outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0, + modelUsed: model, + provider: providerName, + }); + throw error; + } + + return { + rawText, + inputTokens: data.usageMetadata?.promptTokenCount ?? 0, + outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0, + modelUsed: model, + provider: providerName, + }; +} diff --git a/packages/models/test/model/batch-routing.spec.ts b/packages/models/test/model/batch-routing.spec.ts index 259809cd..012a7d28 100644 --- a/packages/models/test/model/batch-routing.spec.ts +++ b/packages/models/test/model/batch-routing.spec.ts @@ -1,188 +1,188 @@ -import { describe, expect, it } from 'vitest'; -import { parseBatchReviewResponse } from '@codraoss/core/model-output'; -import type { FileDiff } from '@codraoss/core/diff'; - -function file(path: string, contents: string[], previousPath: string | null = null): FileDiff { - return { - path, - previousPath, - isNew: false, - isDeleted: false, - isBinary: false, - lineCount: contents.length, - hunks: [{ - header: '@@ -1,10 +1,10 @@', - lines: contents.map((content, i) => ({ - kind: 'add' as const, - content, - newLineNumber: i + 1, - oldLineNumber: undefined, - position: i + 1, - })), - }], - }; -} - -function entry(path: string, evidence: string, title = 'Something is wrong') { - return { - absolute_file_path: path, - findings: [{ - evidence, - code_location: { absolute_file_path: path, line: 1 }, - claim_type: 'other', - title, - body: 'A concrete problem with a concrete impact.', - priority: 2, - }], - overall_explanation: `Summary for ${path}`, - overall_correctness: 'patch is incorrect', - }; -} - -const raw = (files: unknown[]) => JSON.stringify({ files, overall_confidence_score: 0.6 }); - -describe('parseBatchReviewResponse', () => { - it('routes each entry to its own file, and reports one the model omitted', () => { - const files = [ - file('src/a.ts', ['const alpha = computeAlpha();']), - file('src/b.ts', ['const bravo = computeBravo();']), - file('src/c.ts', ['const charlie = 3;']), - ]; - - const result = parseBatchReviewResponse( - raw([entry('src/a.ts', 'const alpha = computeAlpha();'), entry('src/b.ts', 'const bravo = computeBravo();')]), - files, - ); - - expect(result.reviews.get('src/a.ts')!.comments[0].path).toBe('src/a.ts'); - expect(result.reviews.get('src/b.ts')!.comments[0].path).toBe('src/b.ts'); - expect(result.reviews.get('src/a.ts')!.fileSummary).toContain('Summary for src/a.ts'); - // Omitted file must surface for re-queueing, not silently approved. - expect(result.missing).toEqual(['src/c.ts']); - expect(result.reviews.has('src/c.ts')).toBe(false); - }); - - // Renames matter: renderFileDiff shows the old path on the header line. - it('tolerates path noise and renames, but refuses to guess', () => { - for (const reported of ['./src/a.ts', 'a/src/a.ts', 'b/src/a.ts', '/src/a.ts', 'a.ts']) { - const result = parseBatchReviewResponse( - raw([entry(reported, 'const alpha = 1;')]), - [file('src/a.ts', ['const alpha = 1;'])], - ); - expect(result.stats.unroutableEntries).toBe(0); - expect(result.reviews.get('src/a.ts')!.comments).toHaveLength(1); - } - - const renamed = parseBatchReviewResponse( - raw([entry('src/old.ts', 'const alpha = 1;')]), - [file('src/new.ts', ['const alpha = 1;'], 'src/old.ts')], - ); - expect(renamed.reviews.get('src/new.ts')!.comments).toHaveLength(1); - - // Shared basename: guessing would misattribute findings. - const siblings = [file('src/a/index.ts', ['const alpha = 1;']), file('src/b/index.ts', ['const bravo = 2;'])]; - const ambiguous = parseBatchReviewResponse(raw([entry('index.ts', 'const alpha = 1;')]), siblings); - expect(ambiguous.stats.unroutableEntries).toBe(1); - expect(ambiguous.reviews.size).toBe(0); - - const duplicated = parseBatchReviewResponse( - raw([entry('src/a/index.ts', 'const alpha = 1;'), entry('src/a/index.ts', 'const alpha = 1;', 'Duplicate')]), - siblings, - ); - expect(duplicated.stats.unroutableEntries).toBe(1); - expect(duplicated.reviews.get('src/a/index.ts')!.comments).toHaveLength(1); - expect(duplicated.missing).toEqual(['src/b/index.ts']); - }); - - // Per-file indexes miss quotes shared across files. - it('withholds only when a shared quote AND a path disagreement coincide', () => { - const shared = '} catch (error) {'; - const files = [file('src/a.ts', [shared, 'const uniqueToAlpha = 1;']), file('src/b.ts', [shared, 'const bravo = 2;'])]; - const misfiled = (evidence: string, claimedPath: string) => raw([{ - absolute_file_path: 'src/a.ts', - findings: [{ - evidence, - code_location: { absolute_file_path: claimedPath, line: 1 }, - claim_type: 'other', - title: 'Swallowed error', - body: 'The catch block hides the failure.', - priority: 1, - }], - overall_explanation: 'Summary', - overall_correctness: 'patch is incorrect', - }]); - - const withheld = parseBatchReviewResponse(misfiled(shared, 'src/b.ts'), files); - expect(withheld.stats.ambiguousAcrossBin).toBe(1); - expect(withheld.reviews.get('src/a.ts')!.comments).toHaveLength(0); - - const agreeing = parseBatchReviewResponse(raw([entry('src/a.ts', shared, 'Swallowed error')]), files); - expect(agreeing.stats.ambiguousAcrossBin).toBe(0); - expect(agreeing.reviews.get('src/a.ts')!.comments).toHaveLength(1); - - // Unique quote + wrong path: enclosing entry still wins. - const mismatch = parseBatchReviewResponse(misfiled('const uniqueToAlpha = 1;', 'src/b.ts'), files); - expect(mismatch.stats.pathMismatchFindings).toBe(1); - expect(mismatch.reviews.get('src/a.ts')!.comments[0].path).toBe('src/a.ts'); - }); - - // Cap is per-file: a noisy file keeps its own cap while others are untouched. - it('trims over-cap findings per file and accounts for the drop', () => { - const lines = Array.from({ length: 30 }, (_, i) => `const value${i} = ${i};`); - const files = [file('src/a.ts', lines), file('src/b.ts', ['const bravo = 2;'])]; - - const noisy = { - absolute_file_path: 'src/a.ts', - findings: lines.map((line, i) => ({ - evidence: line, - code_location: { absolute_file_path: 'src/a.ts', line: i + 1 }, - claim_type: 'other', - title: `Problem number ${i}`, - body: 'A concrete problem with a concrete impact.', - priority: 2, - })), - overall_explanation: 'Many problems', - overall_correctness: 'patch is incorrect', - }; - - const result = parseBatchReviewResponse( - raw([noisy, entry('src/b.ts', 'const bravo = 2;', 'Bravo is off by one')]), - files, - { maxCommentsPerFile: 5 }, - ); - - // generatorFindingCap(5) = 10. - expect(result.reviews.get('src/a.ts')!.comments).toHaveLength(10); - expect(result.stats.overCap).toBe(20); - expect(result.reviews.get('src/a.ts')!.fileSummary).toContain('over-cap'); - expect(result.reviews.get('src/b.ts')!.comments).toHaveLength(1); - }); - - // One bad finding must not sink the rest of the batch. - it('drops an unassemblable finding without losing the rest of the bin', () => { - const files = [file('src/a.ts', ['const alpha = 1;']), file('src/b.ts', ['const bravo = 2;'])]; - - const result = parseBatchReviewResponse(raw([ - { - absolute_file_path: 'src/a.ts', - findings: [{ - evidence: 'const alpha = 1;', - code_location: { absolute_file_path: 'src/a.ts', line: 1 }, - claim_type: 'other', - // Title is a prefix of the body, so the body is stripped to nothing downstream. - title: 'Leak', - body: 'Leak', - priority: 2, - }], - overall_explanation: 'Summary', - overall_correctness: 'patch is incorrect', - }, - entry('src/b.ts', 'const bravo = 2;', 'Bravo is off by one'), - ]), files); - - expect(result.reviews.get('src/a.ts')!.comments).toHaveLength(0); - expect(result.reviews.get('src/a.ts')!.fileSummary).toContain('unassemblable'); - expect(result.reviews.get('src/b.ts')!.comments).toHaveLength(1); - }); - -}); +import { describe, expect, it } from 'vitest'; +import { parseBatchReviewResponse } from '@codraoss/core/model-output'; +import type { FileDiff } from '@codraoss/core/diff'; + +function file(path: string, contents: string[], previousPath: string | null = null): FileDiff { + return { + path, + previousPath, + isNew: false, + isDeleted: false, + isBinary: false, + lineCount: contents.length, + hunks: [{ + header: '@@ -1,10 +1,10 @@', + lines: contents.map((content, i) => ({ + kind: 'add' as const, + content, + newLineNumber: i + 1, + oldLineNumber: undefined, + position: i + 1, + })), + }], + }; +} + +function entry(path: string, evidence: string, title = 'Something is wrong') { + return { + absolute_file_path: path, + findings: [{ + evidence, + code_location: { absolute_file_path: path, line: 1 }, + claim_type: 'other', + title, + body: 'A concrete problem with a concrete impact.', + priority: 2, + }], + overall_explanation: `Summary for ${path}`, + overall_correctness: 'patch is incorrect', + }; +} + +const raw = (files: unknown[]) => JSON.stringify({ files, overall_confidence_score: 0.6 }); + +describe('parseBatchReviewResponse', () => { + it('routes each entry to its own file, and reports one the model omitted', () => { + const files = [ + file('src/a.ts', ['const alpha = computeAlpha();']), + file('src/b.ts', ['const bravo = computeBravo();']), + file('src/c.ts', ['const charlie = 3;']), + ]; + + const result = parseBatchReviewResponse( + raw([entry('src/a.ts', 'const alpha = computeAlpha();'), entry('src/b.ts', 'const bravo = computeBravo();')]), + files, + ); + + expect(result.reviews.get('src/a.ts')!.comments[0].path).toBe('src/a.ts'); + expect(result.reviews.get('src/b.ts')!.comments[0].path).toBe('src/b.ts'); + expect(result.reviews.get('src/a.ts')!.fileSummary).toContain('Summary for src/a.ts'); + // Omitted file must surface for re-queueing, not silently approved. + expect(result.missing).toEqual(['src/c.ts']); + expect(result.reviews.has('src/c.ts')).toBe(false); + }); + + // Renames matter: renderFileDiff shows the old path on the header line. + it('tolerates path noise and renames, but refuses to guess', () => { + for (const reported of ['./src/a.ts', 'a/src/a.ts', 'b/src/a.ts', '/src/a.ts', 'a.ts']) { + const result = parseBatchReviewResponse( + raw([entry(reported, 'const alpha = 1;')]), + [file('src/a.ts', ['const alpha = 1;'])], + ); + expect(result.stats.unroutableEntries).toBe(0); + expect(result.reviews.get('src/a.ts')!.comments).toHaveLength(1); + } + + const renamed = parseBatchReviewResponse( + raw([entry('src/old.ts', 'const alpha = 1;')]), + [file('src/new.ts', ['const alpha = 1;'], 'src/old.ts')], + ); + expect(renamed.reviews.get('src/new.ts')!.comments).toHaveLength(1); + + // Shared basename: guessing would misattribute findings. + const siblings = [file('src/a/index.ts', ['const alpha = 1;']), file('src/b/index.ts', ['const bravo = 2;'])]; + const ambiguous = parseBatchReviewResponse(raw([entry('index.ts', 'const alpha = 1;')]), siblings); + expect(ambiguous.stats.unroutableEntries).toBe(1); + expect(ambiguous.reviews.size).toBe(0); + + const duplicated = parseBatchReviewResponse( + raw([entry('src/a/index.ts', 'const alpha = 1;'), entry('src/a/index.ts', 'const alpha = 1;', 'Duplicate')]), + siblings, + ); + expect(duplicated.stats.unroutableEntries).toBe(1); + expect(duplicated.reviews.get('src/a/index.ts')!.comments).toHaveLength(1); + expect(duplicated.missing).toEqual(['src/b/index.ts']); + }); + + // Per-file indexes miss quotes shared across files. + it('withholds only when a shared quote AND a path disagreement coincide', () => { + const shared = '} catch (error) {'; + const files = [file('src/a.ts', [shared, 'const uniqueToAlpha = 1;']), file('src/b.ts', [shared, 'const bravo = 2;'])]; + const misfiled = (evidence: string, claimedPath: string) => raw([{ + absolute_file_path: 'src/a.ts', + findings: [{ + evidence, + code_location: { absolute_file_path: claimedPath, line: 1 }, + claim_type: 'other', + title: 'Swallowed error', + body: 'The catch block hides the failure.', + priority: 1, + }], + overall_explanation: 'Summary', + overall_correctness: 'patch is incorrect', + }]); + + const withheld = parseBatchReviewResponse(misfiled(shared, 'src/b.ts'), files); + expect(withheld.stats.ambiguousAcrossBin).toBe(1); + expect(withheld.reviews.get('src/a.ts')!.comments).toHaveLength(0); + + const agreeing = parseBatchReviewResponse(raw([entry('src/a.ts', shared, 'Swallowed error')]), files); + expect(agreeing.stats.ambiguousAcrossBin).toBe(0); + expect(agreeing.reviews.get('src/a.ts')!.comments).toHaveLength(1); + + // Unique quote + wrong path: enclosing entry still wins. + const mismatch = parseBatchReviewResponse(misfiled('const uniqueToAlpha = 1;', 'src/b.ts'), files); + expect(mismatch.stats.pathMismatchFindings).toBe(1); + expect(mismatch.reviews.get('src/a.ts')!.comments[0].path).toBe('src/a.ts'); + }); + + // Cap is per-file: a noisy file keeps its own cap while others are untouched. + it('trims over-cap findings per file and accounts for the drop', () => { + const lines = Array.from({ length: 30 }, (_, i) => `const value${i} = ${i};`); + const files = [file('src/a.ts', lines), file('src/b.ts', ['const bravo = 2;'])]; + + const noisy = { + absolute_file_path: 'src/a.ts', + findings: lines.map((line, i) => ({ + evidence: line, + code_location: { absolute_file_path: 'src/a.ts', line: i + 1 }, + claim_type: 'other', + title: `Problem number ${i}`, + body: 'A concrete problem with a concrete impact.', + priority: 2, + })), + overall_explanation: 'Many problems', + overall_correctness: 'patch is incorrect', + }; + + const result = parseBatchReviewResponse( + raw([noisy, entry('src/b.ts', 'const bravo = 2;', 'Bravo is off by one')]), + files, + { maxCommentsPerFile: 5 }, + ); + + // generatorFindingCap(5) = 10. + expect(result.reviews.get('src/a.ts')!.comments).toHaveLength(10); + expect(result.stats.overCap).toBe(20); + expect(result.reviews.get('src/a.ts')!.fileSummary).toContain('over-cap'); + expect(result.reviews.get('src/b.ts')!.comments).toHaveLength(1); + }); + + // One bad finding must not sink the rest of the batch. + it('drops an unassemblable finding without losing the rest of the bin', () => { + const files = [file('src/a.ts', ['const alpha = 1;']), file('src/b.ts', ['const bravo = 2;'])]; + + const result = parseBatchReviewResponse(raw([ + { + absolute_file_path: 'src/a.ts', + findings: [{ + evidence: 'const alpha = 1;', + code_location: { absolute_file_path: 'src/a.ts', line: 1 }, + claim_type: 'other', + // Title is a prefix of the body, so the body is stripped to nothing downstream. + title: 'Leak', + body: 'Leak', + priority: 2, + }], + overall_explanation: 'Summary', + overall_correctness: 'patch is incorrect', + }, + entry('src/b.ts', 'const bravo = 2;', 'Bravo is off by one'), + ]), files); + + expect(result.reviews.get('src/a.ts')!.comments).toHaveLength(0); + expect(result.reviews.get('src/a.ts')!.fileSummary).toContain('unassemblable'); + expect(result.reviews.get('src/b.ts')!.comments).toHaveLength(1); + }); + +}); diff --git a/packages/models/test/model/catalog-nvidia.spec.ts b/packages/models/test/model/catalog-nvidia.spec.ts index 57c6c947..874fbc5a 100644 --- a/packages/models/test/model/catalog-nvidia.spec.ts +++ b/packages/models/test/model/catalog-nvidia.spec.ts @@ -1,91 +1,91 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { listProviderModels } from '../../src/catalog'; - -// NVIDIA Build serves chat NIMs and non-chat NIMs (embedding, reranking, speech, OCR) from the same -// OpenAI-compatible /models endpoint. Without a filter, provider sync writes the non-chat ones into -// model_configs and they show up in every model picker as if they could review a diff. - -const MIXED_MODEL_LIST = { - data: [ - { id: 'meta/llama-3.3-70b-instruct' }, - { id: 'deepseek-ai/deepseek-r1' }, - { id: 'qwen/qwen2.5-coder-32b-instruct' }, - { id: 'nvidia/llama-3.2-nv-embedqa-1b-v2' }, - { id: 'nvidia/nv-rerankqa-mistral-4b-v3' }, - { id: 'nvidia/nv-embed-v1' }, - { id: 'nvidia/nemoretriever-parse' }, - { id: 'baidu/paddleocr' }, - { id: 'nvidia/parakeet-ctc-0.6b-asr' }, - { id: 'nvidia/magpie-tts-multilingual' }, - ], -}; - -const CHAT_IDS = [ - 'meta/llama-3.3-70b-instruct', - 'deepseek-ai/deepseek-r1', - 'qwen/qwen2.5-coder-32b-instruct', -]; - -function stubModelList(payload: unknown) { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify(payload), { status: 200, headers: { 'content-type': 'application/json' } }), - ); - vi.stubGlobal('fetch', fetchMock); - return fetchMock; -} - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe('listProviderModels NVIDIA Build filtering', () => { - it('drops embedding, reranking, retrieval, OCR, and speech NIMs from the NVIDIA catalog', async () => { - stubModelList(MIXED_MODEL_LIST); - - const models = await listProviderModels({ - apiFormat: 'openai', - baseUrl: 'https://integrate.api.nvidia.com/v1', - apiKey: 'nvapi-test', - }); - - expect(models).toEqual(CHAT_IDS); - }); - - it('requests the standard OpenAI-compatible /models endpoint with a bearer key', async () => { - const fetchMock = stubModelList(MIXED_MODEL_LIST); - - await listProviderModels({ - apiFormat: 'openai', - baseUrl: 'https://integrate.api.nvidia.com/v1/', - apiKey: 'nvapi-test', - }); - - const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe('https://integrate.api.nvidia.com/v1/models'); - expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer nvapi-test' }); - }); - - it('leaves an identical list untouched for other OpenAI-format providers', async () => { - stubModelList(MIXED_MODEL_LIST); - - const models = await listProviderModels({ - apiFormat: 'openai', - baseUrl: 'https://openrouter.ai/api/v1', - apiKey: 'sk-test', - }); - - expect(models).toEqual(MIXED_MODEL_LIST.data.map((entry) => entry.id)); - }); - - it('does not filter a self-hosted provider whose host merely resembles NVIDIA Build', async () => { - stubModelList({ data: [{ id: 'nv-embed-v1' }, { id: 'meta/llama-3.3-70b-instruct' }] }); - - const models = await listProviderModels({ - apiFormat: 'openai', - baseUrl: 'https://api.nvidia.example.com/v1', - apiKey: 'sk-test', - }); - - expect(models).toEqual(['nv-embed-v1', 'meta/llama-3.3-70b-instruct']); - }); -}); +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { listProviderModels } from '../../src/catalog'; + +// NVIDIA Build serves chat NIMs and non-chat NIMs (embedding, reranking, speech, OCR) from the same +// OpenAI-compatible /models endpoint. Without a filter, provider sync writes the non-chat ones into +// model_configs and they show up in every model picker as if they could review a diff. + +const MIXED_MODEL_LIST = { + data: [ + { id: 'meta/llama-3.3-70b-instruct' }, + { id: 'deepseek-ai/deepseek-r1' }, + { id: 'qwen/qwen2.5-coder-32b-instruct' }, + { id: 'nvidia/llama-3.2-nv-embedqa-1b-v2' }, + { id: 'nvidia/nv-rerankqa-mistral-4b-v3' }, + { id: 'nvidia/nv-embed-v1' }, + { id: 'nvidia/nemoretriever-parse' }, + { id: 'baidu/paddleocr' }, + { id: 'nvidia/parakeet-ctc-0.6b-asr' }, + { id: 'nvidia/magpie-tts-multilingual' }, + ], +}; + +const CHAT_IDS = [ + 'meta/llama-3.3-70b-instruct', + 'deepseek-ai/deepseek-r1', + 'qwen/qwen2.5-coder-32b-instruct', +]; + +function stubModelList(payload: unknown) { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(payload), { status: 200, headers: { 'content-type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('listProviderModels NVIDIA Build filtering', () => { + it('drops embedding, reranking, retrieval, OCR, and speech NIMs from the NVIDIA catalog', async () => { + stubModelList(MIXED_MODEL_LIST); + + const models = await listProviderModels({ + apiFormat: 'openai', + baseUrl: 'https://integrate.api.nvidia.com/v1', + apiKey: 'nvapi-test', + }); + + expect(models).toEqual(CHAT_IDS); + }); + + it('requests the standard OpenAI-compatible /models endpoint with a bearer key', async () => { + const fetchMock = stubModelList(MIXED_MODEL_LIST); + + await listProviderModels({ + apiFormat: 'openai', + baseUrl: 'https://integrate.api.nvidia.com/v1/', + apiKey: 'nvapi-test', + }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://integrate.api.nvidia.com/v1/models'); + expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer nvapi-test' }); + }); + + it('leaves an identical list untouched for other OpenAI-format providers', async () => { + stubModelList(MIXED_MODEL_LIST); + + const models = await listProviderModels({ + apiFormat: 'openai', + baseUrl: 'https://openrouter.ai/api/v1', + apiKey: 'sk-test', + }); + + expect(models).toEqual(MIXED_MODEL_LIST.data.map((entry) => entry.id)); + }); + + it('does not filter a self-hosted provider whose host merely resembles NVIDIA Build', async () => { + stubModelList({ data: [{ id: 'nv-embed-v1' }, { id: 'meta/llama-3.3-70b-instruct' }] }); + + const models = await listProviderModels({ + apiFormat: 'openai', + baseUrl: 'https://api.nvidia.example.com/v1', + apiKey: 'sk-test', + }); + + expect(models).toEqual(['nv-embed-v1', 'meta/llama-3.3-70b-instruct']); + }); +}); diff --git a/packages/models/test/model/chain-progress-store.spec.ts b/packages/models/test/model/chain-progress-store.spec.ts index bc2fba64..e970a382 100644 --- a/packages/models/test/model/chain-progress-store.spec.ts +++ b/packages/models/test/model/chain-progress-store.spec.ts @@ -1,288 +1,288 @@ -import { describe, expect, it } from 'vitest'; -import { ModelChainProgressStore } from '@codraoss/models'; - -// KV has no ordering; a late put with less state could revert progress. -function makeKV() { - let value: string | null = null; - let inFlight = 0; - let maxInFlight = 0; - const writes: string[] = []; - - return { - kv: { - async get(_key: string, type?: string) { - if (value === null) return null; - return type === 'json' ? JSON.parse(value) : value; - }, - async put(_key: string, body: string) { - inFlight += 1; - maxInFlight = Math.max(maxInFlight, inFlight); - writes.push(body); - // Two ticks ensure overlapping puts genuinely overlap. - await Promise.resolve(); - await Promise.resolve(); - value = body; - inFlight -= 1; - }, - }, - get maxInFlight() { - return maxInFlight; - }, - get stored() { - return value === null ? null : JSON.parse(value) as { - files?: Record; - timeouts?: Record; - cooldowns?: Record; - }; - }, - writes, - }; -} - -describe('ModelChainProgressStore', () => { - it('keeps both entries when two files defer concurrently', async () => { - const kv = makeKV(); - const store = new ModelChainProgressStore(kv.kv, 'job-race'); - - await Promise.all([store.advance('src/a.ts', 2), store.advance('src/b.ts', 3)]); - - expect(kv.maxInFlight).toBe(1); - expect(kv.stored?.files).toEqual({ 'src/a.ts': 2, 'src/b.ts': 3 }); - expect(await store.startIndexFor('src/a.ts')).toBe(2); - expect(await store.startIndexFor('src/b.ts')).toBe(3); - }); - - it('coalesces a burst of deferrals instead of writing once per file', async () => { - const kv = makeKV(); - const store = new ModelChainProgressStore(kv.kv, 'job-burst'); - - await Promise.all([1, 2, 3, 4, 5, 6].map((n) => store.advance(`src/f${n}.ts`, n))); - - expect(kv.maxInFlight).toBe(1); - expect(kv.writes.length).toBeLessThan(6); - expect(Object.keys(kv.stored?.files ?? {})).toHaveLength(6); - }); - - it('merges with progress another invocation stored, rather than overwriting it', async () => { - const kv = makeKV(); - // Written by a concurrent, unloaded invocation. - await kv.kv.put('k', JSON.stringify({ 'src/other.ts': 4 })); - - const store = new ModelChainProgressStore(kv.kv, 'job-merge'); - await store.advance('src/mine.ts', 1); - - expect(kv.stored?.files).toEqual({ 'src/other.ts': 4, 'src/mine.ts': 1 }); - }); - - it('never walks an index backwards', async () => { - const kv = makeKV(); - const store = new ModelChainProgressStore(kv.kv, 'job-monotonic'); - - await store.advance('src/a.ts', 3); - // Later, shorter deferral must not resurrect ruled-out models. - await store.advance('src/a.ts', 1); - - expect(kv.stored?.files).toEqual({ 'src/a.ts': 3 }); - expect(await store.startIndexFor('src/a.ts')).toBe(3); - }); - - it('drops a model after a full wave of timeouts, and remembers across invocations', async () => { - const kv = makeKV(); - const store = new ModelChainProgressStore(kv.kv, 'job-slow'); - - expect(await store.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(false); - await store.noteTimeout('vertex-ai:gemini-2.5-pro'); - await store.noteTimeout('vertex-ai:gemini-2.5-pro'); - // Judged on a round, not a single slow call. - expect(await store.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(false); - await store.noteTimeout('vertex-ai:gemini-2.5-pro'); - expect(await store.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(true); - - const next = new ModelChainProgressStore(kv.kv, 'job-slow'); - expect(await next.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(true); - expect(await next.isTimingOut('vertex-ai:gemini-2.5-flash')).toBe(false); - }); - - // Tail gets higher strike threshold, not exemption, to avoid infinite looping. - it('holds the last candidate to a higher strike count before dropping it too', async () => { - const kv = makeKV(); - const store = new ModelChainProgressStore(kv.kv, 'job-tail'); - - for (let i = 0; i < 3; i += 1) await store.noteTimeout('cf:glm-4.7-flash'); - expect(await store.isTimingOut('cf:glm-4.7-flash')).toBe(true); - expect(await store.isTimingOutTerminally('cf:glm-4.7-flash')).toBe(false); - - for (let i = 0; i < 3; i += 1) await store.noteTimeout('cf:glm-4.7-flash'); - expect(await store.isTimingOutTerminally('cf:glm-4.7-flash')).toBe(true); - - const next = new ModelChainProgressStore(kv.kv, 'job-tail'); - expect(await next.isTimingOutTerminally('cf:glm-4.7-flash')).toBe(true); - }); - - describe('noteSuccess', () => { - // Reset prevents lifetime tally from condemning a model for the whole job. - it('restarts the tally, so a slow patch cannot condemn a working model', async () => { - const kv = makeKV(); - const store = new ModelChainProgressStore(kv.kv, 'job-recovered'); - - for (let i = 0; i < 3; i += 1) await store.noteTimeout('vertex-ai:gemini-2.5-pro'); - expect(await store.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(true); - - await store.noteSuccess('vertex-ai:gemini-2.5-pro'); - expect(await store.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(false); - }); - - // max() merge must not resurrect pre-success counts from KV. - it('survives the merge against what another invocation stored', async () => { - const kv = makeKV(); - await kv.kv.put('k', JSON.stringify({ timeouts: { 'vertex-ai:gemini-2.5-pro': 5 } })); - - const store = new ModelChainProgressStore(kv.kv, 'job-merge-success'); - await store.noteSuccess('vertex-ai:gemini-2.5-pro'); - - expect(kv.stored?.timeouts?.['vertex-ai:gemini-2.5-pro']).toBeUndefined(); - const next = new ModelChainProgressStore(kv.kv, 'job-merge-success'); - expect(await next.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(false); - }); - - it('writes nothing for a model with a clean record', async () => { - const kv = makeKV(); - const store = new ModelChainProgressStore(kv.kv, 'job-clean'); - - await store.noteSuccess('vertex-ai:gemini-2.5-pro'); - - expect(kv.writes).toHaveLength(0); - }); - }); - - it('keeps chain progress and timeouts in one value without either clobbering the other', async () => { - const kv = makeKV(); - const store = new ModelChainProgressStore(kv.kv, 'job-both'); - - await Promise.all([store.advance('src/a.ts', 2), store.noteTimeout('vertex-ai:gemini-2.5-pro')]); - - const next = new ModelChainProgressStore(kv.kv, 'job-both'); - expect(await next.startIndexFor('src/a.ts')).toBe(2); - await next.noteTimeout('vertex-ai:gemini-2.5-pro'); - await next.noteTimeout('vertex-ai:gemini-2.5-pro'); - expect(await next.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(true); - }); - - // Supports legacy in-flight bare label->index maps. - it('reads the pre-timeouts stored shape without losing resume progress', async () => { - const kv = makeKV(); - await kv.kv.put('k', JSON.stringify({ 'src/legacy.ts': 3 })); - - const store = new ModelChainProgressStore(kv.kv, 'job-legacy'); - - expect(await store.startIndexFor('src/legacy.ts')).toBe(3); - expect(await store.isTimingOut('anything')).toBe(false); - }); - - describe('rate-limit cool-offs', () => { - it('carries a learned cool-off and bucket size to the next invocation', async () => { - const kv = makeKV(); - const store = new ModelChainProgressStore(kv.kv, 'job-cooldown'); - const until = Date.now() + 30_000; - - store.noteRateLimit('google:gemini-2.5-flash', { cooldownUntil: until, limitTokens: 16000 }); - await store.flushPending(); - - const next = new ModelChainProgressStore(kv.kv, 'job-cooldown'); - const loaded = await next.loadCooldowns(); - expect(loaded.get('google:gemini-2.5-flash')).toEqual({ cooldownUntil: until, limitTokens: 16000 }); - expect(loaded.has('google:gemini-2.5-flash-lite')).toBe(false); - }); - - it('does not write on note alone, so a 429 adds no subrequests on a path that had none', async () => { - const kv = makeKV(); - const store = new ModelChainProgressStore(kv.kv, 'job-lazy'); - - store.noteRateLimit('google:gemini-2.5-flash', { cooldownUntil: Date.now() + 30_000 }); - expect(kv.writes).toHaveLength(0); - - await store.flushPending(); - expect(kv.writes.length).toBeGreaterThan(0); - }); - - it('takes the later deadline when two invocations both learned one', async () => { - const kv = makeKV(); - const earlier = Date.now() + 10_000; - const later = Date.now() + 90_000; - await kv.kv.put('k', JSON.stringify({ cooldowns: { 'google:m': { until: later, limitTokens: 16000 } } })); - - const store = new ModelChainProgressStore(kv.kv, 'job-merge-cooldown'); - // Later 429s omitting limitTokens must not erase known buckets. - store.noteRateLimit('google:m', { cooldownUntil: earlier }); - await store.flushPending(); - - expect(kv.stored?.cooldowns?.['google:m']).toEqual({ until: later, limitTokens: 16000 }); - }); - - // Guards against misparsed counts crippling the model for 24h. - it('discards a stored bucket too small to be a token quota', async () => { - const kv = makeKV(); - const until = Date.now() + 30_000; - await kv.kv.put('k', JSON.stringify({ cooldowns: { 'google:m': { until, limitTokens: 15 } } })); - - const store = new ModelChainProgressStore(kv.kv, 'job-poisoned-bucket'); - - const entry = (await store.loadCooldowns()).get('google:m'); - expect(entry?.cooldownUntil).toBe(until); - expect(entry?.limitTokens).toBeUndefined(); - }); - - it('clamps an implausible cool-off rather than disabling a model for the whole job', async () => { - const kv = makeKV(); - // Prevents misparsed delays from disabling models indefinitely. - await kv.kv.put('k', JSON.stringify({ cooldowns: { 'google:m': { until: Date.now() + 3_600_000 } } })); - - const store = new ModelChainProgressStore(kv.kv, 'job-clamp'); - - const entry = (await store.loadCooldowns()).get('google:m'); - expect(entry!.cooldownUntil).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000); - }); - - // Bucket sizes outlive cool-offs to answer "can this prompt fit?". - it('keeps an expired entry so its bucket size survives', async () => { - const kv = makeKV(); - await kv.kv.put('k', JSON.stringify({ cooldowns: { 'google:m': { until: Date.now() - 60_000, limitTokens: 16000 } } })); - - const store = new ModelChainProgressStore(kv.kv, 'job-expired'); - - expect((await store.loadCooldowns()).get('google:m')?.limitTokens).toBe(16000); - }); - - it('reads a blob written before cooldowns existed without losing resume progress', async () => { - const kv = makeKV(); - await kv.kv.put('k', JSON.stringify({ files: { 'src/a.ts': 2 }, timeouts: { 'google:m': 1 } })); - - const store = new ModelChainProgressStore(kv.kv, 'job-old-shape'); - - expect(await store.startIndexFor('src/a.ts')).toBe(2); - expect((await store.loadCooldowns()).size).toBe(0); - }); - - it('keeps a cool-off noted before the KV read resolved', async () => { - const kv = makeKV(); - await kv.kv.put('k', JSON.stringify({ files: { 'src/a.ts': 2 } })); - - const store = new ModelChainProgressStore(kv.kv, 'job-early-note'); - // Sync noteRateLimit can land before load(); must merge, not replace. - store.noteRateLimit('google:m', { cooldownUntil: Date.now() + 30_000, limitTokens: 16000 }); - - expect((await store.loadCooldowns()).get('google:m')?.limitTokens).toBe(16000); - expect(await store.startIndexFor('src/a.ts')).toBe(2); - }); - }); - - it('does nothing at all without a jobId', async () => { - const kv = makeKV(); - const store = new ModelChainProgressStore(kv.kv, undefined); - - await store.advance('src/a.ts', 2); - - expect(kv.writes).toHaveLength(0); - expect(await store.startIndexFor('src/a.ts')).toBe(0); - }); -}); +import { describe, expect, it } from 'vitest'; +import { ModelChainProgressStore } from '@codraoss/models'; + +// KV has no ordering; a late put with less state could revert progress. +function makeKV() { + let value: string | null = null; + let inFlight = 0; + let maxInFlight = 0; + const writes: string[] = []; + + return { + kv: { + async get(_key: string, type?: string) { + if (value === null) return null; + return type === 'json' ? JSON.parse(value) : value; + }, + async put(_key: string, body: string) { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + writes.push(body); + // Two ticks ensure overlapping puts genuinely overlap. + await Promise.resolve(); + await Promise.resolve(); + value = body; + inFlight -= 1; + }, + }, + get maxInFlight() { + return maxInFlight; + }, + get stored() { + return value === null ? null : JSON.parse(value) as { + files?: Record; + timeouts?: Record; + cooldowns?: Record; + }; + }, + writes, + }; +} + +describe('ModelChainProgressStore', () => { + it('keeps both entries when two files defer concurrently', async () => { + const kv = makeKV(); + const store = new ModelChainProgressStore(kv.kv, 'job-race'); + + await Promise.all([store.advance('src/a.ts', 2), store.advance('src/b.ts', 3)]); + + expect(kv.maxInFlight).toBe(1); + expect(kv.stored?.files).toEqual({ 'src/a.ts': 2, 'src/b.ts': 3 }); + expect(await store.startIndexFor('src/a.ts')).toBe(2); + expect(await store.startIndexFor('src/b.ts')).toBe(3); + }); + + it('coalesces a burst of deferrals instead of writing once per file', async () => { + const kv = makeKV(); + const store = new ModelChainProgressStore(kv.kv, 'job-burst'); + + await Promise.all([1, 2, 3, 4, 5, 6].map((n) => store.advance(`src/f${n}.ts`, n))); + + expect(kv.maxInFlight).toBe(1); + expect(kv.writes.length).toBeLessThan(6); + expect(Object.keys(kv.stored?.files ?? {})).toHaveLength(6); + }); + + it('merges with progress another invocation stored, rather than overwriting it', async () => { + const kv = makeKV(); + // Written by a concurrent, unloaded invocation. + await kv.kv.put('k', JSON.stringify({ 'src/other.ts': 4 })); + + const store = new ModelChainProgressStore(kv.kv, 'job-merge'); + await store.advance('src/mine.ts', 1); + + expect(kv.stored?.files).toEqual({ 'src/other.ts': 4, 'src/mine.ts': 1 }); + }); + + it('never walks an index backwards', async () => { + const kv = makeKV(); + const store = new ModelChainProgressStore(kv.kv, 'job-monotonic'); + + await store.advance('src/a.ts', 3); + // Later, shorter deferral must not resurrect ruled-out models. + await store.advance('src/a.ts', 1); + + expect(kv.stored?.files).toEqual({ 'src/a.ts': 3 }); + expect(await store.startIndexFor('src/a.ts')).toBe(3); + }); + + it('drops a model after a full wave of timeouts, and remembers across invocations', async () => { + const kv = makeKV(); + const store = new ModelChainProgressStore(kv.kv, 'job-slow'); + + expect(await store.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(false); + await store.noteTimeout('vertex-ai:gemini-2.5-pro'); + await store.noteTimeout('vertex-ai:gemini-2.5-pro'); + // Judged on a round, not a single slow call. + expect(await store.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(false); + await store.noteTimeout('vertex-ai:gemini-2.5-pro'); + expect(await store.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(true); + + const next = new ModelChainProgressStore(kv.kv, 'job-slow'); + expect(await next.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(true); + expect(await next.isTimingOut('vertex-ai:gemini-2.5-flash')).toBe(false); + }); + + // Tail gets higher strike threshold, not exemption, to avoid infinite looping. + it('holds the last candidate to a higher strike count before dropping it too', async () => { + const kv = makeKV(); + const store = new ModelChainProgressStore(kv.kv, 'job-tail'); + + for (let i = 0; i < 3; i += 1) await store.noteTimeout('cf:glm-4.7-flash'); + expect(await store.isTimingOut('cf:glm-4.7-flash')).toBe(true); + expect(await store.isTimingOutTerminally('cf:glm-4.7-flash')).toBe(false); + + for (let i = 0; i < 3; i += 1) await store.noteTimeout('cf:glm-4.7-flash'); + expect(await store.isTimingOutTerminally('cf:glm-4.7-flash')).toBe(true); + + const next = new ModelChainProgressStore(kv.kv, 'job-tail'); + expect(await next.isTimingOutTerminally('cf:glm-4.7-flash')).toBe(true); + }); + + describe('noteSuccess', () => { + // Reset prevents lifetime tally from condemning a model for the whole job. + it('restarts the tally, so a slow patch cannot condemn a working model', async () => { + const kv = makeKV(); + const store = new ModelChainProgressStore(kv.kv, 'job-recovered'); + + for (let i = 0; i < 3; i += 1) await store.noteTimeout('vertex-ai:gemini-2.5-pro'); + expect(await store.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(true); + + await store.noteSuccess('vertex-ai:gemini-2.5-pro'); + expect(await store.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(false); + }); + + // max() merge must not resurrect pre-success counts from KV. + it('survives the merge against what another invocation stored', async () => { + const kv = makeKV(); + await kv.kv.put('k', JSON.stringify({ timeouts: { 'vertex-ai:gemini-2.5-pro': 5 } })); + + const store = new ModelChainProgressStore(kv.kv, 'job-merge-success'); + await store.noteSuccess('vertex-ai:gemini-2.5-pro'); + + expect(kv.stored?.timeouts?.['vertex-ai:gemini-2.5-pro']).toBeUndefined(); + const next = new ModelChainProgressStore(kv.kv, 'job-merge-success'); + expect(await next.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(false); + }); + + it('writes nothing for a model with a clean record', async () => { + const kv = makeKV(); + const store = new ModelChainProgressStore(kv.kv, 'job-clean'); + + await store.noteSuccess('vertex-ai:gemini-2.5-pro'); + + expect(kv.writes).toHaveLength(0); + }); + }); + + it('keeps chain progress and timeouts in one value without either clobbering the other', async () => { + const kv = makeKV(); + const store = new ModelChainProgressStore(kv.kv, 'job-both'); + + await Promise.all([store.advance('src/a.ts', 2), store.noteTimeout('vertex-ai:gemini-2.5-pro')]); + + const next = new ModelChainProgressStore(kv.kv, 'job-both'); + expect(await next.startIndexFor('src/a.ts')).toBe(2); + await next.noteTimeout('vertex-ai:gemini-2.5-pro'); + await next.noteTimeout('vertex-ai:gemini-2.5-pro'); + expect(await next.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(true); + }); + + // Supports legacy in-flight bare label->index maps. + it('reads the pre-timeouts stored shape without losing resume progress', async () => { + const kv = makeKV(); + await kv.kv.put('k', JSON.stringify({ 'src/legacy.ts': 3 })); + + const store = new ModelChainProgressStore(kv.kv, 'job-legacy'); + + expect(await store.startIndexFor('src/legacy.ts')).toBe(3); + expect(await store.isTimingOut('anything')).toBe(false); + }); + + describe('rate-limit cool-offs', () => { + it('carries a learned cool-off and bucket size to the next invocation', async () => { + const kv = makeKV(); + const store = new ModelChainProgressStore(kv.kv, 'job-cooldown'); + const until = Date.now() + 30_000; + + store.noteRateLimit('google:gemini-2.5-flash', { cooldownUntil: until, limitTokens: 16000 }); + await store.flushPending(); + + const next = new ModelChainProgressStore(kv.kv, 'job-cooldown'); + const loaded = await next.loadCooldowns(); + expect(loaded.get('google:gemini-2.5-flash')).toEqual({ cooldownUntil: until, limitTokens: 16000 }); + expect(loaded.has('google:gemini-2.5-flash-lite')).toBe(false); + }); + + it('does not write on note alone, so a 429 adds no subrequests on a path that had none', async () => { + const kv = makeKV(); + const store = new ModelChainProgressStore(kv.kv, 'job-lazy'); + + store.noteRateLimit('google:gemini-2.5-flash', { cooldownUntil: Date.now() + 30_000 }); + expect(kv.writes).toHaveLength(0); + + await store.flushPending(); + expect(kv.writes.length).toBeGreaterThan(0); + }); + + it('takes the later deadline when two invocations both learned one', async () => { + const kv = makeKV(); + const earlier = Date.now() + 10_000; + const later = Date.now() + 90_000; + await kv.kv.put('k', JSON.stringify({ cooldowns: { 'google:m': { until: later, limitTokens: 16000 } } })); + + const store = new ModelChainProgressStore(kv.kv, 'job-merge-cooldown'); + // Later 429s omitting limitTokens must not erase known buckets. + store.noteRateLimit('google:m', { cooldownUntil: earlier }); + await store.flushPending(); + + expect(kv.stored?.cooldowns?.['google:m']).toEqual({ until: later, limitTokens: 16000 }); + }); + + // Guards against misparsed counts crippling the model for 24h. + it('discards a stored bucket too small to be a token quota', async () => { + const kv = makeKV(); + const until = Date.now() + 30_000; + await kv.kv.put('k', JSON.stringify({ cooldowns: { 'google:m': { until, limitTokens: 15 } } })); + + const store = new ModelChainProgressStore(kv.kv, 'job-poisoned-bucket'); + + const entry = (await store.loadCooldowns()).get('google:m'); + expect(entry?.cooldownUntil).toBe(until); + expect(entry?.limitTokens).toBeUndefined(); + }); + + it('clamps an implausible cool-off rather than disabling a model for the whole job', async () => { + const kv = makeKV(); + // Prevents misparsed delays from disabling models indefinitely. + await kv.kv.put('k', JSON.stringify({ cooldowns: { 'google:m': { until: Date.now() + 3_600_000 } } })); + + const store = new ModelChainProgressStore(kv.kv, 'job-clamp'); + + const entry = (await store.loadCooldowns()).get('google:m'); + expect(entry!.cooldownUntil).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000); + }); + + // Bucket sizes outlive cool-offs to answer "can this prompt fit?". + it('keeps an expired entry so its bucket size survives', async () => { + const kv = makeKV(); + await kv.kv.put('k', JSON.stringify({ cooldowns: { 'google:m': { until: Date.now() - 60_000, limitTokens: 16000 } } })); + + const store = new ModelChainProgressStore(kv.kv, 'job-expired'); + + expect((await store.loadCooldowns()).get('google:m')?.limitTokens).toBe(16000); + }); + + it('reads a blob written before cooldowns existed without losing resume progress', async () => { + const kv = makeKV(); + await kv.kv.put('k', JSON.stringify({ files: { 'src/a.ts': 2 }, timeouts: { 'google:m': 1 } })); + + const store = new ModelChainProgressStore(kv.kv, 'job-old-shape'); + + expect(await store.startIndexFor('src/a.ts')).toBe(2); + expect((await store.loadCooldowns()).size).toBe(0); + }); + + it('keeps a cool-off noted before the KV read resolved', async () => { + const kv = makeKV(); + await kv.kv.put('k', JSON.stringify({ files: { 'src/a.ts': 2 } })); + + const store = new ModelChainProgressStore(kv.kv, 'job-early-note'); + // Sync noteRateLimit can land before load(); must merge, not replace. + store.noteRateLimit('google:m', { cooldownUntil: Date.now() + 30_000, limitTokens: 16000 }); + + expect((await store.loadCooldowns()).get('google:m')?.limitTokens).toBe(16000); + expect(await store.startIndexFor('src/a.ts')).toBe(2); + }); + }); + + it('does nothing at all without a jobId', async () => { + const kv = makeKV(); + const store = new ModelChainProgressStore(kv.kv, undefined); + + await store.advance('src/a.ts', 2); + + expect(kv.writes).toHaveLength(0); + expect(await store.startIndexFor('src/a.ts')).toBe(0); + }); +}); diff --git a/packages/models/test/model/chain-resume.spec.ts b/packages/models/test/model/chain-resume.spec.ts index 3b049cbd..8fb8de69 100644 --- a/packages/models/test/model/chain-resume.spec.ts +++ b/packages/models/test/model/chain-resume.spec.ts @@ -1,110 +1,110 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { nextChainIndexOf, ModelRunner } from '@codraoss/models'; -import { defaultRepoConfig } from '@codraoss/schema'; -import { TokenTracker } from '@codraoss/core/token-tracker'; -import { createTestEnv, saveTestProviderApiKey, createTestModelRunner } from '../../../../test/helpers'; - -// ~55s per invocation: a slow head never reaches the tail, so resume must pick up where it left off. -describe('model chain resume', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - const file = { - path: 'src/app.ts', - lineCount: 1, - hunks: [], - isDeleted: false, - isBinary: false, - isNew: false, - previousPath: null, - }; - - const chainConfig = { - ...defaultRepoConfig, - model: { - main: 'gemini-3.1-pro-preview', - // 3 entries: the memo only records progress when there's still somewhere left to go. - fallbacks: ['gemini-2.5-pro', 'gemini-3.1-flash-lite'], - size_overrides: [], - }, - }; - - const gemini = (status: number, body: unknown) => - new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); - - const ok = () => gemini(200, { - candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, - }); - const unavailable = () => gemini(503, { error: { code: 503, message: 'The model is overloaded.', status: 'UNAVAILABLE' } }); - const rateLimited = () => gemini(429, { error: { code: 429, message: 'Resource exhausted. limit: 16000, model: gemini. Please retry in 30s.', status: 'RESOURCE_EXHAUSTED' } }); - - async function review(service: ModelRunner) { - return service.reviewFile({ - file, - prTitle: 'Test', - prDescription: null, - config: chainConfig, - totalLineCount: 1, - } as Parameters[0]); - } - - it('resumes at the model after the ones that already failed, instead of replaying them', async () => { - const env = createTestEnv(); - await saveTestProviderApiKey(env); - // Near the subrequest cap, so the breaker ends the chain after the primary, matching prod. - const tracker = new TokenTracker(); - tracker.incrementSubrequests(40); - const first = createTestModelRunner(env, tracker, { jobId: 'job-chain-resume' }); - - const firstFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => unavailable()); - await expect(review(first)).rejects.toThrow(/retrying later/); - const walked = firstFetch.mock.calls.map((call) => String(call[0])); - expect(walked.every((url) => url.includes('gemini-3.1-pro-preview'))).toBe(true); - vi.restoreAllMocks(); - - const second = createTestModelRunner(env, undefined, { jobId: 'job-chain-resume' }); - const secondFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ok()); - await review(second); - - const retried = secondFetch.mock.calls.map((call) => String(call[0])); - expect(retried.every((url) => !url.includes('gemini-3.1-pro-preview'))).toBe(true); - expect(retried.length).toBeGreaterThan(0); - }); - - it('does not record progress past a model that was only rate-limited', async () => { - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const service = createTestModelRunner(env, undefined, { jobId: 'job-chain-429' }); - - // 429 means "same model, later"; advancing past it would skip a healthy model for good. - vi.spyOn(globalThis, 'fetch').mockImplementation(async () => rateLimited()); - const failure = await review(service).catch((error) => error); - - expect(nextChainIndexOf(failure)).toBeNull(); - }); - - it('stops the chain the moment the invocation runs out of subrequests', async () => { - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const service = createTestModelRunner(env, undefined, { jobId: 'job-subrequests' }); - - const fetchMock = vi.spyOn(globalThis, 'fetch').mockRejectedValue( - new Error('Too many subrequests by single Worker invocation.'), - ); - const failure = await review(service).catch((error) => error); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(String(failure?.message)).toMatch(/retrying later/); - expect(nextChainIndexOf(failure)).toBeNull(); - }); - - it('reads nothing off an error that never walked a chain', () => { - expect(nextChainIndexOf(new Error('boom'))).toBeNull(); - expect(nextChainIndexOf(undefined)).toBeNull(); - // 0 is "no progress", and must not be mistaken for a recorded index. - expect(nextChainIndexOf(Object.assign(new Error('x'), { nextChainIndex: 0 }))).toBeNull(); - expect(nextChainIndexOf(Object.assign(new Error('x'), { nextChainIndex: 2 }))).toBe(2); - }); -}); +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { nextChainIndexOf, ModelRunner } from '@codraoss/models'; +import { defaultRepoConfig } from '@codraoss/schema'; +import { TokenTracker } from '@codraoss/core/token-tracker'; +import { createTestEnv, saveTestProviderApiKey, createTestModelRunner } from '../../../../test/helpers'; + +// ~55s per invocation: a slow head never reaches the tail, so resume must pick up where it left off. +describe('model chain resume', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const file = { + path: 'src/app.ts', + lineCount: 1, + hunks: [], + isDeleted: false, + isBinary: false, + isNew: false, + previousPath: null, + }; + + const chainConfig = { + ...defaultRepoConfig, + model: { + main: 'gemini-3.1-pro-preview', + // 3 entries: the memo only records progress when there's still somewhere left to go. + fallbacks: ['gemini-2.5-pro', 'gemini-3.1-flash-lite'], + size_overrides: [], + }, + }; + + const gemini = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + + const ok = () => gemini(200, { + candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }); + const unavailable = () => gemini(503, { error: { code: 503, message: 'The model is overloaded.', status: 'UNAVAILABLE' } }); + const rateLimited = () => gemini(429, { error: { code: 429, message: 'Resource exhausted. limit: 16000, model: gemini. Please retry in 30s.', status: 'RESOURCE_EXHAUSTED' } }); + + async function review(service: ModelRunner) { + return service.reviewFile({ + file, + prTitle: 'Test', + prDescription: null, + config: chainConfig, + totalLineCount: 1, + } as Parameters[0]); + } + + it('resumes at the model after the ones that already failed, instead of replaying them', async () => { + const env = createTestEnv(); + await saveTestProviderApiKey(env); + // Near the subrequest cap, so the breaker ends the chain after the primary, matching prod. + const tracker = new TokenTracker(); + tracker.incrementSubrequests(40); + const first = createTestModelRunner(env, tracker, { jobId: 'job-chain-resume' }); + + const firstFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => unavailable()); + await expect(review(first)).rejects.toThrow(/retrying later/); + const walked = firstFetch.mock.calls.map((call) => String(call[0])); + expect(walked.every((url) => url.includes('gemini-3.1-pro-preview'))).toBe(true); + vi.restoreAllMocks(); + + const second = createTestModelRunner(env, undefined, { jobId: 'job-chain-resume' }); + const secondFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ok()); + await review(second); + + const retried = secondFetch.mock.calls.map((call) => String(call[0])); + expect(retried.every((url) => !url.includes('gemini-3.1-pro-preview'))).toBe(true); + expect(retried.length).toBeGreaterThan(0); + }); + + it('does not record progress past a model that was only rate-limited', async () => { + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const service = createTestModelRunner(env, undefined, { jobId: 'job-chain-429' }); + + // 429 means "same model, later"; advancing past it would skip a healthy model for good. + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => rateLimited()); + const failure = await review(service).catch((error) => error); + + expect(nextChainIndexOf(failure)).toBeNull(); + }); + + it('stops the chain the moment the invocation runs out of subrequests', async () => { + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const service = createTestModelRunner(env, undefined, { jobId: 'job-subrequests' }); + + const fetchMock = vi.spyOn(globalThis, 'fetch').mockRejectedValue( + new Error('Too many subrequests by single Worker invocation.'), + ); + const failure = await review(service).catch((error) => error); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(failure?.message)).toMatch(/retrying later/); + expect(nextChainIndexOf(failure)).toBeNull(); + }); + + it('reads nothing off an error that never walked a chain', () => { + expect(nextChainIndexOf(new Error('boom'))).toBeNull(); + expect(nextChainIndexOf(undefined)).toBeNull(); + // 0 is "no progress", and must not be mistaken for a recorded index. + expect(nextChainIndexOf(Object.assign(new Error('x'), { nextChainIndex: 0 }))).toBeNull(); + expect(nextChainIndexOf(Object.assign(new Error('x'), { nextChainIndex: 2 }))).toBe(2); + }); +}); diff --git a/packages/models/test/model/cloudflare.spec.ts b/packages/models/test/model/cloudflare.spec.ts index 21db037e..88755162 100644 --- a/packages/models/test/model/cloudflare.spec.ts +++ b/packages/models/test/model/cloudflare.spec.ts @@ -1,115 +1,115 @@ -import { describe, it, expect, vi } from 'vitest'; -import { reviewWithCloudflare, submitCloudflareBatch, pollCloudflareBatch } from '@codraoss/models/cloudflare'; - -// Regression: some Workers AI models (e.g. @cf/qwen/qwen2.5-coder-32b-instruct honoring -// response_format) return `response` as an already-parsed JSON object/array rather than a string. -// extractCloudflareText used to only accept a string, discarding a good review as "empty response". - -const REVIEW_JSON = { - findings: [], - overall_correctness: 'patch is correct', - overall_explanation: 'Looks good.', - overall_confidence_score: 0.9, -}; - -function envReturning(result: unknown) { - return { AI: { async run() { return result; } } } as any; -} - -const input = { systemPrompt: 'sys', userPrompt: 'user' }; - -describe('reviewWithCloudflare response extraction', () => { - it('accepts a structured object response (parsed JSON) and passes it through verbatim', async () => { - const res = await reviewWithCloudflare( - envReturning({ response: REVIEW_JSON, usage: { prompt_tokens: 3, completion_tokens: 4 } }).AI, - '@cf/qwen/qwen2.5-coder-32b-instruct', - input, - ); - // Must be the real review JSON, not a synthesized "no parseable review content" fallback. - expect(JSON.parse(res.rawText)).toMatchObject({ overall_correctness: 'patch is correct' }); - expect(res.rawText).not.toContain('no parseable review content'); - expect(res.inputTokens).toBe(3); - expect(res.outputTokens).toBe(4); - }); - - it('accepts a structured object under a nested result.response', async () => { - const res = await reviewWithCloudflare( - envReturning({ result: { response: REVIEW_JSON } }).AI, - '@cf/qwen/qwen2.5-coder-32b-instruct', - input, - ); - expect(JSON.parse(res.rawText)).toMatchObject({ overall_explanation: 'Looks good.' }); - expect(res.rawText).not.toContain('no parseable review content'); - }); - - it('still accepts a plain string response (existing behavior)', async () => { - const res = await reviewWithCloudflare( - envReturning({ response: JSON.stringify(REVIEW_JSON) }).AI, - '@cf/meta/llama-3.3-70b-instruct-fp8-fast', - input, - ); - expect(JSON.parse(res.rawText)).toMatchObject({ overall_correctness: 'patch is correct' }); - }); - - it('throws (fails the file) instead of synthesizing a fake review when the model returns nothing usable', async () => { - await expect( - reviewWithCloudflare(envReturning({ something_unexpected: true }).AI, '@cf/qwen/qwen2.5-coder-32b-instruct', input), - ).rejects.toThrow(/no reviewable output/i); - }); - - it('throws on a reasoning-only / token-truncated response (marks the file failed, not inconclusive)', async () => { - const reasoningOnly = { choices: [{ finish_reason: 'length', message: { content: null, reasoning: 'thinking, thinking, never answering...' } }] }; - await expect( - reviewWithCloudflare(envReturning(reasoningOnly).AI, '@cf/moonshotai/kimi-k2.6', input), - ).rejects.toThrow(/no reviewable output/i); - }); -}); - -describe('Cloudflare async batch submit/poll', () => { - it('submits a batch request and returns the queue request_id', async () => { - const run = vi.fn().mockResolvedValue({ status: 'queued', request_id: 'req-123', model: '@cf/moonshotai/kimi-k2.6' }); - const env = { AI: { run } } as any; - const id = await submitCloudflareBatch(env.AI, '@cf/moonshotai/kimi-k2.6', input); - expect(id).toBe('req-123'); - // Must send a `requests` array with queueRequest option. - expect(run.mock.calls[0][1]).toHaveProperty('requests'); - expect(run.mock.calls[0][2]).toMatchObject({ queueRequest: true }); - }); - - it('throws when the model does not return a request_id (async unsupported → caller falls back to sync)', async () => { - const env = { AI: { async run() { return { response: '{"findings":[]}' }; } } } as any; - await expect(submitCloudflareBatch(env.AI, '@cf/meta/llama-3.1-8b-instruct', input)).rejects.toThrow(/async queueing unsupported|did not return/i); - }); - - it('reports pending while the batch is queued or running', async () => { - for (const status of ['queued', 'running']) { - const env = { AI: { async run() { return { status, request_id: 'req-1' }; } } } as any; - const res = await pollCloudflareBatch(env.AI, '@cf/moonshotai/kimi-k2.6', 'req-1'); - expect(res.status).toBe('pending'); - } - }); - - it('extracts the review from a completed batch (responses[] with string response)', async () => { - const env = { AI: { async run() { - return { responses: [{ id: 0, external_reference: 'src/app.ts', result: { response: JSON.stringify(REVIEW_JSON), usage: { prompt_tokens: 5, completion_tokens: 6 } } }] }; - } } } as any; - const res = await pollCloudflareBatch(env.AI, '@cf/moonshotai/kimi-k2.6', 'req-1'); - expect(res.status).toBe('done'); - if (res.status === 'done') { - expect(JSON.parse(res.response.rawText)).toMatchObject({ overall_correctness: 'patch is correct' }); - expect(res.response.inputTokens).toBe(5); - expect(res.response.outputTokens).toBe(6); - } - }); - - it('extracts the review from a completed batch whose entry carries an object response', async () => { - const env = { AI: { async run() { - return { result: { responses: [{ id: 0, response: REVIEW_JSON }] } }; - } } } as any; - const res = await pollCloudflareBatch(env.AI, '@cf/moonshotai/kimi-k2.6', 'req-1'); - expect(res.status).toBe('done'); - if (res.status === 'done') { - expect(JSON.parse(res.response.rawText)).toMatchObject({ overall_explanation: 'Looks good.' }); - } - }); -}); +import { describe, it, expect, vi } from 'vitest'; +import { reviewWithCloudflare, submitCloudflareBatch, pollCloudflareBatch } from '@codraoss/models/cloudflare'; + +// Regression: some Workers AI models (e.g. @cf/qwen/qwen2.5-coder-32b-instruct honoring +// response_format) return `response` as an already-parsed JSON object/array rather than a string. +// extractCloudflareText used to only accept a string, discarding a good review as "empty response". + +const REVIEW_JSON = { + findings: [], + overall_correctness: 'patch is correct', + overall_explanation: 'Looks good.', + overall_confidence_score: 0.9, +}; + +function envReturning(result: unknown) { + return { AI: { async run() { return result; } } } as any; +} + +const input = { systemPrompt: 'sys', userPrompt: 'user' }; + +describe('reviewWithCloudflare response extraction', () => { + it('accepts a structured object response (parsed JSON) and passes it through verbatim', async () => { + const res = await reviewWithCloudflare( + envReturning({ response: REVIEW_JSON, usage: { prompt_tokens: 3, completion_tokens: 4 } }).AI, + '@cf/qwen/qwen2.5-coder-32b-instruct', + input, + ); + // Must be the real review JSON, not a synthesized "no parseable review content" fallback. + expect(JSON.parse(res.rawText)).toMatchObject({ overall_correctness: 'patch is correct' }); + expect(res.rawText).not.toContain('no parseable review content'); + expect(res.inputTokens).toBe(3); + expect(res.outputTokens).toBe(4); + }); + + it('accepts a structured object under a nested result.response', async () => { + const res = await reviewWithCloudflare( + envReturning({ result: { response: REVIEW_JSON } }).AI, + '@cf/qwen/qwen2.5-coder-32b-instruct', + input, + ); + expect(JSON.parse(res.rawText)).toMatchObject({ overall_explanation: 'Looks good.' }); + expect(res.rawText).not.toContain('no parseable review content'); + }); + + it('still accepts a plain string response (existing behavior)', async () => { + const res = await reviewWithCloudflare( + envReturning({ response: JSON.stringify(REVIEW_JSON) }).AI, + '@cf/meta/llama-3.3-70b-instruct-fp8-fast', + input, + ); + expect(JSON.parse(res.rawText)).toMatchObject({ overall_correctness: 'patch is correct' }); + }); + + it('throws (fails the file) instead of synthesizing a fake review when the model returns nothing usable', async () => { + await expect( + reviewWithCloudflare(envReturning({ something_unexpected: true }).AI, '@cf/qwen/qwen2.5-coder-32b-instruct', input), + ).rejects.toThrow(/no reviewable output/i); + }); + + it('throws on a reasoning-only / token-truncated response (marks the file failed, not inconclusive)', async () => { + const reasoningOnly = { choices: [{ finish_reason: 'length', message: { content: null, reasoning: 'thinking, thinking, never answering...' } }] }; + await expect( + reviewWithCloudflare(envReturning(reasoningOnly).AI, '@cf/moonshotai/kimi-k2.6', input), + ).rejects.toThrow(/no reviewable output/i); + }); +}); + +describe('Cloudflare async batch submit/poll', () => { + it('submits a batch request and returns the queue request_id', async () => { + const run = vi.fn().mockResolvedValue({ status: 'queued', request_id: 'req-123', model: '@cf/moonshotai/kimi-k2.6' }); + const env = { AI: { run } } as any; + const id = await submitCloudflareBatch(env.AI, '@cf/moonshotai/kimi-k2.6', input); + expect(id).toBe('req-123'); + // Must send a `requests` array with queueRequest option. + expect(run.mock.calls[0][1]).toHaveProperty('requests'); + expect(run.mock.calls[0][2]).toMatchObject({ queueRequest: true }); + }); + + it('throws when the model does not return a request_id (async unsupported → caller falls back to sync)', async () => { + const env = { AI: { async run() { return { response: '{"findings":[]}' }; } } } as any; + await expect(submitCloudflareBatch(env.AI, '@cf/meta/llama-3.1-8b-instruct', input)).rejects.toThrow(/async queueing unsupported|did not return/i); + }); + + it('reports pending while the batch is queued or running', async () => { + for (const status of ['queued', 'running']) { + const env = { AI: { async run() { return { status, request_id: 'req-1' }; } } } as any; + const res = await pollCloudflareBatch(env.AI, '@cf/moonshotai/kimi-k2.6', 'req-1'); + expect(res.status).toBe('pending'); + } + }); + + it('extracts the review from a completed batch (responses[] with string response)', async () => { + const env = { AI: { async run() { + return { responses: [{ id: 0, external_reference: 'src/app.ts', result: { response: JSON.stringify(REVIEW_JSON), usage: { prompt_tokens: 5, completion_tokens: 6 } } }] }; + } } } as any; + const res = await pollCloudflareBatch(env.AI, '@cf/moonshotai/kimi-k2.6', 'req-1'); + expect(res.status).toBe('done'); + if (res.status === 'done') { + expect(JSON.parse(res.response.rawText)).toMatchObject({ overall_correctness: 'patch is correct' }); + expect(res.response.inputTokens).toBe(5); + expect(res.response.outputTokens).toBe(6); + } + }); + + it('extracts the review from a completed batch whose entry carries an object response', async () => { + const env = { AI: { async run() { + return { result: { responses: [{ id: 0, response: REVIEW_JSON }] } }; + } } } as any; + const res = await pollCloudflareBatch(env.AI, '@cf/moonshotai/kimi-k2.6', 'req-1'); + expect(res.status).toBe('done'); + if (res.status === 'done') { + expect(JSON.parse(res.response.rawText)).toMatchObject({ overall_explanation: 'Looks good.' }); + } + }); +}); diff --git a/packages/models/test/model/config-cache.spec.ts b/packages/models/test/model/config-cache.spec.ts index 51f1d4ba..1783bdba 100644 --- a/packages/models/test/model/config-cache.spec.ts +++ b/packages/models/test/model/config-cache.spec.ts @@ -1,75 +1,75 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createTestEnv, createTestModelRunner } from '../../../../test/helpers'; - -// Isolated in its own file: mocking @codraoss/db/model-configs module-wide would break the -// other model-service tests that resolve configs against the real test DB. -const getResolvedModelConfigMock = vi.hoisted(() => vi.fn()); - -vi.mock('@codraoss/db/model-configs', async (importOriginal) => { - const mod = await importOriginal(); - return { ...mod, getResolvedModelConfig: getResolvedModelConfigMock }; -}); - - - -const cloudflareConfig = (modelId: string) => ({ - modelId, - providerId: 'cf', - providerName: 'Cloudflare', - apiFormat: 'cloudflare-workers-ai' as const, - modelName: modelId, - updatedAt: new Date().toISOString(), - providerEnabled: true, - baseUrl: null, - encryptedApiKey: null, -}); - -describe('ModelRunner model-config caching', () => { - beforeEach(() => { - getResolvedModelConfigMock.mockReset(); - }); - - it('resolves a given model config from the DB at most once per invocation', async () => { - getResolvedModelConfigMock.mockImplementation(async (_env: any, modelId: string) => cloudflareConfig(modelId)); - const service = createTestModelRunner(createTestEnv()); - - // The same model is resolved repeatedly across a chunk (once per file); only the first - // should hit the DB. - await (service as any).resolveModel('@cf/zai-org/glm-4.7-flash'); - await (service as any).resolveModel('@cf/zai-org/glm-4.7-flash'); - await (service as any).resolveModel('@cf/zai-org/glm-4.7-flash'); - - expect(getResolvedModelConfigMock).toHaveBeenCalledTimes(1); - }); - - it('keeps a separate cache entry per distinct model id', async () => { - getResolvedModelConfigMock.mockImplementation(async (_env: any, modelId: string) => cloudflareConfig(modelId)); - const service = createTestModelRunner(createTestEnv()); - - await (service as any).resolveModel('gemini-3.1-pro-preview'); - await (service as any).resolveModel('gemini-2.5-pro'); - await (service as any).resolveModel('gemini-3.1-pro-preview'); - - expect(getResolvedModelConfigMock).toHaveBeenCalledTimes(2); - }); - - it('caches a null "not configured" result so it is not re-queried every file', async () => { - getResolvedModelConfigMock.mockResolvedValue(null); - const service = createTestModelRunner(createTestEnv()); - - await expect((service as any).resolveModel('does-not-exist')).rejects.toThrow('is not configured'); - await expect((service as any).resolveModel('does-not-exist')).rejects.toThrow('is not configured'); - - expect(getResolvedModelConfigMock).toHaveBeenCalledTimes(1); - }); - - it('does not share a cache across ModelRunner instances (one instance == one invocation)', async () => { - getResolvedModelConfigMock.mockImplementation(async (_env: any, modelId: string) => cloudflareConfig(modelId)); - const env = createTestEnv(); - - await (createTestModelRunner(env) as any).resolveModel('@cf/zai-org/glm-4.7-flash'); - await (createTestModelRunner(env) as any).resolveModel('@cf/zai-org/glm-4.7-flash'); - - expect(getResolvedModelConfigMock).toHaveBeenCalledTimes(2); - }); -}); +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTestEnv, createTestModelRunner } from '../../../../test/helpers'; + +// Isolated in its own file: mocking @codraoss/db/model-configs module-wide would break the +// other model-service tests that resolve configs against the real test DB. +const getResolvedModelConfigMock = vi.hoisted(() => vi.fn()); + +vi.mock('@codraoss/db/model-configs', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, getResolvedModelConfig: getResolvedModelConfigMock }; +}); + + + +const cloudflareConfig = (modelId: string) => ({ + modelId, + providerId: 'cf', + providerName: 'Cloudflare', + apiFormat: 'cloudflare-workers-ai' as const, + modelName: modelId, + updatedAt: new Date().toISOString(), + providerEnabled: true, + baseUrl: null, + encryptedApiKey: null, +}); + +describe('ModelRunner model-config caching', () => { + beforeEach(() => { + getResolvedModelConfigMock.mockReset(); + }); + + it('resolves a given model config from the DB at most once per invocation', async () => { + getResolvedModelConfigMock.mockImplementation(async (_env: any, modelId: string) => cloudflareConfig(modelId)); + const service = createTestModelRunner(createTestEnv()); + + // The same model is resolved repeatedly across a chunk (once per file); only the first + // should hit the DB. + await (service as any).resolveModel('@cf/zai-org/glm-4.7-flash'); + await (service as any).resolveModel('@cf/zai-org/glm-4.7-flash'); + await (service as any).resolveModel('@cf/zai-org/glm-4.7-flash'); + + expect(getResolvedModelConfigMock).toHaveBeenCalledTimes(1); + }); + + it('keeps a separate cache entry per distinct model id', async () => { + getResolvedModelConfigMock.mockImplementation(async (_env: any, modelId: string) => cloudflareConfig(modelId)); + const service = createTestModelRunner(createTestEnv()); + + await (service as any).resolveModel('gemini-3.1-pro-preview'); + await (service as any).resolveModel('gemini-2.5-pro'); + await (service as any).resolveModel('gemini-3.1-pro-preview'); + + expect(getResolvedModelConfigMock).toHaveBeenCalledTimes(2); + }); + + it('caches a null "not configured" result so it is not re-queried every file', async () => { + getResolvedModelConfigMock.mockResolvedValue(null); + const service = createTestModelRunner(createTestEnv()); + + await expect((service as any).resolveModel('does-not-exist')).rejects.toThrow('is not configured'); + await expect((service as any).resolveModel('does-not-exist')).rejects.toThrow('is not configured'); + + expect(getResolvedModelConfigMock).toHaveBeenCalledTimes(1); + }); + + it('does not share a cache across ModelRunner instances (one instance == one invocation)', async () => { + getResolvedModelConfigMock.mockImplementation(async (_env: any, modelId: string) => cloudflareConfig(modelId)); + const env = createTestEnv(); + + await (createTestModelRunner(env) as any).resolveModel('@cf/zai-org/glm-4.7-flash'); + await (createTestModelRunner(env) as any).resolveModel('@cf/zai-org/glm-4.7-flash'); + + expect(getResolvedModelConfigMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/models/test/model/gemini-schema.spec.ts b/packages/models/test/model/gemini-schema.spec.ts index b6a6d693..b536ad14 100644 --- a/packages/models/test/model/gemini-schema.spec.ts +++ b/packages/models/test/model/gemini-schema.spec.ts @@ -1,75 +1,75 @@ -import { describe, expect, it } from 'vitest'; -import { toGeminiResponseJsonSchema } from '../../src/gemini-schema'; -import { buildBatchReviewResponseSchema, buildReviewResponseSchema } from '@codraoss/core/prompts/file-review'; -import { VERIFY_RESPONSE_SCHEMA } from '@codraoss/core/prompts/verify'; - -// Transformations asserted on the pure function; the adapter specs only check a grammar reaches the -// wire. Every failure mode here is silent -- a mangled grammar still returns 200. -describe('toGeminiResponseJsonSchema', () => { - const reviewSchema = () => buildReviewResponseSchema(10).schema; - const findingProps = (out: any) => out.properties.findings.items.properties; - - it('adapts both review grammars: ordering stated, code_location union collapsed', () => { - const out = toGeminiResponseJsonSchema(reviewSchema()) as any; - const expected = ['evidence', 'code_location', 'claim_type', 'title', 'body', 'priority', 'code_suggestion']; - expect(Object.keys(findingProps(out))).toEqual(expected); - expect(out.properties.findings.items.propertyOrdering).toEqual(expected); - - const location = findingProps(out).code_location; - expect(location.anyOf).toBeUndefined(); - // Paired: deleting the union without substituting `required` is the outcome to avoid. - expect(location.required).toEqual(['line']); - expect(Object.keys(location.properties)).toEqual(['absolute_file_path', 'line', 'line_range']); - - const verify = toGeminiResponseJsonSchema(VERIFY_RESPONSE_SCHEMA.schema as Record) as any; - // `reason` then `decidable`, both before `verdict`: the verifier justifies, and states whether the - // window it was given can settle the claim at all, before it is allowed to emit a decision token. - expect(verify.properties.results.items.propertyOrdering).toEqual(['index', 'reason', 'decidable', 'verdict', 'confidence']); - - // The batch grammar nests one level deeper; the same transforms must reach it. - const batch = toGeminiResponseJsonSchema(buildBatchReviewResponseSchema(10, 4).schema) as any; - const fileProps = batch.properties.files.items.properties; - expect(Object.keys(fileProps)[0]).toBe('absolute_file_path'); - expect(fileProps.findings.items.properties.code_location.required).toEqual(['line']); - }); - - it('collapses oneOf, but never a union it cannot safely replace', () => { - const collapsed = toGeminiResponseJsonSchema({ - type: 'object', - properties: { a: { type: 'string' }, b: { type: 'string' } }, - oneOf: [{ required: ['a'] }, { required: ['b'] }], - }) as any; - expect(collapsed.oneOf).toBeUndefined(); - expect(collapsed.required).toEqual(['a']); - - // A typed branch is a real union, and an unusable one can't be substituted. Both pass through. - const untouched = [ - [{ type: 'object', required: ['a'] }, { type: 'string' }], - [{ required: 'a' }, { required: ['a'] }], - [{ required: [123] }, { required: ['a'] }], - ]; - for (const anyOf of untouched) { - const out = toGeminiResponseJsonSchema({ type: 'object', properties: { a: { type: 'string' } }, anyOf }) as any; - expect(out.anyOf).toHaveLength(2); - expect(out.required).toBeUndefined(); - } - }); - - it('never mutates or aliases the caller\'s schema', () => { - // VERIFY_RESPONSE_SCHEMA is a module singleton, so an in-place edit would corrupt - // the verify grammar for every later job. - const verifyBefore = JSON.stringify(VERIFY_RESPONSE_SCHEMA.schema); - const input = reviewSchema() as any; - const before = JSON.stringify(input); - - toGeminiResponseJsonSchema(VERIFY_RESPONSE_SCHEMA.schema as Record); - const out = toGeminiResponseJsonSchema(input) as any; - - expect(JSON.stringify(VERIFY_RESPONSE_SCHEMA.schema)).toBe(verifyBefore); - expect(JSON.stringify(input)).toBe(before); - expect(input.properties.findings.items.properties.code_location.anyOf).toBeDefined(); - // Arrays too, so an `enum` or tuple-form `items` cannot be shared. - expect(out.required).not.toBe(input.required); - expect(findingProps(out).claim_type.enum).not.toBe(findingProps(input).claim_type.enum); - }); -}); +import { describe, expect, it } from 'vitest'; +import { toGeminiResponseJsonSchema } from '../../src/gemini-schema'; +import { buildBatchReviewResponseSchema, buildReviewResponseSchema } from '@codraoss/core/prompts/file-review'; +import { VERIFY_RESPONSE_SCHEMA } from '@codraoss/core/prompts/verify'; + +// Transformations asserted on the pure function; the adapter specs only check a grammar reaches the +// wire. Every failure mode here is silent -- a mangled grammar still returns 200. +describe('toGeminiResponseJsonSchema', () => { + const reviewSchema = () => buildReviewResponseSchema(10).schema; + const findingProps = (out: any) => out.properties.findings.items.properties; + + it('adapts both review grammars: ordering stated, code_location union collapsed', () => { + const out = toGeminiResponseJsonSchema(reviewSchema()) as any; + const expected = ['evidence', 'code_location', 'claim_type', 'title', 'body', 'priority', 'code_suggestion']; + expect(Object.keys(findingProps(out))).toEqual(expected); + expect(out.properties.findings.items.propertyOrdering).toEqual(expected); + + const location = findingProps(out).code_location; + expect(location.anyOf).toBeUndefined(); + // Paired: deleting the union without substituting `required` is the outcome to avoid. + expect(location.required).toEqual(['line']); + expect(Object.keys(location.properties)).toEqual(['absolute_file_path', 'line', 'line_range']); + + const verify = toGeminiResponseJsonSchema(VERIFY_RESPONSE_SCHEMA.schema as Record) as any; + // `reason` then `decidable`, both before `verdict`: the verifier justifies, and states whether the + // window it was given can settle the claim at all, before it is allowed to emit a decision token. + expect(verify.properties.results.items.propertyOrdering).toEqual(['index', 'reason', 'decidable', 'verdict', 'confidence']); + + // The batch grammar nests one level deeper; the same transforms must reach it. + const batch = toGeminiResponseJsonSchema(buildBatchReviewResponseSchema(10, 4).schema) as any; + const fileProps = batch.properties.files.items.properties; + expect(Object.keys(fileProps)[0]).toBe('absolute_file_path'); + expect(fileProps.findings.items.properties.code_location.required).toEqual(['line']); + }); + + it('collapses oneOf, but never a union it cannot safely replace', () => { + const collapsed = toGeminiResponseJsonSchema({ + type: 'object', + properties: { a: { type: 'string' }, b: { type: 'string' } }, + oneOf: [{ required: ['a'] }, { required: ['b'] }], + }) as any; + expect(collapsed.oneOf).toBeUndefined(); + expect(collapsed.required).toEqual(['a']); + + // A typed branch is a real union, and an unusable one can't be substituted. Both pass through. + const untouched = [ + [{ type: 'object', required: ['a'] }, { type: 'string' }], + [{ required: 'a' }, { required: ['a'] }], + [{ required: [123] }, { required: ['a'] }], + ]; + for (const anyOf of untouched) { + const out = toGeminiResponseJsonSchema({ type: 'object', properties: { a: { type: 'string' } }, anyOf }) as any; + expect(out.anyOf).toHaveLength(2); + expect(out.required).toBeUndefined(); + } + }); + + it('never mutates or aliases the caller\'s schema', () => { + // VERIFY_RESPONSE_SCHEMA is a module singleton, so an in-place edit would corrupt + // the verify grammar for every later job. + const verifyBefore = JSON.stringify(VERIFY_RESPONSE_SCHEMA.schema); + const input = reviewSchema() as any; + const before = JSON.stringify(input); + + toGeminiResponseJsonSchema(VERIFY_RESPONSE_SCHEMA.schema as Record); + const out = toGeminiResponseJsonSchema(input) as any; + + expect(JSON.stringify(VERIFY_RESPONSE_SCHEMA.schema)).toBe(verifyBefore); + expect(JSON.stringify(input)).toBe(before); + expect(input.properties.findings.items.properties.code_location.anyOf).toBeDefined(); + // Arrays too, so an `enum` or tuple-form `items` cannot be shared. + expect(out.required).not.toBe(input.required); + expect(findingProps(out).claim_type.enum).not.toBe(findingProps(input).claim_type.enum); + }); +}); diff --git a/packages/models/test/model/limits.spec.ts b/packages/models/test/model/limits.spec.ts index 27353a7f..01e542f9 100644 --- a/packages/models/test/model/limits.spec.ts +++ b/packages/models/test/model/limits.spec.ts @@ -1,136 +1,136 @@ -import { describe, expect, it } from 'vitest'; -import { - ModelCallGate, - adaptiveModelTimeoutMs, - clampTimeoutToChainBudget, - geminiThinkingBudgetTokens, - MODEL_FALLBACK_CHAIN_BUDGET_MS, - MODEL_TIMEOUT_BASE_MS, - MODEL_TIMEOUT_MAX_MS, - OUTPUT_TOKENS_FLOOR, - resolveOutputTokenCeiling, - reviewOutputBudgetTokens, -} from '../../src/limits'; -import { generatorFindingCap } from '@codraoss/core/prompts/file-review'; - -// Overrun output repairs to a JSON prefix, silently emptying tail files: indistinguishable from clean. -describe('reviewOutputBudgetTokens', () => { - it('never asks for less than the floor', () => { - expect(reviewOutputBudgetTokens({ findingCap: 1, fileCount: 1 })).toBe(OUTPUT_TOKENS_FLOOR); - }); - - it('grows with the number of findings the prompt asked for', () => { - const one = reviewOutputBudgetTokens({ findingCap: generatorFindingCap(10), fileCount: 1 }); - const bin = reviewOutputBudgetTokens({ findingCap: generatorFindingCap(10), fileCount: 6 }); - expect(bin).toBeGreaterThan(one); - expect(bin).toBeGreaterThan(OUTPUT_TOKENS_FLOOR); - }); - - it('covers the bin ask that the old flat ceiling could not', () => { - // Regression: 6 files x 20 findings exceeded the old flat 8192 ceiling. - expect(reviewOutputBudgetTokens({ findingCap: 20, fileCount: 6 })).toBeGreaterThan(8_192); - }); -}); - -describe('resolveOutputTokenCeiling', () => { - it('falls back to the provider default when no budget is stated', () => { - expect(resolveOutputTokenCeiling(undefined, 65_536, 8_192)).toBe(8_192); - expect(resolveOutputTokenCeiling(0, 65_536, 8_192)).toBe(8_192); - expect(resolveOutputTokenCeiling(Number.NaN, 65_536, 8_192)).toBe(8_192); - }); - - it('never drops below the provider default, and never exceeds its max', () => { - expect(resolveOutputTokenCeiling(1_000, 65_536, 8_192)).toBe(8_192); - expect(resolveOutputTokenCeiling(20_000, 65_536, 8_192)).toBe(20_000); - expect(resolveOutputTokenCeiling(999_999, 65_536, 8_192)).toBe(65_536); - expect(resolveOutputTokenCeiling(20_000, 4_096, 8_192)).toBe(4_096); - }); -}); - -describe('geminiThinkingBudgetTokens', () => { - // Thinking shares maxOutputTokens with JSON output, so a higher ceiling must mostly buy answer room. - it('stays a minority of the ceiling', () => { - expect(geminiThinkingBudgetTokens(32_768)).toBeLessThan(32_768 / 3); - expect(geminiThinkingBudgetTokens(8_192)).toBeLessThan(8_192 / 3); - }); - - it('stays inside the band every Gemini 2.5 model accepts', () => { - // Above 0 (Pro rejects it) and under Flash's 8192 ceiling. - expect(geminiThinkingBudgetTokens(1_024)).toBeGreaterThanOrEqual(1_024); - expect(geminiThinkingBudgetTokens(65_536)).toBeLessThanOrEqual(8_192); - }); -}); - -describe('generatorFindingCap', () => { - // Bin size intentionally doesn't divide this cap; measured output stays ~3% of ceiling. - it('is 2x max_comments regardless of how many files share the call', () => { - expect(generatorFindingCap(10)).toBe(20); - expect(generatorFindingCap(1)).toBe(2); - }); -}); - -describe('adaptiveModelTimeoutMs', () => { - it('uses the base budget for small diffs', () => { - expect(adaptiveModelTimeoutMs(0)).toBe(MODEL_TIMEOUT_BASE_MS); - expect(adaptiveModelTimeoutMs(100)).toBe(MODEL_TIMEOUT_BASE_MS); - expect(adaptiveModelTimeoutMs(undefined)).toBe(MODEL_TIMEOUT_BASE_MS); - expect(adaptiveModelTimeoutMs(null)).toBe(MODEL_TIMEOUT_BASE_MS); - }); - - it('scales with diff size beyond the free-line allowance', () => { - expect(adaptiveModelTimeoutMs(200)).toBe(MODEL_TIMEOUT_BASE_MS + 100 * 100); - expect(adaptiveModelTimeoutMs(250)).toBeGreaterThan(adaptiveModelTimeoutMs(150)); - }); - - it('caps at the maximum regardless of diff size', () => { - expect(adaptiveModelTimeoutMs(100_000)).toBe(MODEL_TIMEOUT_MAX_MS); - }); -}); - -describe('clampTimeoutToChainBudget', () => { - it('leaves every budget the adaptive ceiling can produce untouched', () => { - expect(clampTimeoutToChainBudget(MODEL_TIMEOUT_MAX_MS)).toBe(MODEL_TIMEOUT_MAX_MS); - expect(clampTimeoutToChainBudget(MODEL_TIMEOUT_BASE_MS)).toBe(MODEL_TIMEOUT_BASE_MS); - }); - - // Chain head is exempt from the budget check, so ceiling must stay <= chain budget. - it('holds the ceiling under the chain budget', () => { - expect(MODEL_TIMEOUT_MAX_MS).toBeLessThanOrEqual(MODEL_FALLBACK_CHAIN_BUDGET_MS); - expect(clampTimeoutToChainBudget(MODEL_FALLBACK_CHAIN_BUDGET_MS + 10_000)).toBe(MODEL_FALLBACK_CHAIN_BUDGET_MS); - }); -}); - -describe('ModelCallGate', () => { - it('never runs more than the limit concurrently and eventually runs everything', async () => { - const gate = new ModelCallGate(2); - let active = 0; - let peak = 0; - const done: number[] = []; - - const task = (id: number) => - gate.run(async () => { - active++; - peak = Math.max(peak, active); - await Promise.resolve(); - await Promise.resolve(); - active--; - done.push(id); - }); - - await Promise.all([task(1), task(2), task(3), task(4), task(5)]); - - expect(peak).toBeLessThanOrEqual(2); - expect(done).toHaveLength(5); - }); - - it('releases the slot when a gated call rejects', async () => { - const gate = new ModelCallGate(1); - - await expect(gate.run(async () => { - throw new Error('boom'); - })).rejects.toThrow('boom'); - - const result = await gate.run(async () => 'ok'); - expect(result).toBe('ok'); - }); -}); +import { describe, expect, it } from 'vitest'; +import { + ModelCallGate, + adaptiveModelTimeoutMs, + clampTimeoutToChainBudget, + geminiThinkingBudgetTokens, + MODEL_FALLBACK_CHAIN_BUDGET_MS, + MODEL_TIMEOUT_BASE_MS, + MODEL_TIMEOUT_MAX_MS, + OUTPUT_TOKENS_FLOOR, + resolveOutputTokenCeiling, + reviewOutputBudgetTokens, +} from '../../src/limits'; +import { generatorFindingCap } from '@codraoss/core/prompts/file-review'; + +// Overrun output repairs to a JSON prefix, silently emptying tail files: indistinguishable from clean. +describe('reviewOutputBudgetTokens', () => { + it('never asks for less than the floor', () => { + expect(reviewOutputBudgetTokens({ findingCap: 1, fileCount: 1 })).toBe(OUTPUT_TOKENS_FLOOR); + }); + + it('grows with the number of findings the prompt asked for', () => { + const one = reviewOutputBudgetTokens({ findingCap: generatorFindingCap(10), fileCount: 1 }); + const bin = reviewOutputBudgetTokens({ findingCap: generatorFindingCap(10), fileCount: 6 }); + expect(bin).toBeGreaterThan(one); + expect(bin).toBeGreaterThan(OUTPUT_TOKENS_FLOOR); + }); + + it('covers the bin ask that the old flat ceiling could not', () => { + // Regression: 6 files x 20 findings exceeded the old flat 8192 ceiling. + expect(reviewOutputBudgetTokens({ findingCap: 20, fileCount: 6 })).toBeGreaterThan(8_192); + }); +}); + +describe('resolveOutputTokenCeiling', () => { + it('falls back to the provider default when no budget is stated', () => { + expect(resolveOutputTokenCeiling(undefined, 65_536, 8_192)).toBe(8_192); + expect(resolveOutputTokenCeiling(0, 65_536, 8_192)).toBe(8_192); + expect(resolveOutputTokenCeiling(Number.NaN, 65_536, 8_192)).toBe(8_192); + }); + + it('never drops below the provider default, and never exceeds its max', () => { + expect(resolveOutputTokenCeiling(1_000, 65_536, 8_192)).toBe(8_192); + expect(resolveOutputTokenCeiling(20_000, 65_536, 8_192)).toBe(20_000); + expect(resolveOutputTokenCeiling(999_999, 65_536, 8_192)).toBe(65_536); + expect(resolveOutputTokenCeiling(20_000, 4_096, 8_192)).toBe(4_096); + }); +}); + +describe('geminiThinkingBudgetTokens', () => { + // Thinking shares maxOutputTokens with JSON output, so a higher ceiling must mostly buy answer room. + it('stays a minority of the ceiling', () => { + expect(geminiThinkingBudgetTokens(32_768)).toBeLessThan(32_768 / 3); + expect(geminiThinkingBudgetTokens(8_192)).toBeLessThan(8_192 / 3); + }); + + it('stays inside the band every Gemini 2.5 model accepts', () => { + // Above 0 (Pro rejects it) and under Flash's 8192 ceiling. + expect(geminiThinkingBudgetTokens(1_024)).toBeGreaterThanOrEqual(1_024); + expect(geminiThinkingBudgetTokens(65_536)).toBeLessThanOrEqual(8_192); + }); +}); + +describe('generatorFindingCap', () => { + // Bin size intentionally doesn't divide this cap; measured output stays ~3% of ceiling. + it('is 2x max_comments regardless of how many files share the call', () => { + expect(generatorFindingCap(10)).toBe(20); + expect(generatorFindingCap(1)).toBe(2); + }); +}); + +describe('adaptiveModelTimeoutMs', () => { + it('uses the base budget for small diffs', () => { + expect(adaptiveModelTimeoutMs(0)).toBe(MODEL_TIMEOUT_BASE_MS); + expect(adaptiveModelTimeoutMs(100)).toBe(MODEL_TIMEOUT_BASE_MS); + expect(adaptiveModelTimeoutMs(undefined)).toBe(MODEL_TIMEOUT_BASE_MS); + expect(adaptiveModelTimeoutMs(null)).toBe(MODEL_TIMEOUT_BASE_MS); + }); + + it('scales with diff size beyond the free-line allowance', () => { + expect(adaptiveModelTimeoutMs(200)).toBe(MODEL_TIMEOUT_BASE_MS + 100 * 100); + expect(adaptiveModelTimeoutMs(250)).toBeGreaterThan(adaptiveModelTimeoutMs(150)); + }); + + it('caps at the maximum regardless of diff size', () => { + expect(adaptiveModelTimeoutMs(100_000)).toBe(MODEL_TIMEOUT_MAX_MS); + }); +}); + +describe('clampTimeoutToChainBudget', () => { + it('leaves every budget the adaptive ceiling can produce untouched', () => { + expect(clampTimeoutToChainBudget(MODEL_TIMEOUT_MAX_MS)).toBe(MODEL_TIMEOUT_MAX_MS); + expect(clampTimeoutToChainBudget(MODEL_TIMEOUT_BASE_MS)).toBe(MODEL_TIMEOUT_BASE_MS); + }); + + // Chain head is exempt from the budget check, so ceiling must stay <= chain budget. + it('holds the ceiling under the chain budget', () => { + expect(MODEL_TIMEOUT_MAX_MS).toBeLessThanOrEqual(MODEL_FALLBACK_CHAIN_BUDGET_MS); + expect(clampTimeoutToChainBudget(MODEL_FALLBACK_CHAIN_BUDGET_MS + 10_000)).toBe(MODEL_FALLBACK_CHAIN_BUDGET_MS); + }); +}); + +describe('ModelCallGate', () => { + it('never runs more than the limit concurrently and eventually runs everything', async () => { + const gate = new ModelCallGate(2); + let active = 0; + let peak = 0; + const done: number[] = []; + + const task = (id: number) => + gate.run(async () => { + active++; + peak = Math.max(peak, active); + await Promise.resolve(); + await Promise.resolve(); + active--; + done.push(id); + }); + + await Promise.all([task(1), task(2), task(3), task(4), task(5)]); + + expect(peak).toBeLessThanOrEqual(2); + expect(done).toHaveLength(5); + }); + + it('releases the slot when a gated call rejects', async () => { + const gate = new ModelCallGate(1); + + await expect(gate.run(async () => { + throw new Error('boom'); + })).rejects.toThrow('boom'); + + const result = await gate.run(async () => 'ok'); + expect(result).toBe('ok'); + }); +}); diff --git a/packages/models/test/model/output-batch.spec.ts b/packages/models/test/model/output-batch.spec.ts index 88d79cd4..f9d6a394 100644 --- a/packages/models/test/model/output-batch.spec.ts +++ b/packages/models/test/model/output-batch.spec.ts @@ -1,83 +1,83 @@ -import { describe, expect, it } from 'vitest'; -import { parseRawBatchPayload } from '@codraoss/core/model-output'; - -function nested(paths: string[]) { - return { - files: paths.map((path, i) => ({ - absolute_file_path: path, - findings: [ - { - evidence: `const value${i} = 1;`, - code_location: { absolute_file_path: path, line: i + 1 }, - claim_type: 'other', - title: `Finding in ${path}`, - body: 'Body text.', - priority: 2, - }, - ], - overall_explanation: `Summary for ${path}`, - overall_correctness: 'patch is incorrect', - })), - overall_confidence_score: 0.7, - }; -} - -describe('parseRawBatchPayload', () => { - // Anchoring on the first `"findings"` lands on files[0]'s brace, dropping every other file. - it('recovers every file, bare or fenced, with per-file verdict and summary intact', () => { - const payload = nested(['src/a.ts', 'src/b.ts', 'src/c.ts']); - payload.files[0].overall_correctness = 'patch is correct'; - payload.files[0].overall_explanation = 'Nothing wrong here'; - - const bare = parseRawBatchPayload(JSON.stringify(payload)); - if (bare.shape !== 'nested') throw new Error('expected nested'); - expect(bare.data.files.map(f => f.absolute_file_path)).toEqual(['src/a.ts', 'src/b.ts', 'src/c.ts']); - expect(bare.data.files[0].overall_correctness).toBe('patch is correct'); - expect(bare.data.files[0].overall_explanation).toBe('Nothing wrong here'); - expect(bare.data.files[1].overall_correctness).toBe('patch is incorrect'); - - const fenced = parseRawBatchPayload(`Here is my review:\n\n\`\`\`json\n${JSON.stringify(payload)}\n\`\`\`\n\nLet me know.`); - if (fenced.shape !== 'nested') throw new Error('expected nested'); - expect(fenced.data.files).toHaveLength(3); - }); - - - // A truncated response repairs into JSON whose last entry has no `findings` key, so defaulting to - // [] would approve unexamined code. An explicit [] is honoured. - it('drops an entry with no findings key, but keeps an explicitly empty one', () => { - const complete = nested(['src/a.ts']).files[0]; - const truncated = parseRawBatchPayload(`{"files":[${JSON.stringify(complete)},{"absolute_file_path":"src/b.ts"`); - if (truncated.shape !== 'nested') throw new Error('expected nested'); - expect(truncated.data.files.map(f => f.absolute_file_path)).toEqual(['src/a.ts']); - - const empty = parseRawBatchPayload(JSON.stringify({ - files: [{ absolute_file_path: 'src/a.ts', findings: [], overall_correctness: 'patch is correct' }], - })); - if (empty.shape !== 'nested') throw new Error('expected nested'); - expect(empty.data.files[0].findings).toEqual([]); - }); - - // A weak fallback model emits the single-file shape; without recovery the whole bin is unreviewed. - it('falls back to the flat shape, and throws when nothing is recognisable', () => { - const flat = parseRawBatchPayload(JSON.stringify({ - findings: [{ - evidence: 'const x = 1;', - code_location: { absolute_file_path: 'src/a.ts', line: 3 }, - claim_type: 'other', - title: 'Flat finding', - body: 'Body.', - priority: 1, - }], - overall_correctness: 'patch is incorrect', - overall_explanation: 'Flat summary', - })); - expect(flat.shape).toBe('flat'); - if (flat.shape !== 'flat') throw new Error('unreachable'); - expect(flat.data.findings[0].code_location.absolute_file_path).toBe('src/a.ts'); - - // Must throw, not resolve empty: the throw falls to the next model in the chain. - expect(() => parseRawBatchPayload('I could not review this code.')).toThrow(); - expect(() => parseRawBatchPayload(JSON.stringify({ files: [] }))).toThrow(); - expect(() => parseRawBatchPayload(JSON.stringify({ files: [{ no_path_here: true }] }))).toThrow(); - }); -}); +import { describe, expect, it } from 'vitest'; +import { parseRawBatchPayload } from '@codraoss/core/model-output'; + +function nested(paths: string[]) { + return { + files: paths.map((path, i) => ({ + absolute_file_path: path, + findings: [ + { + evidence: `const value${i} = 1;`, + code_location: { absolute_file_path: path, line: i + 1 }, + claim_type: 'other', + title: `Finding in ${path}`, + body: 'Body text.', + priority: 2, + }, + ], + overall_explanation: `Summary for ${path}`, + overall_correctness: 'patch is incorrect', + })), + overall_confidence_score: 0.7, + }; +} + +describe('parseRawBatchPayload', () => { + // Anchoring on the first `"findings"` lands on files[0]'s brace, dropping every other file. + it('recovers every file, bare or fenced, with per-file verdict and summary intact', () => { + const payload = nested(['src/a.ts', 'src/b.ts', 'src/c.ts']); + payload.files[0].overall_correctness = 'patch is correct'; + payload.files[0].overall_explanation = 'Nothing wrong here'; + + const bare = parseRawBatchPayload(JSON.stringify(payload)); + if (bare.shape !== 'nested') throw new Error('expected nested'); + expect(bare.data.files.map(f => f.absolute_file_path)).toEqual(['src/a.ts', 'src/b.ts', 'src/c.ts']); + expect(bare.data.files[0].overall_correctness).toBe('patch is correct'); + expect(bare.data.files[0].overall_explanation).toBe('Nothing wrong here'); + expect(bare.data.files[1].overall_correctness).toBe('patch is incorrect'); + + const fenced = parseRawBatchPayload(`Here is my review:\n\n\`\`\`json\n${JSON.stringify(payload)}\n\`\`\`\n\nLet me know.`); + if (fenced.shape !== 'nested') throw new Error('expected nested'); + expect(fenced.data.files).toHaveLength(3); + }); + + + // A truncated response repairs into JSON whose last entry has no `findings` key, so defaulting to + // [] would approve unexamined code. An explicit [] is honoured. + it('drops an entry with no findings key, but keeps an explicitly empty one', () => { + const complete = nested(['src/a.ts']).files[0]; + const truncated = parseRawBatchPayload(`{"files":[${JSON.stringify(complete)},{"absolute_file_path":"src/b.ts"`); + if (truncated.shape !== 'nested') throw new Error('expected nested'); + expect(truncated.data.files.map(f => f.absolute_file_path)).toEqual(['src/a.ts']); + + const empty = parseRawBatchPayload(JSON.stringify({ + files: [{ absolute_file_path: 'src/a.ts', findings: [], overall_correctness: 'patch is correct' }], + })); + if (empty.shape !== 'nested') throw new Error('expected nested'); + expect(empty.data.files[0].findings).toEqual([]); + }); + + // A weak fallback model emits the single-file shape; without recovery the whole bin is unreviewed. + it('falls back to the flat shape, and throws when nothing is recognisable', () => { + const flat = parseRawBatchPayload(JSON.stringify({ + findings: [{ + evidence: 'const x = 1;', + code_location: { absolute_file_path: 'src/a.ts', line: 3 }, + claim_type: 'other', + title: 'Flat finding', + body: 'Body.', + priority: 1, + }], + overall_correctness: 'patch is incorrect', + overall_explanation: 'Flat summary', + })); + expect(flat.shape).toBe('flat'); + if (flat.shape !== 'flat') throw new Error('unreachable'); + expect(flat.data.findings[0].code_location.absolute_file_path).toBe('src/a.ts'); + + // Must throw, not resolve empty: the throw falls to the next model in the chain. + expect(() => parseRawBatchPayload('I could not review this code.')).toThrow(); + expect(() => parseRawBatchPayload(JSON.stringify({ files: [] }))).toThrow(); + expect(() => parseRawBatchPayload(JSON.stringify({ files: [{ no_path_here: true }] }))).toThrow(); + }); +}); diff --git a/packages/models/test/model/output.spec.ts b/packages/models/test/model/output.spec.ts index cbf67d24..49547f5f 100644 --- a/packages/models/test/model/output.spec.ts +++ b/packages/models/test/model/output.spec.ts @@ -1,231 +1,231 @@ -import { parseFileReviewResponse, dedupeFindings } from '@codraoss/core/model-output'; -import type { FileDiff } from '@codraoss/core/diff'; -import type { ParsedReviewComment } from '@codraoss/schema'; - -describe('Model Output Parsing Deep Dive', () => { - const mockFile: FileDiff = { - path: 'test.ts', - previousPath: null, - isNew: false, - isDeleted: false, - isBinary: false, - lineCount: 10, - hunks: [ - { - header: '@@ -1,5 +1,5 @@', - lines: [ - { kind: 'context', content: 'older', newLineNumber: 1, position: 1 }, - { kind: 'add', content: 'new line', newLineNumber: 2, position: 2 }, - { kind: 'context', content: 'older', newLineNumber: 3, position: 3 }, - ], - }, - ], - }; - - it('extracts JSON from markdown code blocks with surrounding text', () => { - const rawOutput = ` -Here is my review: -\`\`\`json -{ - "findings": [{ - "title": "Good code", - "body": "This looks fine.", - "priority": 2, - "evidence": "new line", - "code_location": { "absolute_file_path": "test.ts", "line": 2 } - }], - "overall_correctness": "patch is correct", - "overall_explanation": "All good" -} -\`\`\` -Hope this helps!`; - - const result = parseFileReviewResponse(rawOutput, mockFile); - expect(result.comments).toHaveLength(1); - expect(result.verdict).toBe('comment'); - }); - - it('salvages malformed JSON with unescaped newlines using jsonrepair', () => { - const rawOutput = ` -{ - "findings": [{ - "title": "Multiline -Issue", - "body": "This has -unescaped newlines", - "priority": 1, - "evidence": "new line", - "code_location": { "absolute_file_path": "test.ts", "line": 2 } - }], - "overall_correctness": "issues found", - "overall_explanation": "explanation" -}`; - - const result = parseFileReviewResponse(rawOutput, mockFile); - // our cleanText flattens newlines in titles to spaces - expect(result.comments[0].title).toBe('Multiline Issue'); - }); - - it('removes conversational tags and emojis from titles and bodies', () => { - const rawOutput = ` -{ - "findings": [{ - "title": "🚀 [PERFORMANCE] Optimization needed", - "body": "⚠️ HIGH: You should optimize this.", - "priority": 0, - "evidence": "new line", - "code_location": { "absolute_file_path": "test.ts", "line": 2 } - }], - "overall_correctness": "issues found", - "overall_explanation": "explanation" -}`; - - const result = parseFileReviewResponse(rawOutput, mockFile); - expect(result.comments[0].title).toBe('Optimization needed'); - }); - - // The matched quote is the anchor, so a wrong reported line must not move the comment. - it('anchors on the quoted line and ignores a wrong reported line number', () => { - const rawOutput = ` -{ - "findings": [{ - "title": "Off-target", - "body": "Targeting line 5", - "priority": 2, - "evidence": "new line", - "code_location": { "absolute_file_path": "test.ts", "line": 5 } - }], - "overall_correctness": "issues found", - "overall_explanation": "explanation" -}`; - - const result = parseFileReviewResponse(rawOutput, mockFile); - expect(result.comments[0].line).toBe(2); - }); - - it('drops a finding whose line is far outside the diff instead of relocating it', () => { - const rawOutput = ` -{ - "findings": [{ - "title": "Hallucinated location", - "body": "Targeting line 80", - "priority": 2, - "code_location": { "absolute_file_path": "test.ts", "line": 80 } - }], - "overall_correctness": "issues found", - "overall_explanation": "explanation" -}`; - - // A line this far out means the model was reasoning about code that isn't here. - const result = parseFileReviewResponse(rawOutput, mockFile); - expect(result.comments).toHaveLength(0); - expect(result.fileSummary).toContain('Additional Comments (Off-diff)'); - }); - - // `z.string().max(100)` on `title` rejects the whole file's review, not the one finding. - it('clips an over-long or non-string title instead of failing the whole file', () => { - const rawOutput = JSON.stringify({ - findings: [ - { - evidence: 'new line', - code_location: { absolute_file_path: 'test.ts', line: 2 }, - claim_type: 'other', - title: 'T'.repeat(150), - body: 'Long title finding.', - priority: 1, - }, - { - evidence: 'new line', - code_location: { absolute_file_path: 'test.ts', line: 2 }, - claim_type: 'other', - title: 42, - body: 'Non-string title finding.', - priority: 1, - }, - { - evidence: 'new line', - code_location: { absolute_file_path: 'test.ts', line: 2 }, - claim_type: 'other', - title: 'Healthy sibling', - body: 'This one is well formed.', - priority: 1, - }, - ], - overall_correctness: 'patch is correct', - overall_explanation: 'ok', - overall_confidence_score: 0.9, - }); - - const result = parseFileReviewResponse(rawOutput, mockFile); - - // The invariant: one malformed title must not take its well-formed siblings down with it. - expect(result.comments).toHaveLength(3); - for (const comment of result.comments) { - expect(comment.title.length).toBeLessThanOrEqual(100); - } - expect(result.comments[1].title).toBe('42'); - expect(result.comments[2].title).toBe('Healthy sibling'); - }); - - it('drops placeholder schema findings instead of failing validation', () => { - const rawOutput = ` -{ - "findings": [{ - "title": "", - "body": "", - "priority": "<0|1|2|3>", - "code_location": { - "absolute_file_path": "test.ts", - "line": "", - "line_range": { "start": "", "end": "" } - } - }], - "overall_correctness": "patch is correct", - "overall_explanation": "No concrete findings", - "overall_confidence_score": 0.5 -}`; - - const result = parseFileReviewResponse(rawOutput, mockFile); - expect(result.comments).toHaveLength(0); - expect(result.verdict).toBe('approve'); - }); - -}); - -describe('dedupeFindings', () => { - const make = (over: Partial): ParsedReviewComment => ({ - path: 'a.ts', - line: 1, - position: 1, - severity: 'P2', - category: 'quality', - title: 'Use of any', - body: 'body', - ...over, - }); - - // This used to assert the opposite, and the opposite was a bug: the key was the normalized title - // alone, so "Use of any" in three files became one comment and two real findings were dropped. - // Dedupe is a union over locations, not a merge of everything that happens to share a name. - it('keeps same-titled findings that are in different files', () => { - const result = dedupeFindings([ - make({ path: 'a.ts', severity: 'P3', confidenceScore: 0.4 }), - make({ path: 'b.ts', severity: 'P1', confidenceScore: 0.5 }), - make({ path: 'c.ts', severity: 'P3', confidenceScore: 0.9 }), - ]); - - expect(result.map((c) => c.path)).toEqual(['a.ts', 'b.ts', 'c.ts']); - }); - - it('collapses the same finding at the same place, keeping the strongest', () => { - const result = dedupeFindings([ - make({ severity: 'P3', confidenceScore: 0.4, anchorHash: 'aaaa' }), - make({ severity: 'P1', confidenceScore: 0.5, anchorHash: 'aaaa' }), - make({ severity: 'P3', confidenceScore: 0.9, anchorHash: 'aaaa' }), - ]); - - expect(result).toHaveLength(1); - expect(result[0].severity).toBe('P1'); - }); - -}); +import { parseFileReviewResponse, dedupeFindings } from '@codraoss/core/model-output'; +import type { FileDiff } from '@codraoss/core/diff'; +import type { ParsedReviewComment } from '@codraoss/schema'; + +describe('Model Output Parsing Deep Dive', () => { + const mockFile: FileDiff = { + path: 'test.ts', + previousPath: null, + isNew: false, + isDeleted: false, + isBinary: false, + lineCount: 10, + hunks: [ + { + header: '@@ -1,5 +1,5 @@', + lines: [ + { kind: 'context', content: 'older', newLineNumber: 1, position: 1 }, + { kind: 'add', content: 'new line', newLineNumber: 2, position: 2 }, + { kind: 'context', content: 'older', newLineNumber: 3, position: 3 }, + ], + }, + ], + }; + + it('extracts JSON from markdown code blocks with surrounding text', () => { + const rawOutput = ` +Here is my review: +\`\`\`json +{ + "findings": [{ + "title": "Good code", + "body": "This looks fine.", + "priority": 2, + "evidence": "new line", + "code_location": { "absolute_file_path": "test.ts", "line": 2 } + }], + "overall_correctness": "patch is correct", + "overall_explanation": "All good" +} +\`\`\` +Hope this helps!`; + + const result = parseFileReviewResponse(rawOutput, mockFile); + expect(result.comments).toHaveLength(1); + expect(result.verdict).toBe('comment'); + }); + + it('salvages malformed JSON with unescaped newlines using jsonrepair', () => { + const rawOutput = ` +{ + "findings": [{ + "title": "Multiline +Issue", + "body": "This has +unescaped newlines", + "priority": 1, + "evidence": "new line", + "code_location": { "absolute_file_path": "test.ts", "line": 2 } + }], + "overall_correctness": "issues found", + "overall_explanation": "explanation" +}`; + + const result = parseFileReviewResponse(rawOutput, mockFile); + // our cleanText flattens newlines in titles to spaces + expect(result.comments[0].title).toBe('Multiline Issue'); + }); + + it('removes conversational tags and emojis from titles and bodies', () => { + const rawOutput = ` +{ + "findings": [{ + "title": "🚀 [PERFORMANCE] Optimization needed", + "body": "⚠️ HIGH: You should optimize this.", + "priority": 0, + "evidence": "new line", + "code_location": { "absolute_file_path": "test.ts", "line": 2 } + }], + "overall_correctness": "issues found", + "overall_explanation": "explanation" +}`; + + const result = parseFileReviewResponse(rawOutput, mockFile); + expect(result.comments[0].title).toBe('Optimization needed'); + }); + + // The matched quote is the anchor, so a wrong reported line must not move the comment. + it('anchors on the quoted line and ignores a wrong reported line number', () => { + const rawOutput = ` +{ + "findings": [{ + "title": "Off-target", + "body": "Targeting line 5", + "priority": 2, + "evidence": "new line", + "code_location": { "absolute_file_path": "test.ts", "line": 5 } + }], + "overall_correctness": "issues found", + "overall_explanation": "explanation" +}`; + + const result = parseFileReviewResponse(rawOutput, mockFile); + expect(result.comments[0].line).toBe(2); + }); + + it('drops a finding whose line is far outside the diff instead of relocating it', () => { + const rawOutput = ` +{ + "findings": [{ + "title": "Hallucinated location", + "body": "Targeting line 80", + "priority": 2, + "code_location": { "absolute_file_path": "test.ts", "line": 80 } + }], + "overall_correctness": "issues found", + "overall_explanation": "explanation" +}`; + + // A line this far out means the model was reasoning about code that isn't here. + const result = parseFileReviewResponse(rawOutput, mockFile); + expect(result.comments).toHaveLength(0); + expect(result.fileSummary).toContain('Additional Comments (Off-diff)'); + }); + + // `z.string().max(100)` on `title` rejects the whole file's review, not the one finding. + it('clips an over-long or non-string title instead of failing the whole file', () => { + const rawOutput = JSON.stringify({ + findings: [ + { + evidence: 'new line', + code_location: { absolute_file_path: 'test.ts', line: 2 }, + claim_type: 'other', + title: 'T'.repeat(150), + body: 'Long title finding.', + priority: 1, + }, + { + evidence: 'new line', + code_location: { absolute_file_path: 'test.ts', line: 2 }, + claim_type: 'other', + title: 42, + body: 'Non-string title finding.', + priority: 1, + }, + { + evidence: 'new line', + code_location: { absolute_file_path: 'test.ts', line: 2 }, + claim_type: 'other', + title: 'Healthy sibling', + body: 'This one is well formed.', + priority: 1, + }, + ], + overall_correctness: 'patch is correct', + overall_explanation: 'ok', + overall_confidence_score: 0.9, + }); + + const result = parseFileReviewResponse(rawOutput, mockFile); + + // The invariant: one malformed title must not take its well-formed siblings down with it. + expect(result.comments).toHaveLength(3); + for (const comment of result.comments) { + expect(comment.title.length).toBeLessThanOrEqual(100); + } + expect(result.comments[1].title).toBe('42'); + expect(result.comments[2].title).toBe('Healthy sibling'); + }); + + it('drops placeholder schema findings instead of failing validation', () => { + const rawOutput = ` +{ + "findings": [{ + "title": "", + "body": "", + "priority": "<0|1|2|3>", + "code_location": { + "absolute_file_path": "test.ts", + "line": "", + "line_range": { "start": "", "end": "" } + } + }], + "overall_correctness": "patch is correct", + "overall_explanation": "No concrete findings", + "overall_confidence_score": 0.5 +}`; + + const result = parseFileReviewResponse(rawOutput, mockFile); + expect(result.comments).toHaveLength(0); + expect(result.verdict).toBe('approve'); + }); + +}); + +describe('dedupeFindings', () => { + const make = (over: Partial): ParsedReviewComment => ({ + path: 'a.ts', + line: 1, + position: 1, + severity: 'P2', + category: 'quality', + title: 'Use of any', + body: 'body', + ...over, + }); + + // This used to assert the opposite, and the opposite was a bug: the key was the normalized title + // alone, so "Use of any" in three files became one comment and two real findings were dropped. + // Dedupe is a union over locations, not a merge of everything that happens to share a name. + it('keeps same-titled findings that are in different files', () => { + const result = dedupeFindings([ + make({ path: 'a.ts', severity: 'P3', confidenceScore: 0.4 }), + make({ path: 'b.ts', severity: 'P1', confidenceScore: 0.5 }), + make({ path: 'c.ts', severity: 'P3', confidenceScore: 0.9 }), + ]); + + expect(result.map((c) => c.path)).toEqual(['a.ts', 'b.ts', 'c.ts']); + }); + + it('collapses the same finding at the same place, keeping the strongest', () => { + const result = dedupeFindings([ + make({ severity: 'P3', confidenceScore: 0.4, anchorHash: 'aaaa' }), + make({ severity: 'P1', confidenceScore: 0.5, anchorHash: 'aaaa' }), + make({ severity: 'P3', confidenceScore: 0.9, anchorHash: 'aaaa' }), + ]); + + expect(result).toHaveLength(1); + expect(result[0].severity).toBe('P1'); + }); + +}); diff --git a/packages/models/test/model/rate-limit-parse.spec.ts b/packages/models/test/model/rate-limit-parse.spec.ts index 7fe41613..9b47991c 100644 --- a/packages/models/test/model/rate-limit-parse.spec.ts +++ b/packages/models/test/model/rate-limit-parse.spec.ts @@ -1,77 +1,77 @@ -import { describe, expect, it } from 'vitest'; -import { isPlausibleTokenBucket, parseRateLimitFromError } from '@codraoss/models'; - -// Verbatim from production: a free-tier 429 whose only stated quota counts REQUESTS, not tokens. -const REQUESTS_QUOTA_429 = [ - 'You exceeded your current quota, please check your plan and billing details.', - 'For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits.', - 'To monitor your current usage, head to: https://ai.dev/rate-limit.', - '* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests, limit: 15, model: gemini-3.5-flash-lite', - 'Please retry in 21.35281435s.', -].join('\n'); - -const TOKENS_QUOTA_429 = - '* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_input_token_count, limit: 16000, model: gemini-2.5-flash Please retry in 26.9s.'; - -describe('parseRateLimitFromError', () => { - // The regression: `limit: 15` is 15 requests per minute. Reading it as a 15-token bucket made - // skipReason refuse every prompt over 12 tokens for the rest of the job -- a model that was merely - // busy for a minute was taken out for 24 hours, and the whole fallback chain with it. - it('does not read a request-count quota as a token bucket', () => { - const parsed = parseRateLimitFromError(new Error(REQUESTS_QUOTA_429)); - - expect(parsed.limitTokens).toBeUndefined(); - // The cool-off is still learned: the model IS rate-limited, just not by prompt size. - expect(parsed.retryAfterMs).toBeCloseTo(21352.81435, 3); - }); - - it('reads a genuine token quota', () => { - const parsed = parseRateLimitFromError(new Error(TOKENS_QUOTA_429)); - - expect(parsed.limitTokens).toBe(16000); - expect(parsed.retryAfterMs).toBe(26900); - }); - - // A body may state several violated quotas, and the request count often comes first -- which a bare - // /limit:\s*(\d+)/ would happily return as the bucket size. - it('picks the token quota out of a multi-quota body, not the first limit stated', () => { - const parsed = parseRateLimitFromError(new Error([ - '* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests, limit: 15, model: m', - '* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_input_token_count, limit: 16000, model: m', - ].join('\n'))); - - expect(parsed.limitTokens).toBe(16000); - }); - - it('takes the smallest stated token bucket, which rejects a prompt first', () => { - const parsed = parseRateLimitFromError(new Error([ - '* Quota exceeded for metric: x/input_token_count, limit: 32000, model: m', - '* Quota exceeded for metric: x/output_token_count, limit: 8000, model: m', - ].join('\n'))); - - expect(parsed.limitTokens).toBe(8000); - }); - - it('rejects an implausibly small token bucket even from a token metric', () => { - const parsed = parseRateLimitFromError( - new Error('* Quota exceeded for metric: x/input_token_count, limit: 15, model: m'), - ); - - expect(parsed.limitTokens).toBeUndefined(); - }); - - it('returns nothing for an error that states no quota at all', () => { - const parsed = parseRateLimitFromError(new Error('Resource has been exhausted.')); - - expect(parsed.limitTokens).toBeUndefined(); - expect(parsed.retryAfterMs).toBeUndefined(); - }); -}); - -describe('isPlausibleTokenBucket', () => { - it('rejects request counts and accepts real buckets', () => { - expect(isPlausibleTokenBucket(15)).toBe(false); - expect(isPlausibleTokenBucket(undefined)).toBe(false); - expect(isPlausibleTokenBucket(16000)).toBe(true); - }); -}); +import { describe, expect, it } from 'vitest'; +import { isPlausibleTokenBucket, parseRateLimitFromError } from '@codraoss/models'; + +// Verbatim from production: a free-tier 429 whose only stated quota counts REQUESTS, not tokens. +const REQUESTS_QUOTA_429 = [ + 'You exceeded your current quota, please check your plan and billing details.', + 'For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits.', + 'To monitor your current usage, head to: https://ai.dev/rate-limit.', + '* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests, limit: 15, model: gemini-3.5-flash-lite', + 'Please retry in 21.35281435s.', +].join('\n'); + +const TOKENS_QUOTA_429 = + '* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_input_token_count, limit: 16000, model: gemini-2.5-flash Please retry in 26.9s.'; + +describe('parseRateLimitFromError', () => { + // The regression: `limit: 15` is 15 requests per minute. Reading it as a 15-token bucket made + // skipReason refuse every prompt over 12 tokens for the rest of the job -- a model that was merely + // busy for a minute was taken out for 24 hours, and the whole fallback chain with it. + it('does not read a request-count quota as a token bucket', () => { + const parsed = parseRateLimitFromError(new Error(REQUESTS_QUOTA_429)); + + expect(parsed.limitTokens).toBeUndefined(); + // The cool-off is still learned: the model IS rate-limited, just not by prompt size. + expect(parsed.retryAfterMs).toBeCloseTo(21352.81435, 3); + }); + + it('reads a genuine token quota', () => { + const parsed = parseRateLimitFromError(new Error(TOKENS_QUOTA_429)); + + expect(parsed.limitTokens).toBe(16000); + expect(parsed.retryAfterMs).toBe(26900); + }); + + // A body may state several violated quotas, and the request count often comes first -- which a bare + // /limit:\s*(\d+)/ would happily return as the bucket size. + it('picks the token quota out of a multi-quota body, not the first limit stated', () => { + const parsed = parseRateLimitFromError(new Error([ + '* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests, limit: 15, model: m', + '* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_input_token_count, limit: 16000, model: m', + ].join('\n'))); + + expect(parsed.limitTokens).toBe(16000); + }); + + it('takes the smallest stated token bucket, which rejects a prompt first', () => { + const parsed = parseRateLimitFromError(new Error([ + '* Quota exceeded for metric: x/input_token_count, limit: 32000, model: m', + '* Quota exceeded for metric: x/output_token_count, limit: 8000, model: m', + ].join('\n'))); + + expect(parsed.limitTokens).toBe(8000); + }); + + it('rejects an implausibly small token bucket even from a token metric', () => { + const parsed = parseRateLimitFromError( + new Error('* Quota exceeded for metric: x/input_token_count, limit: 15, model: m'), + ); + + expect(parsed.limitTokens).toBeUndefined(); + }); + + it('returns nothing for an error that states no quota at all', () => { + const parsed = parseRateLimitFromError(new Error('Resource has been exhausted.')); + + expect(parsed.limitTokens).toBeUndefined(); + expect(parsed.retryAfterMs).toBeUndefined(); + }); +}); + +describe('isPlausibleTokenBucket', () => { + it('rejects request counts and accepts real buckets', () => { + expect(isPlausibleTokenBucket(15)).toBe(false); + expect(isPlausibleTokenBucket(undefined)).toBe(false); + expect(isPlausibleTokenBucket(16000)).toBe(true); + }); +}); diff --git a/packages/models/test/model/service-chunking.spec.ts b/packages/models/test/model/service-chunking.spec.ts index d55b0bc4..c75eb028 100644 --- a/packages/models/test/model/service-chunking.spec.ts +++ b/packages/models/test/model/service-chunking.spec.ts @@ -1,242 +1,242 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ModelRunner } from '@codraoss/models'; - - - - - -import { createTestEnv, saveTestProviderApiKey, createTestModelRunner } from '../../../../test/helpers'; -import { defaultRepoConfig } from '@codraoss/schema'; -import { TokenTracker } from '@codraoss/core/token-tracker'; -import { geminiThinkingBudgetTokens, reviewOutputBudgetTokens } from '../../src/limits'; -import { reviewBreadth } from '@codraoss/core/prompts/file-review'; - -describe('ModelRunner: diff chunking', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('splits an oversized diff into capped chunks and reviews each in its own call', async () => { - const requestBodies: any[] = []; - const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { - requestBodies.push(JSON.parse(String(init?.body))); - return new Response( - JSON.stringify({ - candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - }); - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const service = createTestModelRunner(env); - const largeFile = { - path: 'src/large.ts', - previousPath: null, - isNew: false, - isDeleted: false, - isBinary: false, - lineCount: 900, - hunks: [ - { - header: '@@ -1,900 +1,900 @@', - lines: Array.from({ length: 900 }, (_, index) => ({ - kind: 'add' as const, - content: `const value${index} = ${index};`, - newLineNumber: index + 1, - position: index + 1, - })), - }, - ], - }; - - const response = await service.reviewFile({ - file: largeFile, - prTitle: 'Test', - prDescription: null, - config: { - ...defaultRepoConfig, - model: { - main: 'gemini-3.1-pro-preview', - fallbacks: [], - size_overrides: [], - }, - }, - totalLineCount: 500, - }); - - // 900 lines at the 800-line cap: two chunks, each its own model call. - expect(fetchMock).toHaveBeenCalledTimes(2); - const answerBudget = reviewOutputBudgetTokens({ - findingCap: reviewBreadth(defaultRepoConfig.review), - fileCount: 1, - }); - for (const body of requestBodies) { - // Room for the findings the prompt asked for, PLUS a bounded thinking budget on top -- thinking - // bills against the same ceiling, so sharing one flat 8192 truncated the JSON. - expect(body.generationConfig.thinkingConfig.thinkingBudget) - .toBe(geminiThinkingBudgetTokens(answerBudget)); - expect(body.generationConfig.maxOutputTokens) - .toBe(answerBudget + geminiThinkingBudgetTokens(answerBudget)); - // Proves the review grammar survives reviewFile -> callResolvedModel -> adapter. - expect(body.generationConfig.responseJsonSchema).toBeDefined(); - } - const firstPrompt = requestBodies[0].contents[0].parts[0].text as string; - expect(firstPrompt).toContain('const value799 = 799;'); - expect(firstPrompt).not.toContain('const value800 = 800;'); - const secondPrompt = requestBodies[1].contents[0].parts[0].text as string; - expect(secondPrompt).toContain('const value800 = 800;'); - expect(secondPrompt).toContain('const value899 = 899;'); - // The whole file is covered across the chunks, so nothing is dropped as truncated. - expect(response.reviewedLineCount).toBe(900); - expect(response.wasPromptTruncated).toBe(false); - }); - - // A flat cap of 4 silently dropped everything past line 3,200. The raise to 8 is opportunistic: - // chunks past the 4th run only on spare budget, so one runaway file can't starve its peers. - - describe('the opportunistic chunk tail', () => { - const okResponse = () => new Response( - JSON.stringify({ - candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - - const hugeFile = (lines: number) => ({ - path: 'src/server/core/review.ts', - previousPath: null, - isNew: false, - isDeleted: false, - isBinary: false, - lineCount: lines, - hunks: [{ - header: `@@ -1,${lines} +1,${lines} @@`, - lines: Array.from({ length: lines }, (_, index) => ({ - kind: 'add' as const, - content: `const value${index} = ${index};`, - newLineNumber: index + 1, - position: index + 1, - })), - }], - }); - - const reviewHugeFile = async (service: ModelRunner, lines: number) => service.reviewFile({ - file: hugeFile(lines), - prTitle: 'Test', - prDescription: null, - config: { - ...defaultRepoConfig, - model: { main: 'gemini-3.1-pro-preview', fallbacks: [], size_overrides: [] }, - }, - totalLineCount: lines, - }); - - it('reviews past the old four-chunk ceiling when the budget is healthy', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => okResponse()); - const env = createTestEnv(); - await saveTestProviderApiKey(env); - // A fresh tracker has the full safe budget, so the tail is affordable. - const service = createTestModelRunner(env, new TokenTracker()); - - // 3,749 lines at the 800-line cap is 5 chunks; the old cap dropped the fifth. - const response = await reviewHugeFile(service, 3_749); - - expect(fetchMock).toHaveBeenCalledTimes(5); - expect(response.reviewedLineCount).toBe(3_749); - expect(response.wasPromptTruncated).toBe(false); - }); - - it('stops at the base chunks and reports truncation when the budget is committed elsewhere', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => okResponse()); - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const tracker = new TokenTracker(); - // Below isNearLimit (25) but not enough spare for the tail once the base chunks have run. - tracker.incrementSubrequests(18); - const service = createTestModelRunner(env, tracker); - - const response = await reviewHugeFile(service, 3_749); - - // Four reviewed, the fifth yielded and reported as truncated rather than clean. - expect(fetchMock).toHaveBeenCalledTimes(4); - expect(response.reviewedLineCount).toBe(3_200); - expect(response.wasPromptTruncated).toBe(true); - }); - - it('still refuses to review a file in more than MAX_CHUNKS calls', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => okResponse()); - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const service = createTestModelRunner(env, new TokenTracker()); - - // 20,000 lines is 25 chunks: the hard cap must bind and report truncation. - const response = await reviewHugeFile(service, 20_000); - - expect(fetchMock).toHaveBeenCalledTimes(8); - expect(response.wasPromptTruncated).toBe(true); - }); - }); - - it('applies the compact prompt cap by producing smaller chunks after a prior transient failure', async () => { - const requestBodies: any[] = []; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { - requestBodies.push(JSON.parse(String(init?.body))); - return new Response( - JSON.stringify({ - candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - }); - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const service = createTestModelRunner(env); - const largeFile = { - path: 'src/large.ts', - previousPath: null, - isNew: false, - isDeleted: false, - isBinary: false, - lineCount: 900, - hunks: [ - { - header: '@@ -1,900 +1,900 @@', - lines: Array.from({ length: 900 }, (_, index) => ({ - kind: 'add' as const, - content: `const value${index} = ${index};`, - newLineNumber: index + 1, - position: index + 1, - })), - }, - ], - }; - - const response = await service.reviewFile({ - file: largeFile, - prTitle: 'Test', - prDescription: null, - config: { - ...defaultRepoConfig, - model: { - main: 'gemini-3.1-pro-preview', - fallbacks: [], - size_overrides: [], - }, - }, - totalLineCount: 900, - compactPrompt: true, - }); - - // compactPrompt lowers the per-call cap to 400, so 900 lines becomes three chunks, not two. - expect(requestBodies.length).toBe(3); - const firstPrompt = requestBodies[0].contents[0].parts[0].text as string; - expect(firstPrompt).toContain('const value399 = 399;'); - expect(firstPrompt).not.toContain('const value400 = 400;'); - expect(response.reviewedLineCount).toBe(900); - expect(response.wasPromptTruncated).toBe(false); - }); -}); +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ModelRunner } from '@codraoss/models'; + + + + + +import { createTestEnv, saveTestProviderApiKey, createTestModelRunner } from '../../../../test/helpers'; +import { defaultRepoConfig } from '@codraoss/schema'; +import { TokenTracker } from '@codraoss/core/token-tracker'; +import { geminiThinkingBudgetTokens, reviewOutputBudgetTokens } from '../../src/limits'; +import { reviewBreadth } from '@codraoss/core/prompts/file-review'; + +describe('ModelRunner: diff chunking', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('splits an oversized diff into capped chunks and reviews each in its own call', async () => { + const requestBodies: any[] = []; + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + requestBodies.push(JSON.parse(String(init?.body))); + return new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }); + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const service = createTestModelRunner(env); + const largeFile = { + path: 'src/large.ts', + previousPath: null, + isNew: false, + isDeleted: false, + isBinary: false, + lineCount: 900, + hunks: [ + { + header: '@@ -1,900 +1,900 @@', + lines: Array.from({ length: 900 }, (_, index) => ({ + kind: 'add' as const, + content: `const value${index} = ${index};`, + newLineNumber: index + 1, + position: index + 1, + })), + }, + ], + }; + + const response = await service.reviewFile({ + file: largeFile, + prTitle: 'Test', + prDescription: null, + config: { + ...defaultRepoConfig, + model: { + main: 'gemini-3.1-pro-preview', + fallbacks: [], + size_overrides: [], + }, + }, + totalLineCount: 500, + }); + + // 900 lines at the 800-line cap: two chunks, each its own model call. + expect(fetchMock).toHaveBeenCalledTimes(2); + const answerBudget = reviewOutputBudgetTokens({ + findingCap: reviewBreadth(defaultRepoConfig.review), + fileCount: 1, + }); + for (const body of requestBodies) { + // Room for the findings the prompt asked for, PLUS a bounded thinking budget on top -- thinking + // bills against the same ceiling, so sharing one flat 8192 truncated the JSON. + expect(body.generationConfig.thinkingConfig.thinkingBudget) + .toBe(geminiThinkingBudgetTokens(answerBudget)); + expect(body.generationConfig.maxOutputTokens) + .toBe(answerBudget + geminiThinkingBudgetTokens(answerBudget)); + // Proves the review grammar survives reviewFile -> callResolvedModel -> adapter. + expect(body.generationConfig.responseJsonSchema).toBeDefined(); + } + const firstPrompt = requestBodies[0].contents[0].parts[0].text as string; + expect(firstPrompt).toContain('const value799 = 799;'); + expect(firstPrompt).not.toContain('const value800 = 800;'); + const secondPrompt = requestBodies[1].contents[0].parts[0].text as string; + expect(secondPrompt).toContain('const value800 = 800;'); + expect(secondPrompt).toContain('const value899 = 899;'); + // The whole file is covered across the chunks, so nothing is dropped as truncated. + expect(response.reviewedLineCount).toBe(900); + expect(response.wasPromptTruncated).toBe(false); + }); + + // A flat cap of 4 silently dropped everything past line 3,200. The raise to 8 is opportunistic: + // chunks past the 4th run only on spare budget, so one runaway file can't starve its peers. + + describe('the opportunistic chunk tail', () => { + const okResponse = () => new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + + const hugeFile = (lines: number) => ({ + path: 'src/server/core/review.ts', + previousPath: null, + isNew: false, + isDeleted: false, + isBinary: false, + lineCount: lines, + hunks: [{ + header: `@@ -1,${lines} +1,${lines} @@`, + lines: Array.from({ length: lines }, (_, index) => ({ + kind: 'add' as const, + content: `const value${index} = ${index};`, + newLineNumber: index + 1, + position: index + 1, + })), + }], + }); + + const reviewHugeFile = async (service: ModelRunner, lines: number) => service.reviewFile({ + file: hugeFile(lines), + prTitle: 'Test', + prDescription: null, + config: { + ...defaultRepoConfig, + model: { main: 'gemini-3.1-pro-preview', fallbacks: [], size_overrides: [] }, + }, + totalLineCount: lines, + }); + + it('reviews past the old four-chunk ceiling when the budget is healthy', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => okResponse()); + const env = createTestEnv(); + await saveTestProviderApiKey(env); + // A fresh tracker has the full safe budget, so the tail is affordable. + const service = createTestModelRunner(env, new TokenTracker()); + + // 3,749 lines at the 800-line cap is 5 chunks; the old cap dropped the fifth. + const response = await reviewHugeFile(service, 3_749); + + expect(fetchMock).toHaveBeenCalledTimes(5); + expect(response.reviewedLineCount).toBe(3_749); + expect(response.wasPromptTruncated).toBe(false); + }); + + it('stops at the base chunks and reports truncation when the budget is committed elsewhere', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => okResponse()); + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const tracker = new TokenTracker(); + // Below isNearLimit (25) but not enough spare for the tail once the base chunks have run. + tracker.incrementSubrequests(18); + const service = createTestModelRunner(env, tracker); + + const response = await reviewHugeFile(service, 3_749); + + // Four reviewed, the fifth yielded and reported as truncated rather than clean. + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(response.reviewedLineCount).toBe(3_200); + expect(response.wasPromptTruncated).toBe(true); + }); + + it('still refuses to review a file in more than MAX_CHUNKS calls', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => okResponse()); + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const service = createTestModelRunner(env, new TokenTracker()); + + // 20,000 lines is 25 chunks: the hard cap must bind and report truncation. + const response = await reviewHugeFile(service, 20_000); + + expect(fetchMock).toHaveBeenCalledTimes(8); + expect(response.wasPromptTruncated).toBe(true); + }); + }); + + it('applies the compact prompt cap by producing smaller chunks after a prior transient failure', async () => { + const requestBodies: any[] = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + requestBodies.push(JSON.parse(String(init?.body))); + return new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }); + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const service = createTestModelRunner(env); + const largeFile = { + path: 'src/large.ts', + previousPath: null, + isNew: false, + isDeleted: false, + isBinary: false, + lineCount: 900, + hunks: [ + { + header: '@@ -1,900 +1,900 @@', + lines: Array.from({ length: 900 }, (_, index) => ({ + kind: 'add' as const, + content: `const value${index} = ${index};`, + newLineNumber: index + 1, + position: index + 1, + })), + }, + ], + }; + + const response = await service.reviewFile({ + file: largeFile, + prTitle: 'Test', + prDescription: null, + config: { + ...defaultRepoConfig, + model: { + main: 'gemini-3.1-pro-preview', + fallbacks: [], + size_overrides: [], + }, + }, + totalLineCount: 900, + compactPrompt: true, + }); + + // compactPrompt lowers the per-call cap to 400, so 900 lines becomes three chunks, not two. + expect(requestBodies.length).toBe(3); + const firstPrompt = requestBodies[0].contents[0].parts[0].text as string; + expect(firstPrompt).toContain('const value399 = 399;'); + expect(firstPrompt).not.toContain('const value400 = 400;'); + expect(response.reviewedLineCount).toBe(900); + expect(response.wasPromptTruncated).toBe(false); + }); +}); diff --git a/packages/models/test/model/service-fallbacks.spec.ts b/packages/models/test/model/service-fallbacks.spec.ts index 2f79d41d..56091b25 100644 --- a/packages/models/test/model/service-fallbacks.spec.ts +++ b/packages/models/test/model/service-fallbacks.spec.ts @@ -1,402 +1,402 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { isRetryableModelError } from '@codraoss/models'; - - -import { createTestEnv, saveTestProviderApiKey, createTestModelRunner } from '../../../../test/helpers'; -import { defaultRepoConfig } from '@codraoss/schema'; -import { TokenTracker } from '@codraoss/core/token-tracker'; - -// Chain fallback, budget breakers, provider availability. Inline retry ladder: service-retries.spec.ts. -describe('ModelRunner: chain fallback, budget breakers and provider availability', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('tries the smaller Google fallback after the primary Google model fails', async () => { - let cloudflareCalls = 0; - const gemini500 = () => - new Response( - JSON.stringify({ error: { code: 500, message: 'Internal error encountered.', status: 'INTERNAL' } }), - { status: 500, headers: { 'content-type': 'application/json' } }, - ); - // Primary fails 3x, fallback succeeds on the 4th call. - const fetchMock = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(gemini500()) - .mockResolvedValueOnce(gemini500()) - .mockResolvedValueOnce(gemini500()) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ); - const env = createTestEnv({ - AI: { - async run() { - cloudflareCalls++; - return { - response: JSON.stringify({ - findings: [], - overall_correctness: 'patch is correct', - overall_explanation: 'ok', - overall_confidence_score: 0.9, - }), - usage: { prompt_tokens: 1, completion_tokens: 1 }, - }; - }, - } as any, - }); - await saveTestProviderApiKey(env); - const service = createTestModelRunner(env); - - const response = await service.reviewFile({ - file: { - path: 'src/app.ts', - lineCount: 1, - hunks: [], - isDeleted: false, - isBinary: false, - isNew: false, - previousPath: null, - }, - prTitle: 'Test', - prDescription: null, - config: { - ...defaultRepoConfig, - model: { - main: 'gemini-3.1-pro-preview', - fallbacks: ['gemini-2.5-pro', '@cf/zai-org/glm-4.7-flash'], - size_overrides: [], - }, - }, - totalLineCount: 1, - }); - - expect(fetchMock).toHaveBeenCalledTimes(4); - expect(String(fetchMock.mock.calls[0][0])).toContain('/models/gemini-3.1-pro-preview:generateContent'); - expect(String(fetchMock.mock.calls[1][0])).toContain('/models/gemini-3.1-pro-preview:generateContent'); - expect(String(fetchMock.mock.calls[2][0])).toContain('/models/gemini-3.1-pro-preview:generateContent'); - expect(String(fetchMock.mock.calls[3][0])).toContain('/models/gemini-2.5-pro:generateContent'); - expect(cloudflareCalls).toBe(0); - expect(response.modelUsed).toBe('gemini-2.5-pro'); - }); - - // Unparseable 200 counts as that model's own failure (parse is inside the per-model try). - it('falls through to the next model when the primary returns an unparseable body', async () => { - const geminiText = (text: string) => new Response( - JSON.stringify({ - candidates: [{ content: { parts: [{ text }] } }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - const fetchMock = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(geminiText('I am unable to review this diff.')) - .mockResolvedValueOnce(geminiText('{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}')); - - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const service = createTestModelRunner(env); - - const response = await service.reviewFile({ - file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null }, - prTitle: 'Test', - prDescription: null, - config: { - ...defaultRepoConfig, - model: { main: 'gemini-3.1-pro-preview', fallbacks: ['gemini-2.5-pro'], size_overrides: [] }, - }, - totalLineCount: 1, - }); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(response.modelUsed).toBe('gemini-2.5-pro'); - }); - - // Three `continue` paths can leave `lastError` undefined, matching no retry predicate. - it('defers rather than throwing undefined when every model is skipped', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch'); - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const service = createTestModelRunner(env); - // A learned bucket below the prompt size makes skipReason refuse every model. - (service as unknown as { rateLimits: { skipReason: () => string } }).rateLimits.skipReason = () => 'prompt too large for its bucket'; - - const promise = service.reviewFile({ - file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null }, - prTitle: 'Test', - prDescription: null, - config: { - ...defaultRepoConfig, - model: { main: 'gemini-3.1-pro-preview', fallbacks: ['gemini-2.5-pro'], size_overrides: [] }, - }, - totalLineCount: 1, - }); - - await expect(promise).rejects.toThrow(/No configured review model was attempted/); - await promise.catch((error) => expect(isRetryableModelError(error)).toBe(true)); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - // Regression: the tail used to be exempt from the timeout breaker, wasting a full budget per unit. - it('drops even the last candidate once it has never answered on this job', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch'); - const env = createTestEnv(); - await saveTestProviderApiKey(env); - - // Six strikes exceeds the tail's higher bar. - await env.APP_KV.put( - 'jobs:job-tail-drop:chain-progress', - JSON.stringify({ timeouts: { 'gemini-3.1-pro-preview': 6, 'gemini-2.5-pro': 6 } }), - ); - const service = createTestModelRunner(env, undefined, { jobId: 'job-tail-drop' }); - - const promise = service.reviewFile({ - file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null }, - prTitle: 'Test', - prDescription: null, - config: { - ...defaultRepoConfig, - model: { main: 'gemini-3.1-pro-preview', fallbacks: ['gemini-2.5-pro'], size_overrides: [] }, - }, - totalLineCount: 1, - }); - - await expect(promise).rejects.toThrow(/No configured review model was attempted.*repeated timeouts/); - await promise.catch((error) => expect(isRetryableModelError(error)).toBe(true)); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - // Same rule, other side: a merely-slow tail still gets its shot since deferring untried is worse. - it('still tries the last candidate when it is only mid-chain slow', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( - new Response( - JSON.stringify({ - candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ); - const env = createTestEnv(); - await saveTestProviderApiKey(env); - - // Three strikes drops a model mid-chain but not at the tail. - await env.APP_KV.put( - 'jobs:job-tail-slow:chain-progress', - JSON.stringify({ timeouts: { 'gemini-3.1-pro-preview': 3, 'gemini-2.5-pro': 3 } }), - ); - const service = createTestModelRunner(env, undefined, { jobId: 'job-tail-slow' }); - - const response = await service.reviewFile({ - file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null }, - prTitle: 'Test', - prDescription: null, - config: { - ...defaultRepoConfig, - model: { main: 'gemini-3.1-pro-preview', fallbacks: ['gemini-2.5-pro'], size_overrides: [] }, - }, - totalLineCount: 1, - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(String(fetchMock.mock.calls[0][0])).toContain('/models/gemini-2.5-pro:generateContent'); - expect(response.modelUsed).toBe('gemini-2.5-pro'); - }); - - it('surfaces a permanent config error rather than deferring', async () => { - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const service = createTestModelRunner(env); - - const promise = service.reviewFile({ - file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null }, - prTitle: 'Test', - prDescription: null, - config: { - ...defaultRepoConfig, - model: { main: 'definitely-not-a-configured-model', fallbacks: [], size_overrides: [] }, - }, - totalLineCount: 1, - }); - - await expect(promise).rejects.toThrow(/is not configured/); - await promise.catch((error) => expect(isRetryableModelError(error)).toBe(false)); - }); - - it('still tries the primary model even when the shared job budget is already near the subrequest limit', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( - new Response( - JSON.stringify({ - candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ); - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const tracker = new TokenTracker(); - tracker.incrementSubrequests(40); // above near-limit threshold (50 - 25 margin) - const service = createTestModelRunner(env, tracker); - - const response = await service.reviewFile({ - file: { - path: 'src/app.ts', - lineCount: 1, - hunks: [], - isDeleted: false, - isBinary: false, - isNew: false, - previousPath: null, - }, - prTitle: 'Test', - prDescription: null, - config: { - ...defaultRepoConfig, - model: { - main: 'gemini-3.1-pro-preview', - fallbacks: ['gemini-2.5-pro'], - size_overrides: [], - }, - }, - totalLineCount: 1, - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(response.modelUsed).toBe('gemini-3.1-pro-preview'); - }); - - // Counterpart: primary is skipped only when budget truly can't cover the call, not merely tight. - it('will not commit a prompt when the budget cannot cover the call', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch'); - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const tracker = new TokenTracker(); - // Leaves 5 of the 50-subrequest cap, under the headroom one call may need. - tracker.incrementSubrequests(45); - const service = createTestModelRunner(env, tracker); - - const promise = service.reviewFile({ - file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null }, - prTitle: 'Test', - prDescription: null, - config: { - ...defaultRepoConfig, - model: { main: 'gemini-3.1-pro-preview', fallbacks: ['gemini-2.5-pro'], size_overrides: [] }, - }, - totalLineCount: 1, - }); - - // Deferred, not failed: a fresh invocation has a fresh budget. - await expect(promise).rejects.toThrow(/retrying later/); - await promise.catch((error) => expect(isRetryableModelError(error)).toBe(true)); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('skips remaining fallback models (instead of spending more of the shared budget) once near the subrequest limit', async () => { - // Fresh Response per call (body reads once); 503 not 500 keeps the failure retryable. - const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => - new Response( - JSON.stringify({ error: { code: 503, message: 'The model is overloaded and currently unavailable.', status: 'UNAVAILABLE' } }), - { status: 503, headers: { 'content-type': 'application/json' } }, - ), - ); - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const tracker = new TokenTracker(); - tracker.incrementSubrequests(40); // above near-limit threshold (50 - 25 margin) - const service = createTestModelRunner(env, tracker); - - await expect( - service.reviewFile({ - file: { - path: 'src/app.ts', - lineCount: 1, - hunks: [], - isDeleted: false, - isBinary: false, - isNew: false, - previousPath: null, - }, - prTitle: 'Test', - prDescription: null, - config: { - ...defaultRepoConfig, - model: { - main: 'gemini-3.1-pro-preview', - fallbacks: ['gemini-2.5-pro'], - size_overrides: [], - }, - }, - totalLineCount: 1, - }), - ).rejects.toSatisfy(isRetryableModelError); - - expect(fetchMock.mock.calls.length).toBeGreaterThan(0); - for (const call of fetchMock.mock.calls) { - expect(String(call[0])).toContain('/models/gemini-3.1-pro-preview:generateContent'); - } - }); - - it('skips Cloudflare for the rest of a job after allocation is exhausted', async () => { - let cloudflareCalls = 0; - const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => - new Response( - JSON.stringify({ - candidates: [{ content: { parts: [{ text: '{"findings":[]}' }] } }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ); - const env = createTestEnv({ - AI: { - async run() { - cloudflareCalls++; - throw new Error('Cloudflare daily free allocation exhausted (4006)'); - }, - } as any, - }); - await saveTestProviderApiKey(env); - const service = createTestModelRunner(env, undefined, { jobId: 'job-provider-skip' }); - const file = { - path: 'src/app.ts', - lineCount: 1, - hunks: [], - isDeleted: false, - isBinary: false, - isNew: false, - previousPath: null, - }; - const config = { - ...defaultRepoConfig, - model: { - main: '@cf/zai-org/glm-4.7-flash', - fallbacks: ['gemini-3.1-pro-preview'], - size_overrides: [], - }, - }; - - await service.reviewFile({ - file, - prTitle: 'Test', - prDescription: null, - config, - totalLineCount: 1, - }); - await service.reviewFile({ - file: { ...file, path: 'src/other.ts' }, - prTitle: 'Test', - prDescription: null, - config, - totalLineCount: 1, - }); - - expect(cloudflareCalls).toBe(1); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); -}); +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { isRetryableModelError } from '@codraoss/models'; + + +import { createTestEnv, saveTestProviderApiKey, createTestModelRunner } from '../../../../test/helpers'; +import { defaultRepoConfig } from '@codraoss/schema'; +import { TokenTracker } from '@codraoss/core/token-tracker'; + +// Chain fallback, budget breakers, provider availability. Inline retry ladder: service-retries.spec.ts. +describe('ModelRunner: chain fallback, budget breakers and provider availability', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('tries the smaller Google fallback after the primary Google model fails', async () => { + let cloudflareCalls = 0; + const gemini500 = () => + new Response( + JSON.stringify({ error: { code: 500, message: 'Internal error encountered.', status: 'INTERNAL' } }), + { status: 500, headers: { 'content-type': 'application/json' } }, + ); + // Primary fails 3x, fallback succeeds on the 4th call. + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(gemini500()) + .mockResolvedValueOnce(gemini500()) + .mockResolvedValueOnce(gemini500()) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + const env = createTestEnv({ + AI: { + async run() { + cloudflareCalls++; + return { + response: JSON.stringify({ + findings: [], + overall_correctness: 'patch is correct', + overall_explanation: 'ok', + overall_confidence_score: 0.9, + }), + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }; + }, + } as any, + }); + await saveTestProviderApiKey(env); + const service = createTestModelRunner(env); + + const response = await service.reviewFile({ + file: { + path: 'src/app.ts', + lineCount: 1, + hunks: [], + isDeleted: false, + isBinary: false, + isNew: false, + previousPath: null, + }, + prTitle: 'Test', + prDescription: null, + config: { + ...defaultRepoConfig, + model: { + main: 'gemini-3.1-pro-preview', + fallbacks: ['gemini-2.5-pro', '@cf/zai-org/glm-4.7-flash'], + size_overrides: [], + }, + }, + totalLineCount: 1, + }); + + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(String(fetchMock.mock.calls[0][0])).toContain('/models/gemini-3.1-pro-preview:generateContent'); + expect(String(fetchMock.mock.calls[1][0])).toContain('/models/gemini-3.1-pro-preview:generateContent'); + expect(String(fetchMock.mock.calls[2][0])).toContain('/models/gemini-3.1-pro-preview:generateContent'); + expect(String(fetchMock.mock.calls[3][0])).toContain('/models/gemini-2.5-pro:generateContent'); + expect(cloudflareCalls).toBe(0); + expect(response.modelUsed).toBe('gemini-2.5-pro'); + }); + + // Unparseable 200 counts as that model's own failure (parse is inside the per-model try). + it('falls through to the next model when the primary returns an unparseable body', async () => { + const geminiText = (text: string) => new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text }] } }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(geminiText('I am unable to review this diff.')) + .mockResolvedValueOnce(geminiText('{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}')); + + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const service = createTestModelRunner(env); + + const response = await service.reviewFile({ + file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null }, + prTitle: 'Test', + prDescription: null, + config: { + ...defaultRepoConfig, + model: { main: 'gemini-3.1-pro-preview', fallbacks: ['gemini-2.5-pro'], size_overrides: [] }, + }, + totalLineCount: 1, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(response.modelUsed).toBe('gemini-2.5-pro'); + }); + + // Three `continue` paths can leave `lastError` undefined, matching no retry predicate. + it('defers rather than throwing undefined when every model is skipped', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch'); + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const service = createTestModelRunner(env); + // A learned bucket below the prompt size makes skipReason refuse every model. + (service as unknown as { rateLimits: { skipReason: () => string } }).rateLimits.skipReason = () => 'prompt too large for its bucket'; + + const promise = service.reviewFile({ + file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null }, + prTitle: 'Test', + prDescription: null, + config: { + ...defaultRepoConfig, + model: { main: 'gemini-3.1-pro-preview', fallbacks: ['gemini-2.5-pro'], size_overrides: [] }, + }, + totalLineCount: 1, + }); + + await expect(promise).rejects.toThrow(/No configured review model was attempted/); + await promise.catch((error) => expect(isRetryableModelError(error)).toBe(true)); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + // Regression: the tail used to be exempt from the timeout breaker, wasting a full budget per unit. + it('drops even the last candidate once it has never answered on this job', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch'); + const env = createTestEnv(); + await saveTestProviderApiKey(env); + + // Six strikes exceeds the tail's higher bar. + await env.APP_KV.put( + 'jobs:job-tail-drop:chain-progress', + JSON.stringify({ timeouts: { 'gemini-3.1-pro-preview': 6, 'gemini-2.5-pro': 6 } }), + ); + const service = createTestModelRunner(env, undefined, { jobId: 'job-tail-drop' }); + + const promise = service.reviewFile({ + file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null }, + prTitle: 'Test', + prDescription: null, + config: { + ...defaultRepoConfig, + model: { main: 'gemini-3.1-pro-preview', fallbacks: ['gemini-2.5-pro'], size_overrides: [] }, + }, + totalLineCount: 1, + }); + + await expect(promise).rejects.toThrow(/No configured review model was attempted.*repeated timeouts/); + await promise.catch((error) => expect(isRetryableModelError(error)).toBe(true)); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + // Same rule, other side: a merely-slow tail still gets its shot since deferring untried is worse. + it('still tries the last candidate when it is only mid-chain slow', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + const env = createTestEnv(); + await saveTestProviderApiKey(env); + + // Three strikes drops a model mid-chain but not at the tail. + await env.APP_KV.put( + 'jobs:job-tail-slow:chain-progress', + JSON.stringify({ timeouts: { 'gemini-3.1-pro-preview': 3, 'gemini-2.5-pro': 3 } }), + ); + const service = createTestModelRunner(env, undefined, { jobId: 'job-tail-slow' }); + + const response = await service.reviewFile({ + file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null }, + prTitle: 'Test', + prDescription: null, + config: { + ...defaultRepoConfig, + model: { main: 'gemini-3.1-pro-preview', fallbacks: ['gemini-2.5-pro'], size_overrides: [] }, + }, + totalLineCount: 1, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0][0])).toContain('/models/gemini-2.5-pro:generateContent'); + expect(response.modelUsed).toBe('gemini-2.5-pro'); + }); + + it('surfaces a permanent config error rather than deferring', async () => { + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const service = createTestModelRunner(env); + + const promise = service.reviewFile({ + file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null }, + prTitle: 'Test', + prDescription: null, + config: { + ...defaultRepoConfig, + model: { main: 'definitely-not-a-configured-model', fallbacks: [], size_overrides: [] }, + }, + totalLineCount: 1, + }); + + await expect(promise).rejects.toThrow(/is not configured/); + await promise.catch((error) => expect(isRetryableModelError(error)).toBe(false)); + }); + + it('still tries the primary model even when the shared job budget is already near the subrequest limit', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const tracker = new TokenTracker(); + tracker.incrementSubrequests(40); // above near-limit threshold (50 - 25 margin) + const service = createTestModelRunner(env, tracker); + + const response = await service.reviewFile({ + file: { + path: 'src/app.ts', + lineCount: 1, + hunks: [], + isDeleted: false, + isBinary: false, + isNew: false, + previousPath: null, + }, + prTitle: 'Test', + prDescription: null, + config: { + ...defaultRepoConfig, + model: { + main: 'gemini-3.1-pro-preview', + fallbacks: ['gemini-2.5-pro'], + size_overrides: [], + }, + }, + totalLineCount: 1, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(response.modelUsed).toBe('gemini-3.1-pro-preview'); + }); + + // Counterpart: primary is skipped only when budget truly can't cover the call, not merely tight. + it('will not commit a prompt when the budget cannot cover the call', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch'); + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const tracker = new TokenTracker(); + // Leaves 5 of the 50-subrequest cap, under the headroom one call may need. + tracker.incrementSubrequests(45); + const service = createTestModelRunner(env, tracker); + + const promise = service.reviewFile({ + file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null }, + prTitle: 'Test', + prDescription: null, + config: { + ...defaultRepoConfig, + model: { main: 'gemini-3.1-pro-preview', fallbacks: ['gemini-2.5-pro'], size_overrides: [] }, + }, + totalLineCount: 1, + }); + + // Deferred, not failed: a fresh invocation has a fresh budget. + await expect(promise).rejects.toThrow(/retrying later/); + await promise.catch((error) => expect(isRetryableModelError(error)).toBe(true)); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('skips remaining fallback models (instead of spending more of the shared budget) once near the subrequest limit', async () => { + // Fresh Response per call (body reads once); 503 not 500 keeps the failure retryable. + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + new Response( + JSON.stringify({ error: { code: 503, message: 'The model is overloaded and currently unavailable.', status: 'UNAVAILABLE' } }), + { status: 503, headers: { 'content-type': 'application/json' } }, + ), + ); + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const tracker = new TokenTracker(); + tracker.incrementSubrequests(40); // above near-limit threshold (50 - 25 margin) + const service = createTestModelRunner(env, tracker); + + await expect( + service.reviewFile({ + file: { + path: 'src/app.ts', + lineCount: 1, + hunks: [], + isDeleted: false, + isBinary: false, + isNew: false, + previousPath: null, + }, + prTitle: 'Test', + prDescription: null, + config: { + ...defaultRepoConfig, + model: { + main: 'gemini-3.1-pro-preview', + fallbacks: ['gemini-2.5-pro'], + size_overrides: [], + }, + }, + totalLineCount: 1, + }), + ).rejects.toSatisfy(isRetryableModelError); + + expect(fetchMock.mock.calls.length).toBeGreaterThan(0); + for (const call of fetchMock.mock.calls) { + expect(String(call[0])).toContain('/models/gemini-3.1-pro-preview:generateContent'); + } + }); + + it('skips Cloudflare for the rest of a job after allocation is exhausted', async () => { + let cloudflareCalls = 0; + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: '{"findings":[]}' }] } }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + const env = createTestEnv({ + AI: { + async run() { + cloudflareCalls++; + throw new Error('Cloudflare daily free allocation exhausted (4006)'); + }, + } as any, + }); + await saveTestProviderApiKey(env); + const service = createTestModelRunner(env, undefined, { jobId: 'job-provider-skip' }); + const file = { + path: 'src/app.ts', + lineCount: 1, + hunks: [], + isDeleted: false, + isBinary: false, + isNew: false, + previousPath: null, + }; + const config = { + ...defaultRepoConfig, + model: { + main: '@cf/zai-org/glm-4.7-flash', + fallbacks: ['gemini-3.1-pro-preview'], + size_overrides: [], + }, + }; + + await service.reviewFile({ + file, + prTitle: 'Test', + prDescription: null, + config, + totalLineCount: 1, + }); + await service.reviewFile({ + file: { ...file, path: 'src/other.ts' }, + prTitle: 'Test', + prDescription: null, + config, + totalLineCount: 1, + }); + + expect(cloudflareCalls).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/models/test/model/service-grammar-rejection.spec.ts b/packages/models/test/model/service-grammar-rejection.spec.ts index 1490ffea..c6074a8d 100644 --- a/packages/models/test/model/service-grammar-rejection.spec.ts +++ b/packages/models/test/model/service-grammar-rejection.spec.ts @@ -1,245 +1,245 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { reviewWithGoogle } from '@codraoss/models/google'; -import { buildReviewResponseSchema } from '@codraoss/core/prompts/file-review'; -import { createTestEnv, saveTestProviderApiKey, createTestModelRunner } from '../../../../test/helpers'; -import { defaultRepoConfig } from '@codraoss/schema'; - -// Split out of service-retries.spec.ts: a non-transient 400 gets its own ladder rung here. -describe('ModelRunner: response-grammar rejection', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - function geminiOk() { - return new Response( - JSON.stringify({ - candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] }, finishReason: 'STOP' }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - - function gemini400(message: string) { - return new Response( - JSON.stringify({ error: { code: 400, status: 'INVALID_ARGUMENT', message } }), - { status: 400, headers: { 'content-type': 'application/json' } }, - ); - } - - const withGrammar = { systemPrompt: 'system', userPrompt: 'user', responseSchema: buildReviewResponseSchema(10) }; - - // A bare "invalid argument" with no details is Google's ACTUAL wording for some feature rejections - // -- observed in production on gemini-3.x-lite, where the identical prompt succeeds once the grammar - // is stripped. So an unexplained 400 gets a bounded probe ladder: retry without the grammar, then - // without the thinking budget, then fail for real. Refusing to probe (one earlier iteration of this - // code) burnt both lite models on every such file; probing on ANY 400 (the iteration before that) - // let one unrelated 400 latch the model into unconstrained mode for the whole job. - it('probes an unexplained 400 by stripping the grammar, then the thinking budget', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(gemini400('Request contains an invalid argument.')) - .mockResolvedValueOnce(gemini400('Request contains an invalid argument.')) - .mockResolvedValueOnce(geminiOk()); - - const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-flash-lite', withGrammar); - - const bodies = fetchMock.mock.calls.map((call) => JSON.parse(String((call[1] as RequestInit).body))); - expect(bodies).toHaveLength(3); - expect(bodies[0].generationConfig.responseJsonSchema).toBeDefined(); - expect(bodies[0].generationConfig.thinkingConfig).toBeDefined(); - // First probe: grammar off, thinking still on. - expect(bodies[1].generationConfig.responseJsonSchema).toBeUndefined(); - expect(bodies[1].generationConfig.thinkingConfig).toBeDefined(); - // Second probe: both off. - expect(bodies[2].generationConfig.responseJsonSchema).toBeUndefined(); - expect(bodies[2].generationConfig.thinkingConfig).toBeUndefined(); - // Heuristic, so marked apart from a confident rejection. - expect(response.degraded).toBe('schema-dropped-catchall'); - }); - - it('fails without latching when the probes do not help', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch') - .mockImplementation(async () => gemini400('Request contains an invalid argument.')); - - const error = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-flash-lite', withGrammar) - .catch((e: unknown) => e); - - expect((error as Error).message).toMatch(/400/); - // Full attempt + two probes, then done -- the ladder is bounded by its own latches. - expect(fetchMock.mock.calls.length).toBe(3); - // NOT marked schema-dropped: the probe failed too, so it proved nothing about the grammar, and - // this flag is what latches the model into unconstrained mode for the rest of the job. - expect((error as { schemaDropped?: boolean }).schemaDropped).toBeUndefined(); - }); - - it('leaves a 400 that is not invalid-argument-shaped alone', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(gemini400('API key not valid. Please pass a valid API key.')); - - await expect( - reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-flash-lite', withGrammar), - ).rejects.toThrow(/400/); - expect(fetchMock.mock.calls.length).toBe(1); - }); - - // The realistic shape of a grammar rejection: the message is the useless generic one, and the - // actionable text arrives via error.details. - it('drops the grammar when the flattened detail names the response format', async () => { - const withDetails = () => new Response( - JSON.stringify({ - error: { - code: 400, - status: 'INVALID_ARGUMENT', - message: 'Request contains an invalid argument.', - details: [{ description: 'Invalid value at generation_config.response_json_schema' }], - }, - }), - { status: 400, headers: { 'content-type': 'application/json' } }, - ); - const fetchMock = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(withDetails()) - .mockResolvedValueOnce(geminiOk()); - - const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-flash-lite', withGrammar); - - expect(fetchMock).toHaveBeenCalledTimes(2); - const retryBody = JSON.parse(String((fetchMock.mock.calls[1][1] as RequestInit).body)); - expect(retryBody.generationConfig.responseJsonSchema).toBeUndefined(); - expect(retryBody.generationConfig.responseMimeType).toBe('application/json'); - expect(response.rawText).toContain('"findings"'); - // Named confidently, so the marker is the plain one. - expect(response.degraded).toBe('schema-dropped'); - }); - - // Kept for the ambiguous middle: enough to act on, not enough to be sure. The distinct marker is what - // makes the heuristic's real hit rate answerable from `file_reviews.degraded` instead of guessed at. - it('marks a heuristic grammar drop apart from a confident one', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(gemini400('Request contains an invalid argument. too many states for serving')) - .mockResolvedValueOnce(geminiOk()); - - const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-flash-lite', withGrammar); - - expect(fetchMock).toHaveBeenCalledTimes(2); - const retryBody = JSON.parse(String((fetchMock.mock.calls[1][1] as RequestInit).body)); - expect(retryBody.generationConfig.responseJsonSchema).toBeUndefined(); - expect(response.degraded).toBe('schema-dropped-catchall'); - }); - - it('drops the grammar and retries once when Gemini rejects responseJsonSchema', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(gemini400('Unknown name "responseJsonSchema" at \'generation_config\': Cannot find field.')) - .mockResolvedValueOnce(geminiOk()); - - const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', withGrammar); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)).generationConfig.responseJsonSchema).toBeDefined(); - expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)).generationConfig.responseJsonSchema).toBeUndefined(); - expect(response.rawText).toContain('"findings"'); - // Preflight ("Test connection") depends on this surfacing. - expect(response.degraded).toBe('schema-dropped'); - - // Probe must not fire on an unrelated 400, nor when there was no grammar to drop. - for (const [message, input] of [ - ['API key not valid. Please pass a valid API key.', withGrammar], - ['Invalid value at generation_config.schema.', { systemPrompt: 'system', userPrompt: 'user' }], - ] as Array<[string, any]>) { - vi.restoreAllMocks(); - const guarded = vi.spyOn(globalThis, 'fetch').mockResolvedValue(gemini400(message)); - await expect( - reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', input), - ).rejects.toThrow(/400/); - expect(guarded).toHaveBeenCalledTimes(1); - } - }); - - // Latch used to set only when the schema-less retry succeeded; a later 429 would re-probe every call. - it('latches the grammar off even when the schema-less retry itself fails', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch') - // Not a 429 deliberately: that would cool the model and skip call 2, hiding whether the latch held. - .mockResolvedValueOnce(gemini400('Unknown name "responseJsonSchema" at \'generation_config\'.')) - .mockResolvedValueOnce(gemini400('API key not valid. Please pass a valid API key.')) - .mockResolvedValue(geminiOk()); - - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const service = createTestModelRunner(env); - const params = { - file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null }, - prTitle: 'Test', - prDescription: null, - config: { - ...defaultRepoConfig, - model: { main: 'gemini-3.1-pro-preview', fallbacks: [], size_overrides: [] }, - }, - totalLineCount: 1, - }; - - await expect(service.reviewFile(params)).rejects.toThrow(); - const callsAfterFirstReview = fetchMock.mock.calls.length; - - await service.reviewFile(params); - - // The second review goes straight out without the grammar: no re-probe, no wasted 400. - const firstCallOfSecondReview = fetchMock.mock.calls[callsAfterFirstReview]; - expect(JSON.parse(String(firstCallOfSecondReview?.[1]?.body)).generationConfig.responseJsonSchema).toBeUndefined(); - expect(fetchMock.mock.calls.length).toBe(callsAfterFirstReview + 1); - }); - - // Gemini 3.x puts the real reason in error.details; without reading it this looked like an unrelated 400. - it('reads the rejection reason out of error.details, not just the message', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(new Response( - JSON.stringify({ - error: { - code: 400, - status: 'INVALID_ARGUMENT', - message: 'Request contains an invalid argument.', - details: [{ - '@type': 'type.googleapis.com/google.rpc.BadRequest', - fieldViolations: [{ - description: 'The specified schema produces a constraint that has too many states for serving.', - }], - }], - }, - }), - { status: 400, headers: { 'content-type': 'application/json' } }, - )) - .mockResolvedValueOnce(geminiOk()); - - const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', withGrammar); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)).generationConfig.responseJsonSchema).toBeUndefined(); - expect(response.degraded).toBe('schema-dropped'); - }); - - it('gives the attempt back for the probe, but only once', async () => { - // Without the give-back, a ladder spent on 5xx could never drop the schema. - const gemini500 = () => new Response(JSON.stringify({ error: { code: 500, message: 'Internal error encountered.' } }), { status: 500, headers: { 'content-type': 'application/json' } }); - const ladderSpent = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(gemini500()) - .mockResolvedValueOnce(gemini500()) - .mockResolvedValueOnce(gemini400('Unknown name "responseJsonSchema" at \'generation_config\'.')) - .mockResolvedValueOnce(geminiOk()); - - const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', withGrammar); - - expect(ladderSpent).toHaveBeenCalledTimes(4); - expect(JSON.parse(String(ladderSpent.mock.calls[3]?.[1]?.body)).generationConfig.responseJsonSchema).toBeUndefined(); - expect(response.degraded).toBe('schema-dropped'); - - // mockImplementation: a retried call can't reread one Response body. - vi.restoreAllMocks(); - const persistent = vi.spyOn(globalThis, 'fetch') - .mockImplementation(async () => gemini400('Invalid JSON payload received. Unknown name "responseJsonSchema".')); - - await expect( - reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', withGrammar), - ).rejects.toThrow(/400/); - - expect(persistent).toHaveBeenCalledTimes(2); - }); -}); +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { reviewWithGoogle } from '@codraoss/models/google'; +import { buildReviewResponseSchema } from '@codraoss/core/prompts/file-review'; +import { createTestEnv, saveTestProviderApiKey, createTestModelRunner } from '../../../../test/helpers'; +import { defaultRepoConfig } from '@codraoss/schema'; + +// Split out of service-retries.spec.ts: a non-transient 400 gets its own ladder rung here. +describe('ModelRunner: response-grammar rejection', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function geminiOk() { + return new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] }, finishReason: 'STOP' }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + + function gemini400(message: string) { + return new Response( + JSON.stringify({ error: { code: 400, status: 'INVALID_ARGUMENT', message } }), + { status: 400, headers: { 'content-type': 'application/json' } }, + ); + } + + const withGrammar = { systemPrompt: 'system', userPrompt: 'user', responseSchema: buildReviewResponseSchema(10) }; + + // A bare "invalid argument" with no details is Google's ACTUAL wording for some feature rejections + // -- observed in production on gemini-3.x-lite, where the identical prompt succeeds once the grammar + // is stripped. So an unexplained 400 gets a bounded probe ladder: retry without the grammar, then + // without the thinking budget, then fail for real. Refusing to probe (one earlier iteration of this + // code) burnt both lite models on every such file; probing on ANY 400 (the iteration before that) + // let one unrelated 400 latch the model into unconstrained mode for the whole job. + it('probes an unexplained 400 by stripping the grammar, then the thinking budget', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(gemini400('Request contains an invalid argument.')) + .mockResolvedValueOnce(gemini400('Request contains an invalid argument.')) + .mockResolvedValueOnce(geminiOk()); + + const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-flash-lite', withGrammar); + + const bodies = fetchMock.mock.calls.map((call) => JSON.parse(String((call[1] as RequestInit).body))); + expect(bodies).toHaveLength(3); + expect(bodies[0].generationConfig.responseJsonSchema).toBeDefined(); + expect(bodies[0].generationConfig.thinkingConfig).toBeDefined(); + // First probe: grammar off, thinking still on. + expect(bodies[1].generationConfig.responseJsonSchema).toBeUndefined(); + expect(bodies[1].generationConfig.thinkingConfig).toBeDefined(); + // Second probe: both off. + expect(bodies[2].generationConfig.responseJsonSchema).toBeUndefined(); + expect(bodies[2].generationConfig.thinkingConfig).toBeUndefined(); + // Heuristic, so marked apart from a confident rejection. + expect(response.degraded).toBe('schema-dropped-catchall'); + }); + + it('fails without latching when the probes do not help', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockImplementation(async () => gemini400('Request contains an invalid argument.')); + + const error = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-flash-lite', withGrammar) + .catch((e: unknown) => e); + + expect((error as Error).message).toMatch(/400/); + // Full attempt + two probes, then done -- the ladder is bounded by its own latches. + expect(fetchMock.mock.calls.length).toBe(3); + // NOT marked schema-dropped: the probe failed too, so it proved nothing about the grammar, and + // this flag is what latches the model into unconstrained mode for the rest of the job. + expect((error as { schemaDropped?: boolean }).schemaDropped).toBeUndefined(); + }); + + it('leaves a 400 that is not invalid-argument-shaped alone', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(gemini400('API key not valid. Please pass a valid API key.')); + + await expect( + reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-flash-lite', withGrammar), + ).rejects.toThrow(/400/); + expect(fetchMock.mock.calls.length).toBe(1); + }); + + // The realistic shape of a grammar rejection: the message is the useless generic one, and the + // actionable text arrives via error.details. + it('drops the grammar when the flattened detail names the response format', async () => { + const withDetails = () => new Response( + JSON.stringify({ + error: { + code: 400, + status: 'INVALID_ARGUMENT', + message: 'Request contains an invalid argument.', + details: [{ description: 'Invalid value at generation_config.response_json_schema' }], + }, + }), + { status: 400, headers: { 'content-type': 'application/json' } }, + ); + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(withDetails()) + .mockResolvedValueOnce(geminiOk()); + + const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-flash-lite', withGrammar); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const retryBody = JSON.parse(String((fetchMock.mock.calls[1][1] as RequestInit).body)); + expect(retryBody.generationConfig.responseJsonSchema).toBeUndefined(); + expect(retryBody.generationConfig.responseMimeType).toBe('application/json'); + expect(response.rawText).toContain('"findings"'); + // Named confidently, so the marker is the plain one. + expect(response.degraded).toBe('schema-dropped'); + }); + + // Kept for the ambiguous middle: enough to act on, not enough to be sure. The distinct marker is what + // makes the heuristic's real hit rate answerable from `file_reviews.degraded` instead of guessed at. + it('marks a heuristic grammar drop apart from a confident one', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(gemini400('Request contains an invalid argument. too many states for serving')) + .mockResolvedValueOnce(geminiOk()); + + const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-flash-lite', withGrammar); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const retryBody = JSON.parse(String((fetchMock.mock.calls[1][1] as RequestInit).body)); + expect(retryBody.generationConfig.responseJsonSchema).toBeUndefined(); + expect(response.degraded).toBe('schema-dropped-catchall'); + }); + + it('drops the grammar and retries once when Gemini rejects responseJsonSchema', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(gemini400('Unknown name "responseJsonSchema" at \'generation_config\': Cannot find field.')) + .mockResolvedValueOnce(geminiOk()); + + const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', withGrammar); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)).generationConfig.responseJsonSchema).toBeDefined(); + expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)).generationConfig.responseJsonSchema).toBeUndefined(); + expect(response.rawText).toContain('"findings"'); + // Preflight ("Test connection") depends on this surfacing. + expect(response.degraded).toBe('schema-dropped'); + + // Probe must not fire on an unrelated 400, nor when there was no grammar to drop. + for (const [message, input] of [ + ['API key not valid. Please pass a valid API key.', withGrammar], + ['Invalid value at generation_config.schema.', { systemPrompt: 'system', userPrompt: 'user' }], + ] as Array<[string, any]>) { + vi.restoreAllMocks(); + const guarded = vi.spyOn(globalThis, 'fetch').mockResolvedValue(gemini400(message)); + await expect( + reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', input), + ).rejects.toThrow(/400/); + expect(guarded).toHaveBeenCalledTimes(1); + } + }); + + // Latch used to set only when the schema-less retry succeeded; a later 429 would re-probe every call. + it('latches the grammar off even when the schema-less retry itself fails', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + // Not a 429 deliberately: that would cool the model and skip call 2, hiding whether the latch held. + .mockResolvedValueOnce(gemini400('Unknown name "responseJsonSchema" at \'generation_config\'.')) + .mockResolvedValueOnce(gemini400('API key not valid. Please pass a valid API key.')) + .mockResolvedValue(geminiOk()); + + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const service = createTestModelRunner(env); + const params = { + file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null }, + prTitle: 'Test', + prDescription: null, + config: { + ...defaultRepoConfig, + model: { main: 'gemini-3.1-pro-preview', fallbacks: [], size_overrides: [] }, + }, + totalLineCount: 1, + }; + + await expect(service.reviewFile(params)).rejects.toThrow(); + const callsAfterFirstReview = fetchMock.mock.calls.length; + + await service.reviewFile(params); + + // The second review goes straight out without the grammar: no re-probe, no wasted 400. + const firstCallOfSecondReview = fetchMock.mock.calls[callsAfterFirstReview]; + expect(JSON.parse(String(firstCallOfSecondReview?.[1]?.body)).generationConfig.responseJsonSchema).toBeUndefined(); + expect(fetchMock.mock.calls.length).toBe(callsAfterFirstReview + 1); + }); + + // Gemini 3.x puts the real reason in error.details; without reading it this looked like an unrelated 400. + it('reads the rejection reason out of error.details, not just the message', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response( + JSON.stringify({ + error: { + code: 400, + status: 'INVALID_ARGUMENT', + message: 'Request contains an invalid argument.', + details: [{ + '@type': 'type.googleapis.com/google.rpc.BadRequest', + fieldViolations: [{ + description: 'The specified schema produces a constraint that has too many states for serving.', + }], + }], + }, + }), + { status: 400, headers: { 'content-type': 'application/json' } }, + )) + .mockResolvedValueOnce(geminiOk()); + + const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', withGrammar); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)).generationConfig.responseJsonSchema).toBeUndefined(); + expect(response.degraded).toBe('schema-dropped'); + }); + + it('gives the attempt back for the probe, but only once', async () => { + // Without the give-back, a ladder spent on 5xx could never drop the schema. + const gemini500 = () => new Response(JSON.stringify({ error: { code: 500, message: 'Internal error encountered.' } }), { status: 500, headers: { 'content-type': 'application/json' } }); + const ladderSpent = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(gemini500()) + .mockResolvedValueOnce(gemini500()) + .mockResolvedValueOnce(gemini400('Unknown name "responseJsonSchema" at \'generation_config\'.')) + .mockResolvedValueOnce(geminiOk()); + + const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', withGrammar); + + expect(ladderSpent).toHaveBeenCalledTimes(4); + expect(JSON.parse(String(ladderSpent.mock.calls[3]?.[1]?.body)).generationConfig.responseJsonSchema).toBeUndefined(); + expect(response.degraded).toBe('schema-dropped'); + + // mockImplementation: a retried call can't reread one Response body. + vi.restoreAllMocks(); + const persistent = vi.spyOn(globalThis, 'fetch') + .mockImplementation(async () => gemini400('Invalid JSON payload received. Unknown name "responseJsonSchema".')); + + await expect( + reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', withGrammar), + ).rejects.toThrow(/400/); + + expect(persistent).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/models/test/model/service-requests.spec.ts b/packages/models/test/model/service-requests.spec.ts index d18ed514..b0780c05 100644 --- a/packages/models/test/model/service-requests.spec.ts +++ b/packages/models/test/model/service-requests.spec.ts @@ -1,169 +1,169 @@ -import { afterEach, describe, expect, it } from 'vitest'; - -import { reviewWithCloudflare } from '@codraoss/models/cloudflare'; -import { reviewWithGoogle } from '@codraoss/models/google'; - -import { buildBatchReviewResponseSchema, buildReviewResponseSchema } from '@codraoss/core/prompts/file-review'; -import { VERIFY_RESPONSE_SCHEMA } from '@codraoss/core/prompts/verify'; -import { createTestEnv, saveTestProviderApiKey, createTestModelRunner } from '../../../../test/helpers'; - - -describe('ModelRunner: request shape and response handling', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('fails (throws) on a Cloudflare reasoning-only response instead of faking an inconclusive review', async () => { - const env = createTestEnv({ - AI: { - async run() { - return { - choices: [ - { - message: { - content: null, - reasoning: 'Long reasoning that consumed the completion budget.', - }, - finish_reason: 'length', - }, - ], - usage: { prompt_tokens: 1, completion_tokens: 4096 }, - }; - }, - } as any, - }); - - // Nothing was reviewed, so this must surface as a failure, not an "inconclusive" pass. - await expect( - reviewWithCloudflare(env.AI, '@cf/moonshotai/kimi-k2.6', { systemPrompt: 'system', userPrompt: 'user' }), - ).rejects.toThrow(/no reviewable output.*reasoning-only/i); - }); - - it('throws when Cloudflare final content is missing (does not parse reasoning as review JSON)', async () => { - const env = createTestEnv({ - AI: { - async run() { - return { - choices: [ - { - message: { - content: null, - reasoning: 'Reasoning mentioned an object like {"foo":"bar"} but never produced final JSON.', - }, - finish_reason: 'length', - }, - ], - usage: { prompt_tokens: 1, completion_tokens: 8192 }, - }; - }, - } as any, - }); - - await expect( - reviewWithCloudflare(env.AI, '@cf/zai-org/glm-4.7-flash', { systemPrompt: 'system', userPrompt: 'user' }), - ).rejects.toThrow(/no reviewable output/i); - }); - - // Per-call: forcing the file-review schema onto the verify pass made it unsatisfiable. - - it('honors a non-review schema, so the verify pass is not forced to emit a file review', async () => { - let inputs: any; - const env = createTestEnv({ - AI: { - async run(_model: string, request: any) { - inputs = request; - return { - choices: [{ message: { content: '{"results":[]}' } }], - usage: { prompt_tokens: 1, completion_tokens: 1 }, - }; - }, - } as any, - }); - - await reviewWithCloudflare(env.AI, '@cf/zai-org/glm-4.7-flash', { - systemPrompt: 'system', - userPrompt: 'user', - responseSchema: VERIFY_RESPONSE_SCHEMA as any, - }); - - expect(inputs.response_format.json_schema.name).toBe('codra_verify_findings'); - expect(inputs.response_format.json_schema.schema.properties.results).toBeDefined(); - }); - - // The adapter's input type once omitted `responseSchema`, so callers' grammars were dropped. - describe('Gemini constrained decoding', () => { - function geminiOk() { - return new Response( - JSON.stringify({ - candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] }, finishReason: 'STOP' }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - - // Last call, not first: captures accumulate within a test, so `calls[0]` is the earliest. - async function captureGeminiBody(input: any) { - const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(geminiOk()); - await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', input); - return JSON.parse(String(fetchMock.mock.calls.at(-1)?.[1]?.body)); - } - - // Enumerated, not `toBeUndefined()`, which would pass on a misspelled field name. - const schemaKeys = (body: any) => Object.keys(body.generationConfig).filter((key) => /schema/i.test(key)); - - it('sends the caller\'s grammar as responseJsonSchema, or none at all', async () => { - const review = await captureGeminiBody({ systemPrompt: 'system', userPrompt: 'user', responseSchema: buildReviewResponseSchema(10) }); - expect(schemaKeys(review)).toEqual(['responseJsonSchema']); - expect(review.generationConfig.responseJsonSchema.properties.findings.maxItems).toBe(10); - expect(review.generationConfig.responseMimeType).toBe('application/json'); - // No `outputBudgetTokens` on this input, so the adapter's own default answer budget applies -- and - // the bounded thinking budget is added ON TOP of it, never carved out of it. - expect(review.generationConfig.thinkingConfig.thinkingBudget).toBe(2048); - expect(review.generationConfig.maxOutputTokens).toBe(8192 + 2048); - - // Per-call, not hardcoded: forcing the review grammar onto the verify pass made it unsatisfiable. - const verify = await captureGeminiBody({ systemPrompt: 'system', userPrompt: 'user', responseSchema: VERIFY_RESPONSE_SCHEMA as any }); - expect(verify.generationConfig.responseJsonSchema.properties.results).toBeDefined(); - expect(verify.generationConfig.responseJsonSchema.properties.findings).toBeUndefined(); - - // The summary path passes no grammar and must keep working unconstrained. - const none = await captureGeminiBody({ systemPrompt: 'system', userPrompt: 'user' }); - expect(schemaKeys(none)).toEqual([]); - expect(none.generationConfig.responseMimeType).toBe('application/json'); - }); - - it('memoizes a refused grammar per grammar, not per endpoint', async () => { - // Refusing only the batched grammar makes both the memo and its grammar-keying observable. - const sent: any[] = []; - const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { - const schema = JSON.parse(String(init?.body)).generationConfig.responseJsonSchema; - sent.push(schema); - return schema?.properties?.files - ? new Response( - JSON.stringify({ error: { code: 400, status: 'INVALID_ARGUMENT', message: 'Unknown name "responseJsonSchema".' } }), - { status: 400, headers: { 'content-type': 'application/json' } }, - ) - : geminiOk(); - }); - - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const service = createTestModelRunner(env); - const call = (responseSchema: any) => (service as any).callModel('gemini-3.1-pro-preview', { systemPrompt: 'system', userPrompt: 'user', responseSchema }); - - const batch = await call(buildBatchReviewResponseSchema(10, 4)); - const batchAgain = await call(buildBatchReviewResponseSchema(10, 4)); - const review = await call(buildReviewResponseSchema(10)); - - expect(batch.degraded).toBe('schema-dropped'); - // Not degraded: the memo stripped the grammar before the call, so nothing was attempted. - expect(batchAgain.degraded).toBeUndefined(); - expect(review.degraded).toBeUndefined(); - // probe + retry, one schemaless call, then the review grammar still attempted. - expect(fetchMock).toHaveBeenCalledTimes(4); - expect(sent.filter((schema) => schema?.properties?.files)).toHaveLength(1); - expect(sent.filter((schema) => schema?.properties?.findings)).toHaveLength(1); - }); - }); -}); +import { afterEach, describe, expect, it } from 'vitest'; + +import { reviewWithCloudflare } from '@codraoss/models/cloudflare'; +import { reviewWithGoogle } from '@codraoss/models/google'; + +import { buildBatchReviewResponseSchema, buildReviewResponseSchema } from '@codraoss/core/prompts/file-review'; +import { VERIFY_RESPONSE_SCHEMA } from '@codraoss/core/prompts/verify'; +import { createTestEnv, saveTestProviderApiKey, createTestModelRunner } from '../../../../test/helpers'; + + +describe('ModelRunner: request shape and response handling', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('fails (throws) on a Cloudflare reasoning-only response instead of faking an inconclusive review', async () => { + const env = createTestEnv({ + AI: { + async run() { + return { + choices: [ + { + message: { + content: null, + reasoning: 'Long reasoning that consumed the completion budget.', + }, + finish_reason: 'length', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 4096 }, + }; + }, + } as any, + }); + + // Nothing was reviewed, so this must surface as a failure, not an "inconclusive" pass. + await expect( + reviewWithCloudflare(env.AI, '@cf/moonshotai/kimi-k2.6', { systemPrompt: 'system', userPrompt: 'user' }), + ).rejects.toThrow(/no reviewable output.*reasoning-only/i); + }); + + it('throws when Cloudflare final content is missing (does not parse reasoning as review JSON)', async () => { + const env = createTestEnv({ + AI: { + async run() { + return { + choices: [ + { + message: { + content: null, + reasoning: 'Reasoning mentioned an object like {"foo":"bar"} but never produced final JSON.', + }, + finish_reason: 'length', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 8192 }, + }; + }, + } as any, + }); + + await expect( + reviewWithCloudflare(env.AI, '@cf/zai-org/glm-4.7-flash', { systemPrompt: 'system', userPrompt: 'user' }), + ).rejects.toThrow(/no reviewable output/i); + }); + + // Per-call: forcing the file-review schema onto the verify pass made it unsatisfiable. + + it('honors a non-review schema, so the verify pass is not forced to emit a file review', async () => { + let inputs: any; + const env = createTestEnv({ + AI: { + async run(_model: string, request: any) { + inputs = request; + return { + choices: [{ message: { content: '{"results":[]}' } }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }; + }, + } as any, + }); + + await reviewWithCloudflare(env.AI, '@cf/zai-org/glm-4.7-flash', { + systemPrompt: 'system', + userPrompt: 'user', + responseSchema: VERIFY_RESPONSE_SCHEMA as any, + }); + + expect(inputs.response_format.json_schema.name).toBe('codra_verify_findings'); + expect(inputs.response_format.json_schema.schema.properties.results).toBeDefined(); + }); + + // The adapter's input type once omitted `responseSchema`, so callers' grammars were dropped. + describe('Gemini constrained decoding', () => { + function geminiOk() { + return new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] }, finishReason: 'STOP' }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + + // Last call, not first: captures accumulate within a test, so `calls[0]` is the earliest. + async function captureGeminiBody(input: any) { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(geminiOk()); + await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', input); + return JSON.parse(String(fetchMock.mock.calls.at(-1)?.[1]?.body)); + } + + // Enumerated, not `toBeUndefined()`, which would pass on a misspelled field name. + const schemaKeys = (body: any) => Object.keys(body.generationConfig).filter((key) => /schema/i.test(key)); + + it('sends the caller\'s grammar as responseJsonSchema, or none at all', async () => { + const review = await captureGeminiBody({ systemPrompt: 'system', userPrompt: 'user', responseSchema: buildReviewResponseSchema(10) }); + expect(schemaKeys(review)).toEqual(['responseJsonSchema']); + expect(review.generationConfig.responseJsonSchema.properties.findings.maxItems).toBe(10); + expect(review.generationConfig.responseMimeType).toBe('application/json'); + // No `outputBudgetTokens` on this input, so the adapter's own default answer budget applies -- and + // the bounded thinking budget is added ON TOP of it, never carved out of it. + expect(review.generationConfig.thinkingConfig.thinkingBudget).toBe(2048); + expect(review.generationConfig.maxOutputTokens).toBe(8192 + 2048); + + // Per-call, not hardcoded: forcing the review grammar onto the verify pass made it unsatisfiable. + const verify = await captureGeminiBody({ systemPrompt: 'system', userPrompt: 'user', responseSchema: VERIFY_RESPONSE_SCHEMA as any }); + expect(verify.generationConfig.responseJsonSchema.properties.results).toBeDefined(); + expect(verify.generationConfig.responseJsonSchema.properties.findings).toBeUndefined(); + + // The summary path passes no grammar and must keep working unconstrained. + const none = await captureGeminiBody({ systemPrompt: 'system', userPrompt: 'user' }); + expect(schemaKeys(none)).toEqual([]); + expect(none.generationConfig.responseMimeType).toBe('application/json'); + }); + + it('memoizes a refused grammar per grammar, not per endpoint', async () => { + // Refusing only the batched grammar makes both the memo and its grammar-keying observable. + const sent: any[] = []; + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + const schema = JSON.parse(String(init?.body)).generationConfig.responseJsonSchema; + sent.push(schema); + return schema?.properties?.files + ? new Response( + JSON.stringify({ error: { code: 400, status: 'INVALID_ARGUMENT', message: 'Unknown name "responseJsonSchema".' } }), + { status: 400, headers: { 'content-type': 'application/json' } }, + ) + : geminiOk(); + }); + + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const service = createTestModelRunner(env); + const call = (responseSchema: any) => (service as any).callModel('gemini-3.1-pro-preview', { systemPrompt: 'system', userPrompt: 'user', responseSchema }); + + const batch = await call(buildBatchReviewResponseSchema(10, 4)); + const batchAgain = await call(buildBatchReviewResponseSchema(10, 4)); + const review = await call(buildReviewResponseSchema(10)); + + expect(batch.degraded).toBe('schema-dropped'); + // Not degraded: the memo stripped the grammar before the call, so nothing was attempted. + expect(batchAgain.degraded).toBeUndefined(); + expect(review.degraded).toBeUndefined(); + // probe + retry, one schemaless call, then the review grammar still attempted. + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(sent.filter((schema) => schema?.properties?.files)).toHaveLength(1); + expect(sent.filter((schema) => schema?.properties?.findings)).toHaveLength(1); + }); + }); +}); diff --git a/packages/models/test/model/service-retries.spec.ts b/packages/models/test/model/service-retries.spec.ts index d01f2f07..3c7b88ca 100644 --- a/packages/models/test/model/service-retries.spec.ts +++ b/packages/models/test/model/service-retries.spec.ts @@ -1,228 +1,228 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { isRetryableModelError } from '@codraoss/models'; -import { reviewWithCloudflare } from '@codraoss/models/cloudflare'; -import { reviewWithGoogle } from '@codraoss/models/google'; -import { MODEL_TIMEOUT_MAX_MS } from '../../src/limits'; -import { createTestEnv, saveTestProviderApiKey, createTestModelRunner } from '../../../../test/helpers'; -import { defaultRepoConfig } from '@codraoss/schema'; - -// The retry ladder: inline retries, Retry-After, and which exhausted runs report as retryable. -describe('ModelRunner: transient failures and the retry ladder', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('retries Google once for transient 524 edge timeouts', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce( - new Response( - JSON.stringify({ error: { code: 524, message: 'A timeout occurred.' } }), - { status: 524, headers: { 'content-type': 'application/json' } }, - ), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ); - - const response = await reviewWithGoogle( - { apiKey: 'test-key' }, - 'gemini-3.1-pro-preview', - { systemPrompt: 'system', userPrompt: 'user' }, - ); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(response.rawText).toContain('"findings"'); - }); - - it('honours a Retry-After it can actually wait out', async () => { - // retry-after: 3s is inside GEMINI_MAX_RETRY_DELAY_MS (5s), so the retry fires at exactly 3s - // -- the provider's own cool-off, not our default backoff. - vi.useFakeTimers(); - try { - const fetchMock = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce( - new Response( - JSON.stringify({ error: { code: 429, message: 'Rate limited.' } }), - { status: 429, headers: { 'content-type': 'application/json', 'retry-after': '3' } }, - ), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ); - - const promise = reviewWithGoogle( - { apiKey: 'test-key' }, - 'gemini-3.1-pro-preview', - { systemPrompt: 'system', userPrompt: 'user' }, - ); - promise.catch(() => {}); - - await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); - await vi.advanceTimersByTimeAsync(2_999); - expect(fetchMock).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(1); - const response = await promise; - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(response.rawText).toContain('"findings"'); - } finally { - vi.useRealTimers(); - } - }); - - // A cool-off we cannot honour isn't worth a retry: waking early earns the same 429. - - it('gives up immediately on a Retry-After longer than the in-call sleep cap', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response( - JSON.stringify({ error: { code: 429, message: 'Rate limited.' } }), - { status: 429, headers: { 'content-type': 'application/json', 'retry-after': '56' } }, - ), - ); - - await expect( - reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', { systemPrompt: 'system', userPrompt: 'user' }), - ).rejects.toThrow(/429/); - - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - // The free-tier buckets are per-minute, so an unstated cool-off is ~60s by construction. Backing - // off ~0.8s then ~1.6s bought two more 429s and two more full prompt transmissions for nothing. - it('gives up immediately on a 429 that states no cool-off at all', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response( - JSON.stringify({ error: { code: 429, message: 'Resource has been exhausted.' } }), - { status: 429, headers: { 'content-type': 'application/json' } }, - ), - ); - - await expect( - reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', { systemPrompt: 'system', userPrompt: 'user' }), - ).rejects.toThrow(/429/); - - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it('still retries a 5xx with no Retry-After, which is a genuinely transient blip', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce( - new Response( - JSON.stringify({ error: { code: 503, message: 'The model is overloaded.' } }), - { status: 503, headers: { 'content-type': 'application/json' } }, - ), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ); - - const response = await reviewWithGoogle( - { apiKey: 'test-key' }, - 'gemini-3.1-pro-preview', - { systemPrompt: 'system', userPrompt: 'user' }, - ); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(response.rawText).toContain('"findings"'); - }); - - it('does not spend an extra queue slice retrying the same Cloudflare model inline', async () => { - let attempts = 0; - const env = createTestEnv({ - AI: { - async run() { - attempts++; - throw new Error('temporary provider error'); - }, - } as any, - }); - - await expect( - reviewWithCloudflare(env.AI, '@cf/zai-org/glm-4.7-flash', { - systemPrompt: 'system', - userPrompt: 'user', - }), - ).rejects.toThrow('temporary provider error'); - expect(attempts).toBe(1); - }); - - it('aborts and fails fast (as a retryable timeout) when a Cloudflare model hangs past the timeout', async () => { - vi.useFakeTimers(); - try { - let capturedSignal: AbortSignal | undefined; - const env = createTestEnv({ - AI: { - run(_model: string, _request: any, options?: { signal?: AbortSignal }) { - capturedSignal = options?.signal; - // Model never responds -- only the timeout can end this call. - return new Promise(() => {}); - }, - } as any, - }); - - const promise = reviewWithCloudflare(env.AI, '@cf/zai-org/glm-4.7-flash', { - systemPrompt: 'system', - userPrompt: 'user', - }); - // Prevent an unhandled-rejection warning while the timer is still pending. - promise.catch(() => {}); - - // Derived, not hardcoded: pinning the number here meant raising the ceiling made this test - // advance past nothing, so the promise never settled and the run hung on fake timers. - await vi.advanceTimersByTimeAsync(MODEL_TIMEOUT_MAX_MS); - - await expect(promise).rejects.toThrow(`timed out after ${MODEL_TIMEOUT_MAX_MS}ms`); - // The underlying Workers-AI request was actually cancelled, not just abandoned. - expect(capturedSignal?.aborted).toBe(true); - } finally { - vi.useRealTimers(); - } - }); - - it('classifies an exhausted run of Google 5xx failures as retryable (not a permanent file failure)', async () => { - // A sustained 5xx outage defers rather than fails. Fresh Response per call: a body reads once. - const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => - new Response( - JSON.stringify({ error: { code: 500, message: 'Internal error encountered.', status: 'INTERNAL' } }), - { status: 500, headers: { 'content-type': 'application/json' } }, - ), - ); - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const service = createTestModelRunner(env); - - await expect( - service.reviewFile({ - file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null }, - prTitle: 'Test', - prDescription: null, - config: { - ...defaultRepoConfig, - model: { main: 'gemini-3.1-pro-preview', fallbacks: ['gemini-2.5-pro'], size_overrides: [] }, - }, - totalLineCount: 1, - }), - ).rejects.toSatisfy(isRetryableModelError); - expect(fetchMock).toHaveBeenCalled(); - }); - -}); +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { isRetryableModelError } from '@codraoss/models'; +import { reviewWithCloudflare } from '@codraoss/models/cloudflare'; +import { reviewWithGoogle } from '@codraoss/models/google'; +import { MODEL_TIMEOUT_MAX_MS } from '../../src/limits'; +import { createTestEnv, saveTestProviderApiKey, createTestModelRunner } from '../../../../test/helpers'; +import { defaultRepoConfig } from '@codraoss/schema'; + +// The retry ladder: inline retries, Retry-After, and which exhausted runs report as retryable. +describe('ModelRunner: transient failures and the retry ladder', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('retries Google once for transient 524 edge timeouts', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ error: { code: 524, message: 'A timeout occurred.' } }), + { status: 524, headers: { 'content-type': 'application/json' } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + + const response = await reviewWithGoogle( + { apiKey: 'test-key' }, + 'gemini-3.1-pro-preview', + { systemPrompt: 'system', userPrompt: 'user' }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(response.rawText).toContain('"findings"'); + }); + + it('honours a Retry-After it can actually wait out', async () => { + // retry-after: 3s is inside GEMINI_MAX_RETRY_DELAY_MS (5s), so the retry fires at exactly 3s + // -- the provider's own cool-off, not our default backoff. + vi.useFakeTimers(); + try { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ error: { code: 429, message: 'Rate limited.' } }), + { status: 429, headers: { 'content-type': 'application/json', 'retry-after': '3' } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + + const promise = reviewWithGoogle( + { apiKey: 'test-key' }, + 'gemini-3.1-pro-preview', + { systemPrompt: 'system', userPrompt: 'user' }, + ); + promise.catch(() => {}); + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync(2_999); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + const response = await promise; + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(response.rawText).toContain('"findings"'); + } finally { + vi.useRealTimers(); + } + }); + + // A cool-off we cannot honour isn't worth a retry: waking early earns the same 429. + + it('gives up immediately on a Retry-After longer than the in-call sleep cap', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ error: { code: 429, message: 'Rate limited.' } }), + { status: 429, headers: { 'content-type': 'application/json', 'retry-after': '56' } }, + ), + ); + + await expect( + reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', { systemPrompt: 'system', userPrompt: 'user' }), + ).rejects.toThrow(/429/); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + // The free-tier buckets are per-minute, so an unstated cool-off is ~60s by construction. Backing + // off ~0.8s then ~1.6s bought two more 429s and two more full prompt transmissions for nothing. + it('gives up immediately on a 429 that states no cool-off at all', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ error: { code: 429, message: 'Resource has been exhausted.' } }), + { status: 429, headers: { 'content-type': 'application/json' } }, + ), + ); + + await expect( + reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', { systemPrompt: 'system', userPrompt: 'user' }), + ).rejects.toThrow(/429/); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('still retries a 5xx with no Retry-After, which is a genuinely transient blip', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ error: { code: 503, message: 'The model is overloaded.' } }), + { status: 503, headers: { 'content-type': 'application/json' } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + + const response = await reviewWithGoogle( + { apiKey: 'test-key' }, + 'gemini-3.1-pro-preview', + { systemPrompt: 'system', userPrompt: 'user' }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(response.rawText).toContain('"findings"'); + }); + + it('does not spend an extra queue slice retrying the same Cloudflare model inline', async () => { + let attempts = 0; + const env = createTestEnv({ + AI: { + async run() { + attempts++; + throw new Error('temporary provider error'); + }, + } as any, + }); + + await expect( + reviewWithCloudflare(env.AI, '@cf/zai-org/glm-4.7-flash', { + systemPrompt: 'system', + userPrompt: 'user', + }), + ).rejects.toThrow('temporary provider error'); + expect(attempts).toBe(1); + }); + + it('aborts and fails fast (as a retryable timeout) when a Cloudflare model hangs past the timeout', async () => { + vi.useFakeTimers(); + try { + let capturedSignal: AbortSignal | undefined; + const env = createTestEnv({ + AI: { + run(_model: string, _request: any, options?: { signal?: AbortSignal }) { + capturedSignal = options?.signal; + // Model never responds -- only the timeout can end this call. + return new Promise(() => {}); + }, + } as any, + }); + + const promise = reviewWithCloudflare(env.AI, '@cf/zai-org/glm-4.7-flash', { + systemPrompt: 'system', + userPrompt: 'user', + }); + // Prevent an unhandled-rejection warning while the timer is still pending. + promise.catch(() => {}); + + // Derived, not hardcoded: pinning the number here meant raising the ceiling made this test + // advance past nothing, so the promise never settled and the run hung on fake timers. + await vi.advanceTimersByTimeAsync(MODEL_TIMEOUT_MAX_MS); + + await expect(promise).rejects.toThrow(`timed out after ${MODEL_TIMEOUT_MAX_MS}ms`); + // The underlying Workers-AI request was actually cancelled, not just abandoned. + expect(capturedSignal?.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('classifies an exhausted run of Google 5xx failures as retryable (not a permanent file failure)', async () => { + // A sustained 5xx outage defers rather than fails. Fresh Response per call: a body reads once. + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + new Response( + JSON.stringify({ error: { code: 500, message: 'Internal error encountered.', status: 'INTERNAL' } }), + { status: 500, headers: { 'content-type': 'application/json' } }, + ), + ); + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const service = createTestModelRunner(env); + + await expect( + service.reviewFile({ + file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null }, + prTitle: 'Test', + prDescription: null, + config: { + ...defaultRepoConfig, + model: { main: 'gemini-3.1-pro-preview', fallbacks: ['gemini-2.5-pro'], size_overrides: [] }, + }, + totalLineCount: 1, + }), + ).rejects.toSatisfy(isRetryableModelError); + expect(fetchMock).toHaveBeenCalled(); + }); + +}); diff --git a/packages/models/test/url-guard.spec.ts b/packages/models/test/url-guard.spec.ts index d5ec2e06..7de565b2 100644 --- a/packages/models/test/url-guard.spec.ts +++ b/packages/models/test/url-guard.spec.ts @@ -1,84 +1,84 @@ -import { describe, expect, it } from 'vitest'; -import { assertPublicBaseUrl, isPrivateHost, isValidPublicUrl } from '../src/url-guard'; -import { ProviderRequestError } from '../src/types'; - -// Guards SSRF via user-supplied provider base URLs (was missing from Anthropic adapter). -describe('provider base URL guard', () => { - it('rejects loopback, link-local and RFC1918 hosts', () => { - const blocked = [ - 'http://127.0.0.1/v1', - 'http://localhost:8080/v1', - 'http://10.0.0.5/v1', - 'http://192.168.1.1/v1', - 'http://172.16.0.1/v1', - 'http://172.31.255.255/v1', - 'http://169.254.169.254/latest/meta-data', // cloud metadata range - ]; - for (const url of blocked) { - expect(isValidPublicUrl(url), url).toBe(false); - } - }); - - // hostname includes brackets, e.g. "[::1]"; a bare /^::1$/ regex would miss it - it('rejects IPv6 private ranges, brackets and all', () => { - const blocked = [ - 'http://[::1]/v1', - 'http://[::]/v1', - 'http://[fc00::1]/v1', - 'http://[fd12:3456::1]/v1', - 'http://[fe80::1]/v1', - 'http://[::ffff:127.0.0.1]/v1', - ]; - for (const url of blocked) { - expect(isValidPublicUrl(url), url).toBe(false); - } - expect(isValidPublicUrl('http://[2606:4700::1111]/v1')).toBe(true); - }); - - it('rejects cloud metadata endpoints by name', () => { - expect(isValidPublicUrl('http://metadata.google.internal/computeMetadata/v1')).toBe(false); - expect(isValidPublicUrl('http://100.100.100.200/latest/meta-data')).toBe(false); - }); - - it('rejects non-HTTP schemes and unparseable input', () => { - expect(isValidPublicUrl('file:///etc/passwd')).toBe(false); - expect(isValidPublicUrl('ftp://example.com')).toBe(false); - expect(isValidPublicUrl('not a url')).toBe(false); - expect(isValidPublicUrl('')).toBe(false); - }); - - it('allows genuine public endpoints', () => { - expect(isValidPublicUrl('https://api.anthropic.com/v1')).toBe(true); - expect(isValidPublicUrl('https://generativelanguage.googleapis.com/v1beta')).toBe(true); - expect(isValidPublicUrl('https://api.openai.com/v1')).toBe(true); - expect(isValidPublicUrl('http://172.32.0.1/v1')).toBe(true); // outside 172.16-172.31 block - }); - - it('classifies hosts without needing a full URL', () => { - expect(isPrivateHost('127.0.0.1')).toBe(true); - expect(isPrivateHost('172.15.0.1')).toBe(false); - expect(isPrivateHost('example.com')).toBe(false); - }); - - describe('assertPublicBaseUrl', () => { - it('throws a provider-shaped 400 for a blocked URL', () => { - try { - assertPublicBaseUrl('http://169.254.169.254/', 'Anthropic'); - expect.unreachable('should have thrown'); - } catch (error) { - expect(error).toBeInstanceOf(ProviderRequestError); - expect((error as ProviderRequestError).status).toBe(400); - } - }); - - it('accepts an absent base URL', () => { - expect(() => assertPublicBaseUrl(null, 'Anthropic')).not.toThrow(); - expect(() => assertPublicBaseUrl(undefined, 'Google')).not.toThrow(); - expect(() => assertPublicBaseUrl('', 'OpenAI')).not.toThrow(); - }); - - it('accepts a public base URL', () => { - expect(() => assertPublicBaseUrl('https://api.anthropic.com/v1', 'Anthropic')).not.toThrow(); - }); - }); -}); +import { describe, expect, it } from 'vitest'; +import { assertPublicBaseUrl, isPrivateHost, isValidPublicUrl } from '../src/url-guard'; +import { ProviderRequestError } from '../src/types'; + +// Guards SSRF via user-supplied provider base URLs (was missing from Anthropic adapter). +describe('provider base URL guard', () => { + it('rejects loopback, link-local and RFC1918 hosts', () => { + const blocked = [ + 'http://127.0.0.1/v1', + 'http://localhost:8080/v1', + 'http://10.0.0.5/v1', + 'http://192.168.1.1/v1', + 'http://172.16.0.1/v1', + 'http://172.31.255.255/v1', + 'http://169.254.169.254/latest/meta-data', // cloud metadata range + ]; + for (const url of blocked) { + expect(isValidPublicUrl(url), url).toBe(false); + } + }); + + // hostname includes brackets, e.g. "[::1]"; a bare /^::1$/ regex would miss it + it('rejects IPv6 private ranges, brackets and all', () => { + const blocked = [ + 'http://[::1]/v1', + 'http://[::]/v1', + 'http://[fc00::1]/v1', + 'http://[fd12:3456::1]/v1', + 'http://[fe80::1]/v1', + 'http://[::ffff:127.0.0.1]/v1', + ]; + for (const url of blocked) { + expect(isValidPublicUrl(url), url).toBe(false); + } + expect(isValidPublicUrl('http://[2606:4700::1111]/v1')).toBe(true); + }); + + it('rejects cloud metadata endpoints by name', () => { + expect(isValidPublicUrl('http://metadata.google.internal/computeMetadata/v1')).toBe(false); + expect(isValidPublicUrl('http://100.100.100.200/latest/meta-data')).toBe(false); + }); + + it('rejects non-HTTP schemes and unparseable input', () => { + expect(isValidPublicUrl('file:///etc/passwd')).toBe(false); + expect(isValidPublicUrl('ftp://example.com')).toBe(false); + expect(isValidPublicUrl('not a url')).toBe(false); + expect(isValidPublicUrl('')).toBe(false); + }); + + it('allows genuine public endpoints', () => { + expect(isValidPublicUrl('https://api.anthropic.com/v1')).toBe(true); + expect(isValidPublicUrl('https://generativelanguage.googleapis.com/v1beta')).toBe(true); + expect(isValidPublicUrl('https://api.openai.com/v1')).toBe(true); + expect(isValidPublicUrl('http://172.32.0.1/v1')).toBe(true); // outside 172.16-172.31 block + }); + + it('classifies hosts without needing a full URL', () => { + expect(isPrivateHost('127.0.0.1')).toBe(true); + expect(isPrivateHost('172.15.0.1')).toBe(false); + expect(isPrivateHost('example.com')).toBe(false); + }); + + describe('assertPublicBaseUrl', () => { + it('throws a provider-shaped 400 for a blocked URL', () => { + try { + assertPublicBaseUrl('http://169.254.169.254/', 'Anthropic'); + expect.unreachable('should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProviderRequestError); + expect((error as ProviderRequestError).status).toBe(400); + } + }); + + it('accepts an absent base URL', () => { + expect(() => assertPublicBaseUrl(null, 'Anthropic')).not.toThrow(); + expect(() => assertPublicBaseUrl(undefined, 'Google')).not.toThrow(); + expect(() => assertPublicBaseUrl('', 'OpenAI')).not.toThrow(); + }); + + it('accepts a public base URL', () => { + expect(() => assertPublicBaseUrl('https://api.anthropic.com/v1', 'Anthropic')).not.toThrow(); + }); + }); +}); diff --git a/packages/schema/src/api.ts b/packages/schema/src/api.ts index 53c521c8..10501047 100644 --- a/packages/schema/src/api.ts +++ b/packages/schema/src/api.ts @@ -14,8 +14,42 @@ export type JobsResponse = { total: number; }; +export const apiActions = [ + 'jobs.read', + 'jobs.retry', + 'jobs.rerun', + 'jobs.stop', + 'jobs.delete', + 'jobs.label', + 'repos.read', + 'repos.install', + 'repos.sync', + 'repos.config.write', + 'models.read', + 'models.sync', + 'models.test', + 'models.provider.create', + 'models.provider.update', + 'models.provider.delete', + 'models.mapping.write', + 'models.global.write', + 'settings.read', + 'settings.write', + 'stats.read', + 'account.write', + 'account.updatesEmail.write', + 'reviews.enqueue', +] as const; + +export type KnownApiAction = (typeof apiActions)[number]; + +// Open union: consumers can add their own action names while the known list keeps autocomplete. +export type ApiAction = KnownApiAction | (string & {}); + export type AuthSessionResponse = { user: AuthSessionUser; + // Omitted, or a '*' entry, means "allow everything". + permissions?: string[]; }; // Durable account record persisted in Postgres (account_settings). diff --git a/packages/ui/package.json b/packages/ui/package.json index eb111254..c2662d4c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,7 +14,9 @@ "url": "https://github.com/devarshishimpi/codra/issues" }, "type": "module", - "sideEffects": false, + "sideEffects": [ + "**/*.css" + ], "exports": { ".": "./src/index.ts", "./theme": "./src/lib/theme.tsx", @@ -26,7 +28,8 @@ "./prompt-diff": "./src/lib/prompt-diff.ts", "./markdown-plugins": "./src/lib/markdown-plugins.ts", "./motion": "./src/components/motion/index.ts", - "./hooks": "./src/hooks/index.ts" + "./hooks": "./src/hooks/index.ts", + "./styles": "./src/styles/tokens.css" }, "files": [ "dist", @@ -82,11 +85,12 @@ "./hooks": { "types": "./dist/hooks/index.d.ts", "import": "./dist/hooks/index.js" - } + }, + "./styles": "./dist/styles/tokens.css" } }, "scripts": { - "build": "tsup", + "build": "tsup && node ./scripts/copy-styles.mjs", "typecheck": "tsc -p tsconfig.json", "prepack": "node ../../scripts/swap-publish-exports.mjs promote", "postpack": "node ../../scripts/swap-publish-exports.mjs restore" diff --git a/packages/ui/scripts/copy-styles.mjs b/packages/ui/scripts/copy-styles.mjs new file mode 100644 index 00000000..6e2c4792 --- /dev/null +++ b/packages/ui/scripts/copy-styles.mjs @@ -0,0 +1,8 @@ +import { copyFile, mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +await mkdir(path.join(root, 'dist/styles'), { recursive: true }); +await copyFile(path.join(root, 'src/styles/tokens.css'), path.join(root, 'dist/styles/tokens.css')); diff --git a/packages/ui/src/components/chart-primitives.tsx b/packages/ui/src/components/chart-primitives.tsx index 6ac6e7cc..994b8bc8 100644 --- a/packages/ui/src/components/chart-primitives.tsx +++ b/packages/ui/src/components/chart-primitives.tsx @@ -1,191 +1,191 @@ -import { Children, type ReactNode } from 'react'; -import { cn } from '../lib/utils'; - -export function CardDots() { - return ( -
- ); -} - -export function GraphShell({ - title, - icon, - legend, - children, - className = '', -}: { - title: string; - icon?: ReactNode; - legend?: ReactNode; - children: ReactNode; - className?: string; -}) { - return ( - // Same chrome as the dashboard stat cards: card face carries the title, the chart itself sits - // in a recessed inner panel. -
-
- {icon && {icon}} -

- {title} -

-
- -
- {/* Dot texture lives on the recessed face, where the chart reads against it. */} - - {legend && ( -
- {legend} -
- )} -
{children}
-
-
- ); -} - -export interface SeriesMarkerProps { - /** Flat CSS colour. Ignored when `hatched` is set, which paints its own fill. */ - color?: string; - /** The cross-hatched fill used for the input-token series. */ - hatched?: boolean; - /** A dashed rule instead of a swatch, for series drawn as a dashed line. */ - dashed?: boolean; -} - -/** - * The swatch that identifies a series. Shared by the legend and the tooltip so a series looks the - * same in both - a tooltip dot that doesn't match its legend chip reads as a different series. - */ -export function SeriesMarker({ color, hatched, dashed }: SeriesMarkerProps) { - if (dashed) { - return ( - - ); - } - - return ( - - ); -} - -export function LegendChip({ - color, - hatched, - dashed, - label, -}: SeriesMarkerProps & { label: string }) { - return ( - - - {label} - - ); -} - - - -export function ChartDefs({ isDark }: { isDark: boolean }) { - const hatch = isDark ? 'rgba(228,228,231,0.5)' : 'rgba(63,63,70,0.4)'; - const hatchBg = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)'; - return ( - - - - - - - - - - - - - - - ); -} - -const METER_ROW_PX = 20; -const METER_GAP_PX = 14; - -export function MeterList({ visible, children }: { visible: number; children: ReactNode }) { - const scrolls = Children.count(children) > visible; - - return ( -
-
- {children} -
-
- ); -} - -export function TickMeter({ - label, - value, - max, - color, - valueLabel, -}: { - label: string; - value: number; - max: number; - color: string; - valueLabel: string; -}) { - const SEGMENTS = 26; - const filled = value > 0 ? Math.max(1, Math.round((value / Math.max(max, 1)) * SEGMENTS)) : 0; - - return ( -
- - {label} - -
- {Array.from({ length: SEGMENTS }).map((_, i) => ( - - ))} -
- - {valueLabel} - -
- ); -} +import { Children, type ReactNode } from 'react'; +import { cn } from '../lib/utils'; + +export function CardDots() { + return ( +
+ ); +} + +export function GraphShell({ + title, + icon, + legend, + children, + className = '', +}: { + title: string; + icon?: ReactNode; + legend?: ReactNode; + children: ReactNode; + className?: string; +}) { + return ( + // Same chrome as the dashboard stat cards: card face carries the title, the chart itself sits + // in a recessed inner panel. +
+
+ {icon && {icon}} +

+ {title} +

+
+ +
+ {/* Dot texture lives on the recessed face, where the chart reads against it. */} + + {legend && ( +
+ {legend} +
+ )} +
{children}
+
+
+ ); +} + +export interface SeriesMarkerProps { + /** Flat CSS colour. Ignored when `hatched` is set, which paints its own fill. */ + color?: string; + /** The cross-hatched fill used for the input-token series. */ + hatched?: boolean; + /** A dashed rule instead of a swatch, for series drawn as a dashed line. */ + dashed?: boolean; +} + +/** + * The swatch that identifies a series. Shared by the legend and the tooltip so a series looks the + * same in both - a tooltip dot that doesn't match its legend chip reads as a different series. + */ +export function SeriesMarker({ color, hatched, dashed }: SeriesMarkerProps) { + if (dashed) { + return ( + + ); + } + + return ( + + ); +} + +export function LegendChip({ + color, + hatched, + dashed, + label, +}: SeriesMarkerProps & { label: string }) { + return ( + + + {label} + + ); +} + + + +export function ChartDefs({ isDark }: { isDark: boolean }) { + const hatch = isDark ? 'rgba(228,228,231,0.5)' : 'rgba(63,63,70,0.4)'; + const hatchBg = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)'; + return ( + + + + + + + + + + + + + + + ); +} + +const METER_ROW_PX = 20; +const METER_GAP_PX = 14; + +export function MeterList({ visible, children }: { visible: number; children: ReactNode }) { + const scrolls = Children.count(children) > visible; + + return ( +
+
+ {children} +
+
+ ); +} + +export function TickMeter({ + label, + value, + max, + color, + valueLabel, +}: { + label: string; + value: number; + max: number; + color: string; + valueLabel: string; +}) { + const SEGMENTS = 26; + const filled = value > 0 ? Math.max(1, Math.round((value / Math.max(max, 1)) * SEGMENTS)) : 0; + + return ( +
+ + {label} + +
+ {Array.from({ length: SEGMENTS }).map((_, i) => ( + + ))} +
+ + {valueLabel} + +
+ ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2f8c5898..af1668fa 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,23 +1,23 @@ -// Components -export { Alert } from './components/alert'; -export { Badge } from './components/badge'; -export { badgeVariants } from './components/badge-variants'; -export { Button, LinkButton, type ButtonProps } from './components/button'; -export { buttonVariants } from './components/button-variants'; -export { ConfirmDialog } from './components/confirm-dialog'; -export { Input, type InputProps } from './components/input'; -export { LayerCard } from './components/layer-card'; -export { Select } from './components/select'; -export { Switch, type SwitchProps } from './components/switch'; -export { Text } from './components/text'; -export { Skeleton } from './components/skeleton'; -export { EmptyState } from './components/empty-state'; -export { SectionCard } from './components/section-card'; -export { CopyButton } from './components/copy-button'; -export { BarSparkline } from './components/bar-sparkline'; -export { GithubMark } from './components/github-mark'; -export { LoadError } from './components/load-error'; - -// Chart primitives -export { GraphShell, LegendChip, SeriesMarker, ChartDefs, MeterList, TickMeter, CardDots } from './components/chart-primitives'; -export type { SeriesMarkerProps } from './components/chart-primitives'; +// Components +export { Alert } from './components/alert'; +export { Badge } from './components/badge'; +export { badgeVariants } from './components/badge-variants'; +export { Button, LinkButton, type ButtonProps } from './components/button'; +export { buttonVariants } from './components/button-variants'; +export { ConfirmDialog } from './components/confirm-dialog'; +export { Input, type InputProps } from './components/input'; +export { LayerCard } from './components/layer-card'; +export { Select } from './components/select'; +export { Switch, type SwitchProps } from './components/switch'; +export { Text } from './components/text'; +export { Skeleton } from './components/skeleton'; +export { EmptyState } from './components/empty-state'; +export { SectionCard } from './components/section-card'; +export { CopyButton } from './components/copy-button'; +export { BarSparkline } from './components/bar-sparkline'; +export { GithubMark } from './components/github-mark'; +export { LoadError } from './components/load-error'; + +// Chart primitives +export { GraphShell, LegendChip, SeriesMarker, ChartDefs, MeterList, TickMeter, CardDots } from './components/chart-primitives'; +export type { SeriesMarkerProps } from './components/chart-primitives'; diff --git a/packages/ui/src/lib/file-tree.ts b/packages/ui/src/lib/file-tree.ts index 35ca8cea..ff863c11 100644 --- a/packages/ui/src/lib/file-tree.ts +++ b/packages/ui/src/lib/file-tree.ts @@ -1,55 +1,55 @@ -import type { FileReviewRecord } from '@codraoss/schema'; - -/** Builds the collapsed directory tree the diff viewer's file list renders. */ - -export type TreeNode = - | { type: 'dir'; name: string; path: string; children: TreeNode[] } - | { type: 'file'; name: string; file: FileReviewRecord }; - -export function buildTree(files: FileReviewRecord[]): TreeNode[] { - const root: TreeNode[] = []; - - for (const file of files) { - const parts = file.filePath.split('/'); - const fileName = parts.pop()!; - let level = root; - let prefix = ''; - - for (const part of parts) { - prefix = prefix ? `${prefix}/${part}` : part; - let dir = level.find((n): n is Extract => n.type === 'dir' && n.name === part); - if (!dir) { - dir = { type: 'dir', name: part, path: prefix, children: [] }; - level.push(dir); - } - level = dir.children; - } - - level.push({ type: 'file', name: fileName, file }); - } - - // Collapse single-child directory chains (src → client → components → "src/client/components"). - function compress(nodes: TreeNode[]): TreeNode[] { - return nodes.map((node) => { - if (node.type !== 'dir') return node; - let dir = node; - while (dir.children.length === 1 && dir.children[0].type === 'dir') { - const child = dir.children[0]; - dir = { type: 'dir', name: `${dir.name}/${child.name}`, path: child.path, children: child.children }; - } - return { ...dir, children: compress(dir.children) }; - }); - } - - // Folders before files, each alphabetical - matches GitHub's ordering. - function sortNodes(nodes: TreeNode[]): TreeNode[] { - const sorted = nodes.toSorted((a, b) => { - if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; - return a.name.localeCompare(b.name); - }); - for (const n of sorted) if (n.type === 'dir') n.children = sortNodes(n.children); - return sorted; - } - - return sortNodes(compress(root)); -} +import type { FileReviewRecord } from '@codraoss/schema'; + +/** Builds the collapsed directory tree the diff viewer's file list renders. */ + +export type TreeNode = + | { type: 'dir'; name: string; path: string; children: TreeNode[] } + | { type: 'file'; name: string; file: FileReviewRecord }; + +export function buildTree(files: FileReviewRecord[]): TreeNode[] { + const root: TreeNode[] = []; + + for (const file of files) { + const parts = file.filePath.split('/'); + const fileName = parts.pop()!; + let level = root; + let prefix = ''; + + for (const part of parts) { + prefix = prefix ? `${prefix}/${part}` : part; + let dir = level.find((n): n is Extract => n.type === 'dir' && n.name === part); + if (!dir) { + dir = { type: 'dir', name: part, path: prefix, children: [] }; + level.push(dir); + } + level = dir.children; + } + + level.push({ type: 'file', name: fileName, file }); + } + + // Collapse single-child directory chains (src → client → components → "src/client/components"). + function compress(nodes: TreeNode[]): TreeNode[] { + return nodes.map((node) => { + if (node.type !== 'dir') return node; + let dir = node; + while (dir.children.length === 1 && dir.children[0].type === 'dir') { + const child = dir.children[0]; + dir = { type: 'dir', name: `${dir.name}/${child.name}`, path: child.path, children: child.children }; + } + return { ...dir, children: compress(dir.children) }; + }); + } + + // Folders before files, each alphabetical - matches GitHub's ordering. + function sortNodes(nodes: TreeNode[]): TreeNode[] { + const sorted = nodes.toSorted((a, b) => { + if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; + return a.name.localeCompare(b.name); + }); + for (const n of sorted) if (n.type === 'dir') n.children = sortNodes(n.children); + return sorted; + } + + return sortNodes(compress(root)); +} diff --git a/packages/ui/src/lib/prompt-diff.ts b/packages/ui/src/lib/prompt-diff.ts index 6a0bfd61..3a853cf4 100644 --- a/packages/ui/src/lib/prompt-diff.ts +++ b/packages/ui/src/lib/prompt-diff.ts @@ -1,92 +1,92 @@ -// Parses the prompt's padded gutter diff ("NNNN MMMM Pcontent"), not raw git output (see parseUnifiedDiff in @server/core/diff). - -export interface DiffRow { - kind: 'add' | 'del' | 'ctx' | 'hunk'; - oldNo: number | null; - newNo: number | null; - text: string; -} - -const HUNK_RE = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/; - -function parsePaddedLine(line: string) { - if (line.length < 11 || line[4] !== ' ' || line[9] !== ' ') return null; - const prefix = line[10]; - if (prefix !== '+' && prefix !== '-' && prefix !== ' ') return null; - const oldNo = line.slice(0, 4).trim(); - const newNo = line.slice(5, 9).trim(); - if (oldNo && !/^\d+$/.test(oldNo)) return null; - if (newNo && !/^\d+$/.test(newNo)) return null; - return { prefix, oldNo, newNo, content: line.slice(11) }; -} - -export function parsePromptDiff(diff: string): DiffRow[] { - const rows: DiffRow[] = []; - let started = false; - let oldNo = 0; - let newNo = 0; - - for (const line of diff.split('\n')) { - const hunk = HUNK_RE.exec(line); - if (hunk) { - oldNo = Number(hunk[1]); - newNo = Number(hunk[2]); - started = true; - rows.push({ kind: 'hunk', oldNo: null, newNo: null, text: line }); - continue; - } - if (!started) continue; // preamble before first hunk - if (line.startsWith('diff --git')) { started = false; continue; } - if (line.startsWith('\\')) continue; // no-newline marker - if (line.startsWith('[NOTE')) continue; - - const padded = parsePaddedLine(line); - if (padded) { - if (padded.prefix === '+') { - rows.push({ kind: 'add', oldNo: null, newNo: padded.newNo ? Number(padded.newNo) : null, text: padded.content }); - } else if (padded.prefix === '-') { - rows.push({ kind: 'del', oldNo: padded.oldNo ? Number(padded.oldNo) : null, newNo: null, text: padded.content }); - } else { - rows.push({ kind: 'ctx', oldNo: padded.oldNo ? Number(padded.oldNo) : null, newNo: padded.newNo ? Number(padded.newNo) : null, text: padded.content }); - } - continue; - } - - const p = line[0]; - if (p === '+') rows.push({ kind: 'add', oldNo: null, newNo: newNo++, text: line.slice(1) }); - else if (p === '-') rows.push({ kind: 'del', oldNo: oldNo++, newNo: null, text: line.slice(1) }); - else if (p === ' ') rows.push({ kind: 'ctx', oldNo: oldNo++, newNo: newNo++, text: line.slice(1) }); - } - - // drop trailing blank row from final newline - const last = rows[rows.length - 1]; - if (last && last.kind === 'ctx' && last.text === '') rows.pop(); - - return rows; -} - -// line-only scan; avoids full parse for collapsed panels -export function diffStats(diff: string | null) { - if (!diff) return { adds: 0, dels: 0, total: 0 }; - let adds = 0; - let dels = 0; - let total = 0; - let started = false; - for (const line of diff.split('\n')) { - if (HUNK_RE.test(line)) { started = true; total++; continue; } - if (!started) continue; - if (line.startsWith('diff --git')) { started = false; continue; } - const padded = parsePaddedLine(line); - if (padded) { - total++; - if (padded.prefix === '+') adds++; - else if (padded.prefix === '-') dels++; - continue; - } - const p = line[0]; - if (p === '+' && !line.startsWith('+++')) { adds++; total++; } - else if (p === '-' && !line.startsWith('---')) { dels++; total++; } - else if (p === ' ') total++; - } - return { adds, dels, total }; -} +// Parses the prompt's padded gutter diff ("NNNN MMMM Pcontent"), not raw git output (see parseUnifiedDiff in @server/core/diff). + +export interface DiffRow { + kind: 'add' | 'del' | 'ctx' | 'hunk'; + oldNo: number | null; + newNo: number | null; + text: string; +} + +const HUNK_RE = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/; + +function parsePaddedLine(line: string) { + if (line.length < 11 || line[4] !== ' ' || line[9] !== ' ') return null; + const prefix = line[10]; + if (prefix !== '+' && prefix !== '-' && prefix !== ' ') return null; + const oldNo = line.slice(0, 4).trim(); + const newNo = line.slice(5, 9).trim(); + if (oldNo && !/^\d+$/.test(oldNo)) return null; + if (newNo && !/^\d+$/.test(newNo)) return null; + return { prefix, oldNo, newNo, content: line.slice(11) }; +} + +export function parsePromptDiff(diff: string): DiffRow[] { + const rows: DiffRow[] = []; + let started = false; + let oldNo = 0; + let newNo = 0; + + for (const line of diff.split('\n')) { + const hunk = HUNK_RE.exec(line); + if (hunk) { + oldNo = Number(hunk[1]); + newNo = Number(hunk[2]); + started = true; + rows.push({ kind: 'hunk', oldNo: null, newNo: null, text: line }); + continue; + } + if (!started) continue; // preamble before first hunk + if (line.startsWith('diff --git')) { started = false; continue; } + if (line.startsWith('\\')) continue; // no-newline marker + if (line.startsWith('[NOTE')) continue; + + const padded = parsePaddedLine(line); + if (padded) { + if (padded.prefix === '+') { + rows.push({ kind: 'add', oldNo: null, newNo: padded.newNo ? Number(padded.newNo) : null, text: padded.content }); + } else if (padded.prefix === '-') { + rows.push({ kind: 'del', oldNo: padded.oldNo ? Number(padded.oldNo) : null, newNo: null, text: padded.content }); + } else { + rows.push({ kind: 'ctx', oldNo: padded.oldNo ? Number(padded.oldNo) : null, newNo: padded.newNo ? Number(padded.newNo) : null, text: padded.content }); + } + continue; + } + + const p = line[0]; + if (p === '+') rows.push({ kind: 'add', oldNo: null, newNo: newNo++, text: line.slice(1) }); + else if (p === '-') rows.push({ kind: 'del', oldNo: oldNo++, newNo: null, text: line.slice(1) }); + else if (p === ' ') rows.push({ kind: 'ctx', oldNo: oldNo++, newNo: newNo++, text: line.slice(1) }); + } + + // drop trailing blank row from final newline + const last = rows[rows.length - 1]; + if (last && last.kind === 'ctx' && last.text === '') rows.pop(); + + return rows; +} + +// line-only scan; avoids full parse for collapsed panels +export function diffStats(diff: string | null) { + if (!diff) return { adds: 0, dels: 0, total: 0 }; + let adds = 0; + let dels = 0; + let total = 0; + let started = false; + for (const line of diff.split('\n')) { + if (HUNK_RE.test(line)) { started = true; total++; continue; } + if (!started) continue; + if (line.startsWith('diff --git')) { started = false; continue; } + const padded = parsePaddedLine(line); + if (padded) { + total++; + if (padded.prefix === '+') adds++; + else if (padded.prefix === '-') dels++; + continue; + } + const p = line[0]; + if (p === '+' && !line.startsWith('+++')) { adds++; total++; } + else if (p === '-' && !line.startsWith('---')) { dels++; total++; } + else if (p === ' ') total++; + } + return { adds, dels, total }; +} diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts index 2027866b..062228c5 100644 --- a/packages/ui/src/lib/utils.ts +++ b/packages/ui/src/lib/utils.ts @@ -1,39 +1,39 @@ -import { clsx, type ClassValue } from 'clsx'; -import { twMerge } from 'tailwind-merge'; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} - -export function fmtNumber(n: number) { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`; - return n.toLocaleString(); -} - -/** - * Like {@link fmtNumber} but returns the numeric part and its unit suffix - * separately, so a card can render "1.4" large and "M" as a smaller unit. - */ -export function fmtStat(n: number): { value: string; unit: string } { - if (n >= 1_000_000) return { value: (n / 1_000_000).toFixed(1), unit: 'M' }; - if (n >= 1_000) return { value: (n / 1_000).toFixed(n >= 10_000 ? 0 : 1), unit: 'k' }; - return { value: n.toLocaleString(), unit: '' }; -} - -export function formatPreciseDuration(ms: number | null | undefined): string { - if (ms == null) return ''; - // Sub-minute: show one decimal so e.g. a 724ms review reads as "0.7s" rather than "0s". - if (ms < 60_000) { - return `${(ms / 1000).toFixed(1)}s`; - } - const totalSeconds = Math.round(ms / 1000); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - if (minutes < 60) { - return `${minutes}m ${seconds}s`; - } - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return `${hours}h ${remainingMinutes}m`; -} +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function fmtNumber(n: number) { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`; + return n.toLocaleString(); +} + +/** + * Like {@link fmtNumber} but returns the numeric part and its unit suffix + * separately, so a card can render "1.4" large and "M" as a smaller unit. + */ +export function fmtStat(n: number): { value: string; unit: string } { + if (n >= 1_000_000) return { value: (n / 1_000_000).toFixed(1), unit: 'M' }; + if (n >= 1_000) return { value: (n / 1_000).toFixed(n >= 10_000 ? 0 : 1), unit: 'k' }; + return { value: n.toLocaleString(), unit: '' }; +} + +export function formatPreciseDuration(ms: number | null | undefined): string { + if (ms == null) return ''; + // Sub-minute: show one decimal so e.g. a 724ms review reads as "0.7s" rather than "0s". + if (ms < 60_000) { + return `${(ms / 1000).toFixed(1)}s`; + } + const totalSeconds = Math.round(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) { + return `${minutes}m ${seconds}s`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; +} diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css new file mode 100644 index 00000000..637c79fc --- /dev/null +++ b/packages/ui/src/styles/tokens.css @@ -0,0 +1,345 @@ +/* + * @codraoss/ui design tokens: the tokens and classes this package's components reference, without which they render unstyled. A Tailwind v4 partial, not an entry, so import it from the app's stylesheet AFTER `@import "tailwindcss"` for the @theme/@utility directives to be processed. + */ + + +/* dark: utilities follow .dark class, not OS prefers-color-scheme. */ +@custom-variant dark (&:where(.dark, .dark *)); + +:root { + --ui-base: #ffffff; + --ui-canvas: oklch(98.75% 0 0); + --ui-line: oklch(14.5% 0 0 / 0.1); + --ui-fill: oklch(92.2% 0 0); + --ui-subtle: oklch(55.6% 0 0); + --ui-default: oklch(21% 0 0); + --ui-strong: oklch(14.5% 0 0); +} +.dark { + --ui-base: oklch(17% 0 0); + --ui-canvas: oklch(10% 0 0); + --ui-line: oklch(32% 0 0); + --ui-fill: oklch(26.9% 0 0); + --ui-subtle: oklch(70.8% 0 0); + --ui-default: oklch(97% 0 0); + --ui-strong: oklch(98.5% 0 0); +} + +:root { + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); +} + +:root { + --background: oklch(100% 0 0); + --foreground: oklch(12% 0.02 115); + --card: oklch(100% 0 0); + --card-foreground: oklch(12% 0.02 115); + --popover: oklch(100% 0 0); + --popover-foreground: oklch(12% 0.02 115); + + /* Lime darkened for AA contrast on white; .dark restores full brightness. */ + --primary: oklch(64% 0.24 115); + --primary-foreground: oklch(100% 0 0); + --btn-primary-bg: oklch(64% 0.24 115); + --btn-primary-fg: oklch(20% 0.02 118); + --btn-primary-border: oklch(72% 0.17 118); + --btn-primary-surface: oklch(95% 0.09 118); + --btn-primary-hover: oklch(90% 0.13 118); + + --secondary: oklch(96.3% 0.003 286.3); + --secondary-foreground:oklch(27.4% 0.006 286.3); + --muted: oklch(96.3% 0.003 286.3); + --muted-foreground: oklch(55.1% 0.011 286.3); + + --accent: oklch(90.9% 0.004 286.3); + --accent-foreground: oklch(20.5% 0.005 286.3); + + --destructive: oklch(55% 0.22 25); + --destructive-foreground: oklch(100% 0 0); + + --border: oklch(90.9% 0.004 286.3); + --input: oklch(90.9% 0.004 286.3); + --ring: oklch(72% 0.22 115); + + --radius: 0.75rem; + + --success: oklch(64% 0.24 115); + --success-bg: oklch(98% 0.04 115); + --success-border: oklch(85% 0.15 115); + --warning: oklch(56% 0.18 65); + --warning-bg: oklch(98% 0.04 65); + --warning-border: oklch(90% 0.12 65); + --danger: oklch(62% 0.22 25); + --danger-bg: oklch(98% 0.04 25); + --danger-border: oklch(88% 0.14 25); + --info: oklch(68% 0.18 250); + --info-bg: oklch(98% 0.04 250); + --info-border: oklch(88% 0.12 250); + + --shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.02); + --shadow-md: 0 1px 4px oklch(0% 0 0 / 0.03), 0 1px 2px oklch(0% 0 0 / 0.02); + --shadow-lg: 0 4px 16px -4px oklch(0% 0 0 / 0.04), 0 1px 6px -2px oklch(0% 0 0 / 0.03); + + --code-bg: oklch(96.3% 0.003 286.3); + --code-fg: oklch(27.4% 0.006 286.3); + --code-border: oklch(90.9% 0.004 286.3); + + /* True green/red, not brand lime, so diff rows/counts stay distinguishable. */ + --diff-add-bg: oklch(95% 0.06 150); + --diff-add-fg: oklch(48% 0.13 150); + --diff-del-bg: oklch(95% 0.05 27); + --diff-del-fg: oklch(52% 0.16 27); +} + +.dark { + --background: #000000; + --foreground: oklch(98% 0.005 115); + --card: #09090b; + --card-foreground: oklch(98% 0.005 115); + --popover: #09090b; + --popover-foreground: oklch(98% 0.005 115); + + --primary: oklch(94% 0.23 115); + --primary-foreground: oklch(12% 0.04 115); + + --btn-primary-bg: #CCE800; + --btn-primary-fg: #CCE800; + --btn-primary-border: color-mix(in oklab, #CCE800 50%, transparent); + --btn-primary-surface: color-mix(in oklab, #CCE800 8%, transparent); + --btn-primary-hover: color-mix(in oklab, #CCE800 16%, transparent); + + --secondary: oklch(18% 0.018 115); + --secondary-foreground:oklch(82% 0.012 115); + --muted: oklch(18% 0.018 115); + --muted-foreground: oklch(55% 0.015 115); + + --accent: oklch(18% 0.018 115); + --accent-foreground: oklch(91% 0.010 115); + + --destructive: oklch(60% 0.220 25); + --destructive-foreground: oklch(10% 0.015 115); + + --border: oklch(22% 0.02 115); + --input: oklch(22% 0.02 115); + --ring: oklch(94% 0.23 115); + + --success: oklch(94% 0.23 115); + --success-bg: oklch(18% 0.06 115); + --success-border: oklch(28% 0.10 115); + --warning: oklch(78% 0.165 65); + --warning-bg: oklch(18% 0.080 65); + --warning-border: oklch(35% 0.14 65); + --danger: oklch(70% 0.200 25); + --danger-bg: oklch(18% 0.080 25); + --danger-border: oklch(35% 0.14 25); + --info: oklch(72% 0.160 250); + --info-bg: oklch(18% 0.075 250); + --info-border: oklch(35% 0.12 250); + + --shadow-sm: 0 1px 2px oklch(100% 0 0 / 0.05), 0 1px 2px oklch(0% 0 0 / 0.3); + --shadow-md: 0 4px 12px oklch(0% 0 0 / 0.45), 0 1px 4px oklch(0% 0 0 / 0.25); + --shadow-lg: 0 12px 24px -4px oklch(0% 0 0 / 0.5), 0 4px 12px -2px oklch(0% 0 0 / 0.3); + + --code-bg: oklch(20.5% 0.005 286.3); + --code-fg: oklch(86.5% 0.005 286.3); + --code-border: oklch(27.4% 0.006 286.3); + + --diff-add-bg: oklch(24% 0.055 150); + --diff-add-fg: oklch(82% 0.15 150); + --diff-del-bg: oklch(25% 0.075 27); + --diff-del-fg: oklch(80% 0.16 27); +} + +@theme inline { + --font-sans: 'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; + + --color-ui-base: var(--ui-base); + --color-ui-canvas: var(--ui-canvas); + --color-ui-line: var(--ui-line); + --color-ui-fill: var(--ui-fill); + --color-ui-subtle: var(--ui-subtle); + --color-ui-default: var(--ui-default); + --color-ui-strong: var(--ui-strong); + --color-ui-brand: var(--primary); + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground:var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + + --color-success: var(--success); + --color-success-bg: var(--success-bg); + --color-success-border: var(--success-border); + + --color-warning: var(--warning); + --color-warning-bg: var(--warning-bg); + --color-warning-border: var(--warning-border); + + --color-danger: var(--danger); + --color-danger-bg: var(--danger-bg); + --color-danger-border: var(--danger-border); + + --color-info: var(--info); + --color-info-bg: var(--info-bg); + --color-info-border: var(--info-border); + + /* radius-lg == radius-xl intentionally: cards and .surface share one size. */ + --radius-sm: 0.3125rem; + --radius-md: 0.4375rem; + --radius-lg: 0.6875rem; + --radius-xl: 0.6875rem; + --radius-2xl: 0.875rem; + + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: clamp(1.125rem, 2vw, 1.25rem); + --text-xl: clamp(1.25rem, 3vw, 1.5rem); + --text-2xl: clamp(1.5rem, 4vw, 2.25rem); + --text-3xl: clamp(2rem, 6vw, 3.5rem); + --text-4xl: clamp(2.5rem, 10vw, 6rem); + --text-display: clamp(3rem, 12vw, 9rem); + + --space-xs: clamp(0.5rem, 1vw, 0.75rem); + --space-sm: clamp(1rem, 2vw, 1.5rem); + --space-md: clamp(1.5rem, 4vw, 3rem); + --space-lg: clamp(3rem, 8vw, 6rem); + --space-xl: clamp(6rem, 12vw, 10rem); +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@utility surface { + @apply bg-card border border-border rounded-xl; + box-shadow: var(--shadow-md); +} + +.surface-static { + transition: none !important; +} + +.surface-static:hover { + box-shadow: var(--shadow-md) !important; + transform: none !important; +} + +.surface-static-shadow { + box-shadow: var(--shadow-md) !important; + transition: none !important; +} + +.surface-static-shadow:hover { + box-shadow: var(--shadow-md) !important; + transform: none !important; +} + +@utility glass { + @apply backdrop-blur-md bg-card/75 border border-border; + background-image: linear-gradient(to bottom right, oklch(100% 0 0 / 0.05), transparent); +} + +@utility surface-hover { + @apply transition-all duration-300; + &:hover { + @apply border-primary/30 shadow-lg shadow-primary/5 -translate-y-[1px]; + } +} + +@utility skeleton { + background: linear-gradient( + 90deg, + var(--muted) 25%, + color-mix(in oklch, var(--muted) 50%, var(--card)) 50%, + var(--muted) 75% + ); + background-size: 200% 100%; + @apply animate-[shimmer_1.8s_linear_infinite] rounded-sm; +} + +/* Unlayered, kept at this specificity so no later utility can beat it. */ +.skeleton { + background: + linear-gradient( + 90deg, + var(--muted) 25%, + color-mix(in oklch, var(--muted) 50%, var(--card)) 50%, + var(--muted) 75% + ) !important; + background-size: 200% 100% !important; + animation: shimmer 1.8s linear infinite !important; +} + +/* Geist, scoped locally since global @theme sets --font-sans/mono to app defaults. */ +.ui-font-sans { + font-family: 'Geist', ui-sans-serif, system-ui, sans-serif; +} +.ui-font-mono { + font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-feature-settings: 'tnum' 1; +} + +/* Matches dashboard stat-card chrome; .ui-well is its recessed inner panel. */ +.ui-panel { + font-family: 'Geist', ui-sans-serif, system-ui, sans-serif; + border-radius: var(--radius-lg); + border: 1px solid var(--ui-line); + background: #ffffff; +} +.dark .ui-panel { + background: #000000; + border-color: oklch(0.27 0 0); +} +.ui-well { + background: oklch(97.8% 0.002 286.3); + /* On the recessed face the neutral-500 subtle tone reads washed out in light mode, + so step it down to zinc-600. Dark mode already has enough separation. */ + --ui-subtle: oklch(44.2% 0.017 285.8); +} +.dark .ui-well { + background: oklch(19% 0 0); + --ui-subtle: oklch(70.8% 0 0); +} + +/* Syntax tokens for sugar-high (./lib/highlight.tsx); it emits + color: var(--sh-) per token. */ +:root { + --sh-keyword: oklch(48% 0.19 305); + --sh-string: oklch(46% 0.12 150); + --sh-class: oklch(50% 0.13 65); + --sh-comment: oklch(58% 0.01 260); + --sh-entity: oklch(46% 0.14 260); + --sh-property: oklch(45% 0.11 200); + --sh-identifier: inherit; + --sh-sign: oklch(58% 0.01 260); + --sh-jsxliterals: inherit; + --sh-break: inherit; + --sh-space: inherit; +} +.dark { + --sh-keyword: oklch(75% 0.14 305); + --sh-string: oklch(76% 0.11 150); + --sh-class: oklch(78% 0.12 65); + --sh-comment: oklch(58% 0.01 260); + --sh-entity: oklch(76% 0.1 260); + --sh-property: oklch(78% 0.1 200); +} diff --git a/scripts/outdated-rate.ts b/scripts/outdated-rate.ts index c7549f36..1a9d7e10 100644 --- a/scripts/outdated-rate.ts +++ b/scripts/outdated-rate.ts @@ -2,8 +2,8 @@ * signal for unacted rules. npx vite-node scripts/outdated-rate.ts -- --repo devarshishimpi/codra */ import { readFileSync } from 'node:fs'; import postgres from 'postgres'; -import { buildUnifiedDiffFromFiles, parseUnifiedDiff } from '@server/core/diff'; -import { buildAnchorHash } from '@server/core/fingerprint'; +import { buildUnifiedDiffFromFiles, parseUnifiedDiff } from '@codraoss/core/diff'; +import { buildAnchorHash } from '@codraoss/core/fingerprint'; const argOf = (name: string, fallback: string) => { const i = process.argv.indexOf(`--${name}`); diff --git a/src/client/main.tsx b/src/client/main.tsx deleted file mode 100644 index 2c45ac51..00000000 --- a/src/client/main.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React, { Suspense } from 'react'; -import ReactDOM from 'react-dom/client'; -import { createBrowserRouter, RouterProvider } from 'react-router-dom'; -import { Toaster } from 'sonner'; -import { AppShell } from './components/layout/app-shell'; -import { RouteErrorBoundary } from './components/shared/route-error-boundary'; - -const LandingPage = React.lazy(() => import('./pages/landing').then(m => ({ default: m.LandingPage }))); -const DashboardPage = React.lazy(() => import('./pages/dashboard').then(m => ({ default: m.DashboardPage }))); -const LoginPage = React.lazy(() => import('./pages/login').then(m => ({ default: m.LoginPage }))); -const JobsPage = React.lazy(() => import('./pages/jobs').then(m => ({ default: m.JobsPage }))); -const JobDetailPage = React.lazy(() => import('./pages/job-detail').then(m => ({ default: m.JobDetailPage }))); -const JobLogsPage = React.lazy(() => import('./pages/job-logs').then(m => ({ default: m.JobLogsPage }))); -const ReposPage = React.lazy(() => import('./pages/repos').then(m => ({ default: m.ReposPage }))); -const StatsPage = React.lazy(() => import('./pages/stats').then(m => ({ default: m.StatsPage }))); -const SettingsPage = React.lazy(() => import('./pages/settings').then(m => ({ default: m.SettingsPage }))); -const AccountPage = React.lazy(() => import('./pages/account').then(m => ({ default: m.AccountPage }))); -const NotFoundPage = React.lazy(() => import('./pages/not-found').then(m => ({ default: m.NotFoundPage }))); - -import './app.css'; - -import { ThemeProvider } from '@codraoss/ui/theme'; -import { useIsDarkMode } from '@codraoss/ui/hooks'; -import { SmoothScroll } from '@codraoss/ui/motion'; - -function ToasterWrapper() { - const isDark = useIsDarkMode(); - return ( - - ); -} - -// Render failures (including a failed lazy chunk) bubble to the branch's -// `errorElement` so there is one styled fallback instead of two. -const withSuspense = (Component: React.ComponentType, isFullPage = false) => ( - }> - - -); - -const router = createBrowserRouter([ - { - path: '/', - element: withSuspense(LandingPage, true), - errorElement: , - }, - { - path: '/login', - element: withSuspense(LoginPage, true), - errorElement: , - }, - { - element: , - errorElement: , - // Per child too, not just on the layout: React Router replaces the whole matched branch, so a - // boundary only on the branch would take the sidebar and header down with a single page. - children: [ - { path: 'dashboard', element: withSuspense(DashboardPage), errorElement: }, - { path: 'jobs', element: withSuspense(JobsPage), errorElement: }, - { path: 'jobs/:id', element: withSuspense(JobDetailPage), errorElement: }, - { path: 'jobs/:id/logs', element: withSuspense(JobLogsPage), errorElement: }, - { path: 'repos', element: withSuspense(ReposPage), errorElement: }, - { path: 'stats', element: withSuspense(StatsPage), errorElement: }, - { path: 'settings', element: withSuspense(SettingsPage), errorElement: }, - { path: 'account', element: withSuspense(AccountPage), errorElement: }, - ], - }, - { - path: '*', - element: withSuspense(NotFoundPage, true), - errorElement: , - }, -]); - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - - , -); diff --git a/src/server/core/claim-checks.ts b/src/server/core/claim-checks.ts deleted file mode 100644 index cd7b421b..00000000 --- a/src/server/core/claim-checks.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Moved to @codraoss/core/claim-checks; see the note in ./fingerprint.ts. -export * from '@codraoss/core/claim-checks'; diff --git a/src/server/core/diff/index.ts b/src/server/core/diff/index.ts deleted file mode 100644 index 51a45667..00000000 --- a/src/server/core/diff/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Moved to @codraoss/core/diff; see the note in ../fingerprint.ts. 22 importers name this path. -export * from '@codraoss/core/diff'; diff --git a/src/server/core/fingerprint.ts b/src/server/core/fingerprint.ts deleted file mode 100644 index 8b7e3083..00000000 --- a/src/server/core/fingerprint.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Moved to @codraoss/core/fingerprint. Kept as a re-export so the existing `@server/core/fingerprint` -// importers -- and the specs that name that specifier -- do not all have to change in the same -// commit as the move. -export * from '@codraoss/core/fingerprint'; diff --git a/src/server/core/http.ts b/src/server/core/http.ts deleted file mode 100644 index b0bc9dd1..00000000 --- a/src/server/core/http.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function jsonError(message: string, status = 400) { - return Response.json({ error: message }, { status }); -} - -export function wantsHtml(request: Request) { - const accept = request.headers.get('accept') ?? ''; - return accept.includes('text/html'); -} diff --git a/src/server/core/model-output/index.ts b/src/server/core/model-output/index.ts deleted file mode 100644 index d5eead39..00000000 --- a/src/server/core/model-output/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Moved to @codraoss/core/model-output; see the note in ../fingerprint.ts. -export * from '@codraoss/core/model-output'; diff --git a/src/server/core/rules/detect.ts b/src/server/core/rules/detect.ts deleted file mode 100644 index b85553b0..00000000 --- a/src/server/core/rules/detect.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Moved to @codraoss/core/rules/detect; see the note in ../fingerprint.ts. -export * from '@codraoss/core/rules/detect'; diff --git a/src/server/core/rules/table.ts b/src/server/core/rules/table.ts deleted file mode 100644 index 58445181..00000000 --- a/src/server/core/rules/table.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Moved to @codraoss/core/rules/table; see the note in ../fingerprint.ts. -export * from '@codraoss/core/rules/table'; diff --git a/src/server/core/timeout.ts b/src/server/core/timeout.ts deleted file mode 100644 index dedf69dd..00000000 --- a/src/server/core/timeout.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Moved to @codraoss/core/timeout; see the note in ./fingerprint.ts. Eight importers live outside the -// review engine (core/github/*, models/*), which is why this shim stays rather than being inlined. -export * from '@codraoss/core/timeout'; diff --git a/src/server/core/token-tracker.ts b/src/server/core/token-tracker.ts deleted file mode 100644 index 9a7c00cb..00000000 --- a/src/server/core/token-tracker.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Moved to @codraoss/core/token-tracker; see the note in ./fingerprint.ts. -export * from '@codraoss/core/token-tracker'; diff --git a/src/server/core/verify.ts b/src/server/core/verify.ts deleted file mode 100644 index cf08e7a0..00000000 --- a/src/server/core/verify.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Moved to @codraoss/core/verify; see the note in ./fingerprint.ts. -export * from '@codraoss/core/verify'; diff --git a/src/server/env.d.ts b/src/server/env.d.ts deleted file mode 100644 index b4c600bb..00000000 --- a/src/server/env.d.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { DashboardSessionUser, SessionStore } from '@codraoss/core'; -export interface AppBindings { - SESSION_STORE: SessionStore; - APP_PRIVATE_KEY: string; - GITHUB_APP_ID: string; - GITHUB_APP_SLUG?: string; - GITHUB_APP_WEBHOOK_SECRET: string; - GITHUB_CLIENT_ID: string; - GITHUB_CLIENT_SECRET: string; - AUTH_CALLBACK_URL: string; - APP_URL: string; - DASHBOARD_ALLOWED_USERS: string; - LLM_CONFIG_ENCRYPTION_KEY: string; - BOT_USERNAME: string; - ENVIRONMENT: string; - CF_API_TOKEN: string; - CF_ACCOUNT_ID: string; - APP_KV?: any; - REVIEW_QUEUE?: any; - REVIEW_WORKFLOW?: any; - ASSETS?: any; - HYPERDRIVE?: any; -} -export interface AppVariables { - sessionToken: string | null; - sessionUser: DashboardSessionUser | null; - requestId: string; -} -export type AppEnv = { - Bindings: AppBindings; - Variables: AppVariables; -}; diff --git a/src/server/env.ts b/src/server/env.ts deleted file mode 100644 index 3f85a169..00000000 --- a/src/server/env.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { DashboardSessionUser, SessionStore, IdentityProvider } from '@codraoss/core'; -export type { DashboardSessionUser }; - -export interface AppBindings { - SESSION_STORE: SessionStore; - IDENTITY_PROVIDER: IdentityProvider; - APP_PRIVATE_KEY: string; - GITHUB_APP_ID: string; - GITHUB_APP_SLUG?: string; - GITHUB_APP_WEBHOOK_SECRET: string; - GITHUB_CLIENT_ID: string; - GITHUB_CLIENT_SECRET: string; - AUTH_CALLBACK_URL: string; - APP_URL: string; - DASHBOARD_ALLOWED_USERS: string; - LLM_CONFIG_ENCRYPTION_KEY: string; - BOT_USERNAME: string; - ENVIRONMENT: string; - - // These are still used by DB for now, until DB is fully ported - CF_API_TOKEN: string; - CF_ACCOUNT_ID: string; - - // Temporary aliases while we port everything else - APP_KV: any; - REVIEW_QUEUE: any; - REVIEW_WORKFLOW: any; - ASSETS: any; - HYPERDRIVE: any; - AI: any; -} - -export interface AppVariables { - sessionToken: string | null; - sessionUser: DashboardSessionUser | null; - requestId: string; -} - -export type AppEnv = { - Bindings: AppBindings; - Variables: AppVariables; -}; diff --git a/src/server/prompts/file-review.ts b/src/server/prompts/file-review.ts deleted file mode 100644 index 4f3109fe..00000000 --- a/src/server/prompts/file-review.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Moved to @codraoss/core/prompts/file-review; see the note in ../core/fingerprint.ts. -export * from '@codraoss/core/prompts/file-review'; diff --git a/src/server/prompts/languages.ts b/src/server/prompts/languages.ts deleted file mode 100644 index 37287ba3..00000000 --- a/src/server/prompts/languages.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Moved to @codraoss/core/prompts/languages; see the note in ../core/fingerprint.ts. -export * from '@codraoss/core/prompts/languages'; diff --git a/src/server/prompts/summary.ts b/src/server/prompts/summary.ts deleted file mode 100644 index ff2f3afb..00000000 --- a/src/server/prompts/summary.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Moved to @codraoss/core/prompts/summary; see the note in ../core/fingerprint.ts. -export * from '@codraoss/core/prompts/summary'; diff --git a/src/server/prompts/verify.ts b/src/server/prompts/verify.ts deleted file mode 100644 index c5c11a45..00000000 --- a/src/server/prompts/verify.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Moved to @codraoss/core/prompts/verify; see the note in ../core/fingerprint.ts. -export * from '@codraoss/core/prompts/verify'; diff --git a/test/api/authorize.spec.ts b/test/api/authorize.spec.ts new file mode 100644 index 00000000..d123e433 --- /dev/null +++ b/test/api/authorize.spec.ts @@ -0,0 +1,128 @@ +import { createApiRouter } from '@codraoss/api'; +import type { AuthorizeContext, AuthzPort } from '@codraoss/api'; +import { getJobForProcessing, insertJob } from '@codraoss/db/jobs'; +import type { AppBindings } from '@server/env'; + +import { createTestEnv, dbDescribe, uniqueName } from '../helpers'; + +dbDescribe('Dashboard API: authorization port', () => { + const app = createApiRouter(); + + async function signIn(env: AppBindings, login = 'devarshishimpi', githubUserId = 42) { + // The fake identity must report a login present in DASHBOARD_ALLOWED_USERS or the callback rejects it. + if (env.IDENTITY_PROVIDER && 'defaultUser' in env.IDENTITY_PROVIDER) { + (env.IDENTITY_PROVIDER as any).defaultUser = { + provider: 'github', + providerUserId: String(githubUserId), + login, + name: 'Devarshi Shimpi', + avatarUrl: null, + email: null, + signedInAt: new Date().toISOString(), + metadata: { githubUserId, githubUsername: login }, + }; + } + + const authStart = await app.request('/auth/github', {}, env); + const location = authStart.headers.get('location'); + const state = location ? new URL(location).searchParams.get('state') : null; + const callback = await app.request(`/auth/github/callback?code=test-code&state=${state}`, {}, env); + const match = (callback.headers.get('set-cookie') || '').match(/codra_session=([^;]+)/); + return match ? match[1] : ''; + } + + function authHeaders(token: string) { + return { Cookie: `codra_session=${token}`, 'x-requested-with': 'XMLHttpRequest' }; + } + + async function newJob(env: AppBindings, label: string) { + return insertJob(env, { + installationId: '123', owner: 'authz-owner', repo: uniqueName(label), prNumber: 1, + prTitle: 'Authz', prAuthor: 'author', commitSha: 'a'.repeat(40), baseSha: 'b'.repeat(40), + trigger: 'auto', headRef: 'feature', baseRef: 'main', + }); + } + + it('allows every action when no authorization port is configured', async () => { + const env = createTestEnv(); + const token = await signIn(env); + const job = await newJob(env, 'authz-allow'); + + const response = await app.request(`/api/jobs/${job.id}`, { headers: authHeaders(token) }, env); + + expect(response.status).toBe(200); + }); + + it('refuses a denied action with a 403 and leaves the resource untouched', async () => { + const authz: AuthzPort = { + async authorize({ action }) { + return action === 'jobs.delete' ? { allowed: false, reason: 'read-only member' } : { allowed: true }; + }, + }; + const env = createTestEnv({}, { authz }); + const token = await signIn(env); + const job = await newJob(env, 'authz-deny'); + + const response = await app.request(`/api/jobs/${job.id}`, { + method: 'DELETE', + headers: authHeaders(token), + }, env); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'Forbidden', + code: 'forbidden', + action: 'jobs.delete', + reason: 'read-only member', + }); + + // The guard has to run before the handler does any work, not merely change the response. + expect(await getJobForProcessing(env, job.id)).not.toBeNull(); + }); + + it('passes the action and resource identity of the request to the port', async () => { + const seen: AuthorizeContext[] = []; + const authz: AuthzPort = { + async authorize(ctx) { + seen.push(ctx); + return { allowed: true }; + }, + }; + const env = createTestEnv({}, { authz }); + const token = await signIn(env); + const job = await newJob(env, 'authz-ctx'); + + await app.request('/api/settings', { headers: authHeaders(token) }, env); + await app.request(`/api/jobs/${job.id}`, { headers: authHeaders(token) }, env); + await app.request('/api/repos/some-owner/some-repo/config', { + method: 'PATCH', + headers: { ...authHeaders(token), 'content-type': 'application/json' }, + body: JSON.stringify({}), + }, env); + + expect(seen.map((c) => c.action)).toEqual(['settings.read', 'jobs.read', 'repos.config.write']); + expect(seen[1].resource).toEqual({ type: 'job', id: job.id }); + expect(seen[2].resource).toEqual({ type: 'repo', id: 'some-owner/some-repo' }); + expect(seen[0].user.login).toBeTruthy(); + }); + + it('reports computed permissions on the session endpoint, and omits the field without a port', async () => { + const withPort = createTestEnv({}, { + authz: { + async authorize() { return { allowed: true }; }, + async listPermissions() { return ['jobs.read', 'stats.read']; }, + }, + }); + const token = await signIn(withPort); + + const scoped = await app.request('/api/auth/session', { headers: authHeaders(token) }, withPort); + expect(scoped.status).toBe(200); + expect((await scoped.json() as { permissions?: string[] }).permissions).toEqual(['jobs.read', 'stats.read']); + + const plain = createTestEnv(); + const plainToken = await signIn(plain); + const unscoped = await app.request('/api/auth/session', { headers: authHeaders(plainToken) }, plain); + expect(unscoped.status).toBe(200); + expect(await unscoped.json()).not.toHaveProperty('permissions'); + }); +}); diff --git a/test/api/quota.spec.ts b/test/api/quota.spec.ts new file mode 100644 index 00000000..5573cfe7 --- /dev/null +++ b/test/api/quota.spec.ts @@ -0,0 +1,130 @@ +import { createApiRouter } from '@codraoss/api'; +import type { QuotaCheckInput, QuotaResult } from '@codraoss/api'; +import { insertJob } from '@codraoss/db/jobs'; +import type { AppBindings } from '@server/env'; + +import { createMockPRWebhook, createTestEnv, dbDescribe, uniqueName } from '../helpers'; +import { signPayload } from '../mocks/fixtures'; + +dbDescribe('Dashboard API: quota port', () => { + const app = createApiRouter(); + + async function signIn(env: AppBindings, login = 'devarshishimpi', githubUserId = 42) { + if (env.IDENTITY_PROVIDER && 'defaultUser' in env.IDENTITY_PROVIDER) { + (env.IDENTITY_PROVIDER as any).defaultUser = { + provider: 'github', + providerUserId: String(githubUserId), + login, + name: 'Devarshi Shimpi', + avatarUrl: null, + email: null, + signedInAt: new Date().toISOString(), + metadata: { githubUserId, githubUsername: login }, + }; + } + const authStart = await app.request('/auth/github', {}, env); + const location = authStart.headers.get('location'); + const state = location ? new URL(location).searchParams.get('state') : null; + const callback = await app.request(`/auth/github/callback?code=test-code&state=${state}`, {}, env); + const match = (callback.headers.get('set-cookie') || '').match(/codra_session=([^;]+)/); + return match ? match[1] : ''; + } + + function authHeaders(token: string) { + return { Cookie: `codra_session=${token}`, 'x-requested-with': 'XMLHttpRequest' }; + } + + async function newJob(env: AppBindings, label: string) { + return insertJob(env, { + installationId: '123', owner: 'quota-owner', repo: uniqueName(label), prNumber: 1, + prTitle: 'Quota', prAuthor: 'author', commitSha: 'a'.repeat(40), baseSha: 'b'.repeat(40), + trigger: 'auto', headRef: 'feature', baseRef: 'main', + }); + } + + it('answers 429 with Retry-After when the quota port refuses a dashboard action', async () => { + const env = createTestEnv({}, { + async checkQuota(): Promise { + return { allowed: false, retryAfterSeconds: 90, reason: 'monthly review limit reached' }; + }, + }); + const token = await signIn(env); + const job = await newJob(env, 'quota-deny'); + + const response = await app.request(`/api/jobs/${job.id}/rerun`, { + method: 'POST', + headers: authHeaders(token), + }, env); + + expect(response.status).toBe(429); + expect(response.headers.get('retry-after')).toBe('90'); + expect(await response.json()).toEqual({ + error: 'Too many requests', + code: 'quota_exceeded', + action: 'jobs.rerun', + reason: 'monthly review limit reached', + }); + }); + + it('passes the signed-in user to the quota port and proceeds when allowed', async () => { + const seen: QuotaCheckInput[] = []; + const env = createTestEnv({}, { + async checkQuota(input): Promise { + seen.push(input); + return { allowed: true }; + }, + }); + const token = await signIn(env); + const job = await newJob(env, 'quota-allow'); + + const response = await app.request(`/api/jobs/${job.id}/rerun`, { + method: 'POST', + headers: authHeaders(token), + }, env); + + expect(response.status).toBe(202); + expect(seen).toHaveLength(1); + expect(seen[0].action).toBe('jobs.rerun'); + expect(seen[0].user?.login).toBe('devarshishimpi'); + }); + + it('drops a quota-denied webhook as 202-ignored without enqueueing a review', async () => { + const env = createTestEnv({}, { + async checkQuota(): Promise { + return { allowed: false, reason: 'plan limit' }; + }, + }); + + const body = JSON.stringify(createMockPRWebhook({ + repository: { name: uniqueName('quota-webhook'), owner: { login: 'quota-owner' } }, + })); + const signature = await signPayload(env.GITHUB_APP_WEBHOOK_SECRET, body); + + const response = await app.request('http://codra.test/webhook', { + method: 'POST', + headers: { + 'x-github-event': 'pull_request', + 'x-github-delivery': `quota-${Date.now()}`, + 'x-hub-signature-256': signature, + }, + body, + }, env); + + expect(response.status).toBe(202); + expect(await response.json()).toEqual({ ok: true, ignored: true, reason: 'quota_exceeded' }); + expect((env.REVIEW_QUEUE as any).sent).toHaveLength(0); + }); + + it('leaves every path untouched when no quota port is configured', async () => { + const env = createTestEnv(); + const token = await signIn(env); + const job = await newJob(env, 'quota-absent'); + + const response = await app.request(`/api/jobs/${job.id}/rerun`, { + method: 'POST', + headers: authHeaders(token), + }, env); + + expect(response.status).toBe(202); + }); +}); diff --git a/test/api/router-options.spec.ts b/test/api/router-options.spec.ts new file mode 100644 index 00000000..210f1f47 --- /dev/null +++ b/test/api/router-options.spec.ts @@ -0,0 +1,86 @@ +import { createApiRouter } from '@codraoss/api'; +import type { AppBindings } from '@server/env'; + +import { createTestEnv, dbDescribe } from '../helpers'; + +dbDescribe('createApiRouter options', () => { + async function signIn(app: ReturnType, env: AppBindings) { + if (env.IDENTITY_PROVIDER && 'defaultUser' in env.IDENTITY_PROVIDER) { + (env.IDENTITY_PROVIDER as any).defaultUser = { + provider: 'github', + providerUserId: '42', + login: 'devarshishimpi', + name: 'Devarshi Shimpi', + avatarUrl: null, + email: null, + signedInAt: new Date().toISOString(), + metadata: { githubUserId: 42, githubUsername: 'devarshishimpi' }, + }; + } + const authStart = await app.request('/auth/github', {}, env); + const location = authStart.headers.get('location'); + const state = location ? new URL(location).searchParams.get('state') : null; + const callback = await app.request(`/auth/github/callback?code=test-code&state=${state}`, {}, env); + const match = (callback.headers.get('set-cookie') || '').match(/codra_session=([^;]+)/); + return match ? match[1] : ''; + } + + it('inherits the session and CSRF guards on routes mounted through options', async () => { + const app = createApiRouter({ + routes: (extended) => { + extended.get('/api/admin/ping', (c) => c.json({ ok: true })); + }, + }); + const env = createTestEnv(); + + const anonymous = await app.request('/api/admin/ping', {}, env); + expect(anonymous.status).toBe(401); + + const token = await signIn(app, env); + const authorized = await app.request('/api/admin/ping', { + headers: { Cookie: `codra_session=${token}`, 'x-requested-with': 'XMLHttpRequest' }, + }, env); + + expect(authorized.status).toBe(200); + expect(await authorized.json()).toEqual({ ok: true }); + }); + + it('runs beforeAuth middleware on unauthenticated paths such as the webhook', async () => { + const seen: string[] = []; + const app = createApiRouter({ + beforeAuth: [async (c, next) => { seen.push(new URL(c.req.url).pathname); await next(); }], + }); + const env = createTestEnv(); + + await app.request('http://codra.test/webhook', { method: 'POST', body: '{}' }, env); + await app.request('http://codra.test/api/stats', {}, env); + + expect(seen).toEqual(['/webhook', '/api/stats']); + }); + + it('gates extra pages behind the session and serves public ones openly', async () => { + const app = createApiRouter({ pages: ['/admin'], publicPages: ['/pricing'] }); + const env = createTestEnv(); + + const gated = await app.request('/admin', { headers: { accept: 'text/html' } }, env); + expect(gated.status).toBe(302); + expect(gated.headers.get('location')).toBe('/login'); + + const open = await app.request('/pricing', { headers: { accept: 'text/html' } }, env); + expect(open.status).toBe(200); + + const token = await signIn(app, env); + const authorized = await app.request('/admin', { + headers: { accept: 'text/html', Cookie: `codra_session=${token}` }, + }, env); + expect(authorized.status).toBe(200); + }); + + it('keeps the no-argument router unchanged', async () => { + const app = createApiRouter(); + const env = createTestEnv(); + + expect((await app.request('/api/admin/ping', {}, env)).status).toBe(401); + expect((await app.request('/nope-not-a-route', {}, env)).status).toBe(404); + }); +}); diff --git a/test/db/migrate-extra-dir.spec.ts b/test/db/migrate-extra-dir.spec.ts new file mode 100644 index 00000000..2dbdb4de --- /dev/null +++ b/test/db/migrate-extra-dir.spec.ts @@ -0,0 +1,82 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterAll, expect, it } from 'vitest'; +import { queryRows, runWithDb } from '@codraoss/db/client'; +import { createTestEnv, dbDescribe, getTestDatabaseUrl } from '../helpers'; + +const execFileAsync = promisify(execFile); +const env = createTestEnv(); + +const probeTable = 'codra_extra_migration_probe'; +const trackedName = 'extra:001_probe.sql'; + +async function cleanup() { + await runWithDb(env, async () => { + await queryRows(env, `DROP TABLE IF EXISTS ${probeTable}`); + await queryRows(env, 'DELETE FROM schema_migrations WHERE name = $1', [trackedName]); + }); +} + +dbDescribe('migrate.mjs --extra-dir', () => { + afterAll(cleanup); + + it('applies extra migrations after the core set and tracks them under an extra: prefix', async () => { + await cleanup(); + + const extraDir = await mkdtemp(path.join(tmpdir(), 'codra-extra-migrations-')); + try { + await writeFile( + path.join(extraDir, '001_probe.sql'), + `CREATE TABLE IF NOT EXISTS ${probeTable} (id TEXT PRIMARY KEY);\n`, + 'utf8', + ); + + const { stdout } = await execFileAsync( + process.execPath, + ['packages/db/scripts/migrate.mjs', `--extra-dir=${extraDir}`], + { + cwd: process.cwd(), + env: { ...process.env, DATABASE_URL: getTestDatabaseUrl() }, + }, + ); + + expect(stdout).toContain(`Applied ${trackedName}.`); + + await runWithDb(env, async () => { + const tracked = await queryRows<{ name: string }>( + env, + 'SELECT name FROM schema_migrations WHERE name = $1', + [trackedName], + ); + expect(tracked).toHaveLength(1); + + const core = await queryRows<{ name: string }>( + env, + "SELECT name FROM schema_migrations WHERE name = '001_initial.sql'", + ); + expect(core).toHaveLength(1); + + const probe = await queryRows<{ name: string | null }>(env, 'SELECT to_regclass($1) AS name', [ + `public.${probeTable}`, + ]); + expect(probe[0]?.name).not.toBeNull(); + }); + } finally { + await rm(extraDir, { recursive: true, force: true }); + } + }); + + it('fails loudly when the configured extra directory does not exist', async () => { + const missing = path.join(tmpdir(), 'codra-extra-migrations-does-not-exist'); + + await expect( + execFileAsync(process.execPath, ['packages/db/scripts/migrate.mjs', `--extra-dir=${missing}`], { + cwd: process.cwd(), + env: { ...process.env, DATABASE_URL: getTestDatabaseUrl() }, + }), + ).rejects.toThrow(/Extra migrations directory not readable/); + }); +}); diff --git a/test/diff-from-files.spec.ts b/test/diff-from-files.spec.ts index 67cb40e4..c3f29522 100644 --- a/test/diff-from-files.spec.ts +++ b/test/diff-from-files.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildUnifiedDiffFromFiles, parseUnifiedDiff } from '@server/core/diff'; +import { buildUnifiedDiffFromFiles, parseUnifiedDiff } from '@codraoss/core/diff'; // GitHub's unified-diff media type answers 406 `too_large` above 20,000 lines, so a large PR has to // be rebuilt from `GET /pulls/{n}/files`. What matters is that the rebuilt text is indistinguishable diff --git a/test/diff.spec.ts b/test/diff.spec.ts index c498d24d..51565a5a 100644 --- a/test/diff.spec.ts +++ b/test/diff.spec.ts @@ -6,7 +6,7 @@ import { parseDiffHeaderPath, parseUnifiedDiff, truncateFileDiff, -} from '@server/core/diff'; +} from '@codraoss/core/diff'; import { defaultRepoConfig } from '@codraoss/schema'; describe('Diff Engine Deep Dive', () => { diff --git a/test/e2e/router-extensions.spec.tsx b/test/e2e/router-extensions.spec.tsx new file mode 100644 index 00000000..69f2489e --- /dev/null +++ b/test/e2e/router-extensions.spec.tsx @@ -0,0 +1,91 @@ +/** + * @vitest-environment jsdom + */ +import { expect, it, describe, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { RouterProvider } from 'react-router-dom'; +import { Boxes } from 'lucide-react'; +import { api } from '@client/lib/api'; +import { ThemeProvider } from '@codraoss/ui/theme'; +import { buildRouter, publicRoutes, shellRoutes } from '@client/routes'; +import { navItems } from '@client/nav'; + +vi.mock('@client/lib/api', () => ({ + api: { getSession: vi.fn() }, +})); + +// createBrowserRouter captures the URL when it is created, so window.history must be set before buildRouter runs. +function renderAt(path: string, build: () => ReturnType) { + window.history.pushState({}, '', path); + const router = build(); + return render( + + + , + ); +} + +describe('Dashboard route and nav registries (JSDOM)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(api.getSession).mockResolvedValue({ + user: { + githubUserId: 42, + login: 'devarshishimpi', + name: 'Devarshi Shimpi', + avatarUrl: null, + email: null, + signedInAt: new Date().toISOString(), + }, + }); + }); + + it('exposes the built-in routes as composable arrays', () => { + expect(publicRoutes.map((r) => r.path)).toEqual(['/', '/login']); + expect(shellRoutes.map((r) => r.path)).toContain('dashboard'); + expect([...publicRoutes, ...shellRoutes].map((r) => r.path)).not.toContain('*'); + }); + + it('renders an injected shell route and its nav entry', async () => { + const { container } = renderAt('/teams', () => buildRouter({ + shellRoutes: [{ path: 'teams', element:

Teams

}], + navItems: [{ to: '/teams', label: 'Teams', icon: Boxes }], + })); + + expect(await screen.findByRole('heading', { name: 'Teams' })).toBeTruthy(); + await waitFor(() => expect(container.querySelector('a[href="/teams"]')).toBeTruthy()); + }); + + it('still falls through to the not-found route for unknown paths', async () => { + renderAt('/definitely-not-a-route', () => buildRouter({ + shellRoutes: [{ path: 'teams', element:

Teams

}], + })); + + await waitFor(() => expect(screen.queryByRole('heading', { name: 'Teams' })).toBeNull()); + }); + + it('hides a nav entry whose required permission is absent from the session', async () => { + vi.mocked(api.getSession).mockResolvedValue({ + user: { + githubUserId: 42, + login: 'devarshishimpi', + name: 'Devarshi Shimpi', + avatarUrl: null, + email: null, + signedInAt: new Date().toISOString(), + }, + permissions: ['jobs.read'], + }); + + const { container } = renderAt('/teams', () => buildRouter({ + shellRoutes: [{ path: 'teams', element:

Teams

}], + navItems: [{ to: '/teams', label: 'Teams', icon: Boxes, requiresAction: 'teams.read' }], + })); + + expect(await screen.findByRole('heading', { name: 'Teams' })).toBeTruthy(); + await waitFor(() => { + expect(container.querySelector('a[href="/teams"]')).toBeNull(); + }); + expect(navItems.every((item) => item.requiresAction === undefined)).toBe(true); + }); +}); diff --git a/test/findings/absence-gate.spec.ts b/test/findings/absence-gate.spec.ts index c1dc23d2..de063565 100644 --- a/test/findings/absence-gate.spec.ts +++ b/test/findings/absence-gate.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { parseFileReviewResponse } from '@server/core/model-output'; -import { buildPresenceIndex, checkAbsenceClaim } from '@server/core/claim-checks'; -import type { FileDiff } from '@server/core/diff'; +import { parseFileReviewResponse } from '@codraoss/core/model-output'; +import { buildPresenceIndex, checkAbsenceClaim } from '@codraoss/core/claim-checks'; +import type { FileDiff } from '@codraoss/core/diff'; import { reviewJson } from '../mocks/fixtures'; diff --git a/test/findings/blame-gate.spec.ts b/test/findings/blame-gate.spec.ts index ae61bbf4..e23408a0 100644 --- a/test/findings/blame-gate.spec.ts +++ b/test/findings/blame-gate.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { parseFileReviewResponse } from '@server/core/model-output'; -import type { FileDiff } from '@server/core/diff'; +import { parseFileReviewResponse } from '@codraoss/core/model-output'; +import type { FileDiff } from '@codraoss/core/diff'; import { reviewJson } from '../mocks/fixtures'; diff --git a/test/findings/claim-checks.spec.ts b/test/findings/claim-checks.spec.ts index 03ee026d..53ff9111 100644 --- a/test/findings/claim-checks.spec.ts +++ b/test/findings/claim-checks.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { buildPresenceIndex, checkAbsenceClaim, stripCommentsAndStrings } from '@server/core/claim-checks'; -import type { FileDiff } from '@server/core/diff'; +import { buildPresenceIndex, checkAbsenceClaim, stripCommentsAndStrings } from '@codraoss/core/claim-checks'; +import type { FileDiff } from '@codraoss/core/diff'; import { fileFromLines } from '../mocks/fixtures'; const fileWith = fileFromLines; diff --git a/test/findings/claim-types.spec.ts b/test/findings/claim-types.spec.ts index b9790714..3fdd9648 100644 --- a/test/findings/claim-types.spec.ts +++ b/test/findings/claim-types.spec.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { parseFileReviewResponse } from '@server/core/model-output'; +import { parseFileReviewResponse } from '@codraoss/core/model-output'; import { CLAIM_TYPE_DECIDABILITY, DEFAULT_DENIED_CLAIM_TYPES, claimTypes, } from '@codraoss/schema'; -import type { FileDiff } from '@server/core/diff'; +import type { FileDiff } from '@codraoss/core/diff'; import { reviewJson } from '../mocks/fixtures'; const file: FileDiff = { diff --git a/test/findings/dedupe.spec.ts b/test/findings/dedupe.spec.ts index 25673243..fe3411d0 100644 --- a/test/findings/dedupe.spec.ts +++ b/test/findings/dedupe.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { dedupeFindings } from '@server/core/model-output'; +import { dedupeFindings } from '@codraoss/core/model-output'; import type { ParsedReviewComment } from '@codraoss/schema'; // Dedupe is a UNION, not a vote. It exists to stop the same finding being posted twice, and it must diff --git a/test/findings/evidence-grounding.spec.ts b/test/findings/evidence-grounding.spec.ts index 97718e32..0d3ad6bc 100644 --- a/test/findings/evidence-grounding.spec.ts +++ b/test/findings/evidence-grounding.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { parseFileReviewResponse } from '@server/core/model-output'; -import { buildAnchorHash, buildFindingFingerprint, fnv1a32Hex, normalizeDiffText } from '@server/core/fingerprint'; -import type { FileDiff } from '@server/core/diff'; +import { parseFileReviewResponse } from '@codraoss/core/model-output'; +import { buildAnchorHash, buildFindingFingerprint, fnv1a32Hex, normalizeDiffText } from '@codraoss/core/fingerprint'; +import type { FileDiff } from '@codraoss/core/diff'; import { reviewJson } from '../mocks/fixtures'; const file: FileDiff = { diff --git a/test/findings/gold-set.spec.ts b/test/findings/gold-set.spec.ts index 7d43eae7..e7010e57 100644 --- a/test/findings/gold-set.spec.ts +++ b/test/findings/gold-set.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { parseFileReviewResponse } from '@server/core/model-output'; +import { parseFileReviewResponse } from '@codraoss/core/model-output'; import { verifyFindings } from '@server/core/review'; import { DEFAULT_DENIED_CLAIM_TYPES, defaultRepoConfig } from '@codraoss/schema'; -import type { FileDiff } from '@server/core/diff'; +import type { FileDiff } from '@codraoss/core/diff'; // The regression wall. // diff --git a/test/findings/language-gates.spec.ts b/test/findings/language-gates.spec.ts index 12495d42..d12ffc32 100644 --- a/test/findings/language-gates.spec.ts +++ b/test/findings/language-gates.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { commentSyntaxFor, stripCommentsAndStrings } from '@server/core/claim-checks'; +import { commentSyntaxFor, stripCommentsAndStrings } from '@codraoss/core/claim-checks'; // Precision varies 5.8x by language in the measured corpus (Go 0.52, Python 0.09) while every gate was // global, and the comment-syntax table sent everything outside six extensions to the JavaScript diff --git a/test/findings/non-answer.spec.ts b/test/findings/non-answer.spec.ts index 0232f84c..13227213 100644 --- a/test/findings/non-answer.spec.ts +++ b/test/findings/non-answer.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { isNonAnswerReview, NON_ANSWER_MIN_DIFF_LINES, -} from '@server/core/model-output'; +} from '@codraoss/core/model-output'; // The literal response gemini-3.5-flash-lite returned for a 253-line diff: valid JSON, zero findings, // one sentence, full confidence. 77 output tokens. Recorded verbatim so a future prompt or model change diff --git a/test/findings/prompts-batch-review.spec.ts b/test/findings/prompts-batch-review.spec.ts index e5c73233..30a7ba46 100644 --- a/test/findings/prompts-batch-review.spec.ts +++ b/test/findings/prompts-batch-review.spec.ts @@ -4,11 +4,11 @@ import { buildBatchReviewResponseSchema, buildReviewResponseSchema, reviewBreadth, -} from '@server/prompts/file-review'; +} from '@codraoss/core/prompts/file-review'; import { BIN_DIFF_CHAR_BUDGET, BIN_MAX_FILES } from '@server/core/review'; import { PROMPT_FIT_SAFETY_FACTOR, estimatePromptTokens } from '@codraoss/models'; import { defaultRepoConfig } from '@codraoss/schema'; -import type { FileDiff } from '@server/core/diff'; +import type { FileDiff } from '@codraoss/core/diff'; function file(path: string, lines: string[]): FileDiff { return { diff --git a/test/findings/prompts-file-context.spec.ts b/test/findings/prompts-file-context.spec.ts index 437d1477..e4b11462 100644 --- a/test/findings/prompts-file-context.spec.ts +++ b/test/findings/prompts-file-context.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { buildFileReviewPrompts, buildFileReviewSystemPromptBase, wantsFileContext } from '@server/prompts/file-review'; +import { buildFileReviewPrompts, buildFileReviewSystemPromptBase, wantsFileContext } from '@codraoss/core/prompts/file-review'; import { contentMatchesDiff } from '../../packages/core/src/review/file-context'; import { defaultRepoConfig, type RepoConfig } from '@codraoss/schema'; -import type { FileDiff } from '@server/core/diff'; +import type { FileDiff } from '@codraoss/core/diff'; const SOURCE = Array.from({ length: 400 }, (_, i) => `line ${i + 1}`); diff --git a/test/findings/prompts-file-review.spec.ts b/test/findings/prompts-file-review.spec.ts index 1cf79071..07ae3928 100644 --- a/test/findings/prompts-file-review.spec.ts +++ b/test/findings/prompts-file-review.spec.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { getLanguageForFile } from '@server/prompts/languages'; +import { getLanguageForFile } from '@codraoss/core/prompts/languages'; import { buildFileReviewPrompts, buildFileReviewSystemPromptBase, buildReviewResponseSchema, generatorFindingCap, reviewBreadth, -} from '@server/prompts/file-review'; +} from '@codraoss/core/prompts/file-review'; import { defaultRepoConfig } from '@codraoss/schema'; -import type { FileDiff } from '@server/core/diff'; +import type { FileDiff } from '@codraoss/core/diff'; function fileAt(path: string): FileDiff { return { diff --git a/test/findings/prompts-intent.spec.ts b/test/findings/prompts-intent.spec.ts index 2b829a95..d830a464 100644 --- a/test/findings/prompts-intent.spec.ts +++ b/test/findings/prompts-intent.spec.ts @@ -3,9 +3,9 @@ import { buildBatchReviewPrompts, buildFileReviewPrompts, changelogExcerptFromDiff, -} from '@server/prompts/file-review'; +} from '@codraoss/core/prompts/file-review'; import { defaultRepoConfig } from '@codraoss/schema'; -import type { FileDiff } from '@server/core/diff'; +import type { FileDiff } from '@codraoss/core/diff'; function file(path: string, lines: Array<{ kind: 'add' | 'context'; content: string }>): FileDiff { return { diff --git a/test/findings/review-verify.spec.ts b/test/findings/review-verify.spec.ts index 62c2830f..193f1a11 100644 --- a/test/findings/review-verify.spec.ts +++ b/test/findings/review-verify.spec.ts @@ -1,6 +1,6 @@ import { verifyFindings } from '@server/core/review'; import { defaultRepoConfig, type ParsedReviewComment } from '@codraoss/schema'; -import type { FileDiff } from '@server/core/diff'; +import type { FileDiff } from '@codraoss/core/diff'; const files: FileDiff[] = [ { diff --git a/test/findings/rules-detect.spec.ts b/test/findings/rules-detect.spec.ts index 76cd0b91..bcdb95f3 100644 --- a/test/findings/rules-detect.spec.ts +++ b/test/findings/rules-detect.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { scanFileForRuleHits, ruleHitsToComments } from '@server/core/rules/detect'; -import { RULES } from '@server/core/rules/table'; +import { scanFileForRuleHits, ruleHitsToComments } from '@codraoss/core/rules/detect'; +import { RULES } from '@codraoss/core/rules/table'; import { CLAIM_TYPE_DECIDABILITY, DEFAULT_SHADOW_RULE_IDS } from '@codraoss/schema'; import { addedLinesFile } from '../mocks/fixtures'; diff --git a/test/findings/rules-pipeline.spec.ts b/test/findings/rules-pipeline.spec.ts index 6fe30448..2e5c5cec 100644 --- a/test/findings/rules-pipeline.spec.ts +++ b/test/findings/rules-pipeline.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { dedupeFindings } from '@server/core/model-output'; -import { ruleHitsToComments, scanFileForRuleHits } from '@server/core/rules/detect'; +import { dedupeFindings } from '@codraoss/core/model-output'; +import { ruleHitsToComments, scanFileForRuleHits } from '@codraoss/core/rules/detect'; import { defaultRepoConfig } from '@codraoss/schema'; -import type { FileDiff } from '@server/core/diff'; +import type { FileDiff } from '@codraoss/core/diff'; import { addedLinesFile } from '../mocks/fixtures'; const fileWith = addedLinesFile; diff --git a/test/findings/undecidable-claims.spec.ts b/test/findings/undecidable-claims.spec.ts index e5e2bf9a..2a920c4f 100644 --- a/test/findings/undecidable-claims.spec.ts +++ b/test/findings/undecidable-claims.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { looksLikeExternalVersionClaim, refuteUndecidableClaim } from '@server/core/claim-checks'; -import { parseFileReviewResponse } from '@server/core/model-output'; -import type { FileDiff } from '@server/core/diff'; +import { looksLikeExternalVersionClaim, refuteUndecidableClaim } from '@codraoss/core/claim-checks'; +import { parseFileReviewResponse } from '@codraoss/core/model-output'; +import type { FileDiff } from '@codraoss/core/diff'; // Verbatim PR #86 findings: all false, all asserting facts outside the diff. Evidence grounding // and the verifier both passed them, so only deterministic refutation catches this. diff --git a/test/findings/verify.spec.ts b/test/findings/verify.spec.ts index e32cef20..225618e2 100644 --- a/test/findings/verify.spec.ts +++ b/test/findings/verify.spec.ts @@ -1,5 +1,5 @@ -import { parseVerifyResponse, renderDiffSnippet, buildVerifyPrompt, type VerifyCandidate } from '@server/prompts/verify'; -import type { FileDiff } from '@server/core/diff'; +import { parseVerifyResponse, renderDiffSnippet, buildVerifyPrompt, type VerifyCandidate } from '@codraoss/core/prompts/verify'; +import type { FileDiff } from '@codraoss/core/diff'; const file: FileDiff = { path: 'src/foo.ts', diff --git a/test/helpers.ts b/test/helpers.ts index bf42fac7..fa2e8b88 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -6,6 +6,7 @@ import { queryRows } from '@codraoss/db/client'; import { getResolvedModelConfig } from '@codraoss/db/model-configs'; import type { TokenTracker } from '@codraoss/core/token-tracker'; import { createApiRouterDeps } from '../apps/worker/src/api-deps'; +import type { ApiRouterDeps } from '@codraoss/api'; export class MemoryKV { private readonly store = new Map(); @@ -44,7 +45,6 @@ export class MemoryKV { } } -// Strict in the two ways a forgiving mock let production outages ship green: throws on a detached `fetch` call, and 307s an explicit /index.html. export class MockAssets { private readonly self = 'mock-assets'; @@ -119,7 +119,10 @@ export function hasConfiguredTestDatabaseUrl() { import { FakeIdentityProvider } from '../packages/core/test/fakes/identity-provider'; -export function createTestEnv(overrides: Partial = {}): AppBindings { +export function createTestEnv( + overrides: Partial = {}, + depsOverrides: Partial = {}, +): AppBindings { const env = { AI: { async run() { @@ -151,7 +154,7 @@ export function createTestEnv(overrides: Partial = {}): AppBindings get CF_ACCOUNT_ID() { return unusedEnv('CF_ACCOUNT_ID'); }, ...overrides, } as AppBindings; - (env as any).deps = createApiRouterDeps(env, {} as any); + (env as any).deps = Object.assign(createApiRouterDeps(env, {} as any), depsOverrides); return env; } @@ -166,10 +169,7 @@ export function createTestModelRunner(env: AppBindings, tracker?: TokenTracker, }); } -// These Gemini fixtures are NOT real catalog entries -- only Cloudflare models are seeded by -// ensureModelCatalog -- so tests must create them here, or they'd pass locally and fail on a fresh -// CI database. gemini-3.1-flash-lite lets a test assert fall-through to a model that actually -// ANSWERS, not just that the metered models were skipped. +// ensureModelCatalog seeds only Cloudflare models, so these Gemini fixtures must be created here or a fresh CI database fails. const GOOGLE_TEST_MODEL_IDS = ['gemini-3.1-pro-preview', 'gemini-2.5-pro', 'gemini-3.1-flash-lite']; export async function saveTestProviderApiKey(env: AppBindings, providerName = 'Google', apiKey = 'test-key') { @@ -240,16 +240,10 @@ export function createMockPRWebhook(overrides: any = {}) { }; } -// The `.slice(0, 40)` is load-bearing: without it, `seed.repeat(40)` produces an 80-character string -// for any two-character seed, and nothing validates the length so the bug never surfaces as a failure. export const sha = (seed: string) => seed.repeat(40).slice(0, 40); -// `describe` that skips when TEST_DATABASE_URL is unset, so the suite still runs without Postgres. export const dbDescribe = hasConfiguredTestDatabaseUrl() ? describe : describe.skip; -// DB-backed suites isolate themselves by repo name rather than truncating tables, which is what lets -// them run in parallel. `Date.now()` alone collides when two workers start in the same millisecond, -// so the counter and random block are both needed to make a collision impossible. let nameSeq = 0; export function uniqueName(prefix: string) { nameSeq += 1; diff --git a/test/mocks/fixtures.ts b/test/mocks/fixtures.ts index c4234e6f..c2f249d3 100644 --- a/test/mocks/fixtures.ts +++ b/test/mocks/fixtures.ts @@ -1,4 +1,4 @@ -import type { FileDiff, DiffLine } from '@server/core/diff'; +import type { FileDiff, DiffLine } from '@codraoss/core/diff'; // Shared fixture builders, deduped from parser/rule/webhook suites. diff --git a/test/review/chunk-concurrency.spec.ts b/test/review/chunk-concurrency.spec.ts index d6149320..7566b839 100644 --- a/test/review/chunk-concurrency.spec.ts +++ b/test/review/chunk-concurrency.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { budgetAwareFileLimit, estimatedSubrequestsPerFile } from '@server/core/review'; -import { TokenTracker } from '@server/core/token-tracker'; +import { TokenTracker } from '@codraoss/core/token-tracker'; import { REVIEW_CONCURRENCY_LIMITS, reviewConcurrencyLevels } from '@codraoss/schema'; // Regression guard for "concurrency slider is dead above medium": the per-chunk budget cap must diff --git a/test/review/comments.spec.ts b/test/review/comments.spec.ts index fb4cba2d..ed71896a 100644 --- a/test/review/comments.spec.ts +++ b/test/review/comments.spec.ts @@ -1,139 +1,139 @@ -import { describe, it, expect, vi } from 'vitest'; -import { GitHubClient } from '@codraoss/provider-github'; -import type { ReviewComment } from '@codraoss/core/ports'; - -// Regression: inline comments silently stopped reaching GitHub because `createReview` kept only -// comments carrying a legacy diff `position` -- a value nothing in the pipeline computes anymore -// (the model reports a file `line`). Every review posted with just the summary body while the -// summary still claimed N findings were shown. The old suite stubbed `createReview` wholesale, so -// nothing inspected the request body. -function clientWithCapturedRequest() { - const client = new GitHubClient({} as never, '123'); - const sent: { url: string; body: any }[] = []; - - // `request` is the single choke point every GitHub call funnels through. - vi.spyOn(client as any, 'request').mockImplementation(async (...args: unknown[]) => { - const [url, init] = args as [string, RequestInit | undefined]; - sent.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined }); - return new Response(JSON.stringify({ id: 555 }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - }); - - return { client, sent }; -} - -const comment = (over: Partial = {}): ReviewComment => ({ - path: 'src/app.ts', - line: 12, - body: 'Something is wrong here.', - ...over, -}); - -describe('createReview inline comment payload', () => { - it('sends line-addressed comments through to GitHub', async () => { - const { client, sent } = clientWithCapturedRequest(); - - await client.createReview('o', 'r', 7, { - commitSha: 'abc123', - event: 'COMMENT', - body: 'summary', - comments: [comment(), comment({ path: 'src/b.ts', line: 40 })], - }); - - const payload = sent.at(-1)!.body; - // The regression: this array used to arrive empty. - expect(payload.comments).toHaveLength(2); - expect(payload.comments[0]).toMatchObject({ path: 'src/app.ts', line: 12, side: 'RIGHT' }); - expect(payload.comments[1]).toMatchObject({ path: 'src/b.ts', line: 40, side: 'RIGHT' }); - expect(payload.commit_id).toBe('abc123'); - }); - - it('honours an explicit side', async () => { - const { client, sent } = clientWithCapturedRequest(); - await client.createReview('o', 'r', 7, { - commitSha: 'abc123', event: 'COMMENT', body: 'summary', - comments: [comment({ side: 'LEFT' })], - }); - expect(sent.at(-1)!.body.comments[0].side).toBe('LEFT'); - }); - - it('still supports legacy position-addressed comments', async () => { - const { client, sent } = clientWithCapturedRequest(); - await client.createReview('o', 'r', 7, { - commitSha: 'abc123', event: 'COMMENT', body: 'summary', - comments: [{ path: 'src/app.ts', position: 4, body: 'legacy' }], - }); - const [only] = sent.at(-1)!.body.comments; - expect(only).toMatchObject({ path: 'src/app.ts', position: 4 }); - expect(only.line).toBeUndefined(); - }); - - it('drops only the comments that have no usable anchor', async () => { - const { client, sent } = clientWithCapturedRequest(); - await client.createReview('o', 'r', 7, { - commitSha: 'abc123', event: 'COMMENT', body: 'summary', - comments: [ - comment({ line: 3 }), - { path: 'src/x.ts', body: 'no anchor at all' }, - { path: 'src/y.ts', line: 0, body: 'zero is not a line' }, - ], - }); - const { comments } = sent.at(-1)!.body; - expect(comments).toHaveLength(1); - expect(comments[0].line).toBe(3); - }); - - it('keeps the summary body when GitHub rejects the inline comments', async () => { - const client = new GitHubClient({} as never, '123'); - const sent: any[] = []; - let call = 0; - vi.spyOn(client as any, 'request').mockImplementation(async (...args: unknown[]) => { - const [, init] = args as [string, RequestInit]; - sent.push(JSON.parse(String(init.body))); - call += 1; - // First attempt (with comments) is rejected the way GitHub rejects an - // out-of-diff line; the retry must still land the summary. - if (call === 1) { - return new Response('{"message":"line must be part of the diff"}', { status: 422 }); - } - return new Response(JSON.stringify({ id: 777 }), { - status: 200, headers: { 'content-type': 'application/json' }, - }); - }); - - const review = await client.createReview('o', 'r', 7, { - commitSha: 'abc123', event: 'COMMENT', body: 'summary', comments: [comment()], - }); - - expect(review.id).toBe(777); - expect(sent[0].comments).toHaveLength(1); - expect(sent[1].comments).toHaveLength(0); - expect(sent[1].body).toBe('summary'); - // Nothing was actually shown, so nothing may be recorded as posted -- otherwise the finding - // would be suppressed on every later commit without a human ever having seen it. - expect(review.postedIndices).toEqual([]); - }); - - it('reports which comments GitHub accepted, by caller index', async () => { - const client = new GitHubClient({} as never, '123'); - vi.spyOn(client as any, 'request').mockResolvedValue( - new Response(JSON.stringify({ id: 42 }), { status: 200, headers: { 'content-type': 'application/json' } }), - ); - - const review = await client.createReview('o', 'r', 7, { - commitSha: 'abc123', - event: 'COMMENT', - body: 'summary', - comments: [ - comment({ line: 3 }), - // Unaddressable: dropped before the request, so its index must not be reported. - comment({ line: 0, position: 0 }), - comment({ line: 9 }), - ], - }); - - expect(review.postedIndices).toEqual([0, 2]); - }); -}); +import { describe, it, expect, vi } from 'vitest'; +import { GitHubClient } from '@codraoss/provider-github'; +import type { ReviewComment } from '@codraoss/core/ports'; + +// Regression: inline comments silently stopped reaching GitHub because `createReview` kept only +// comments carrying a legacy diff `position` -- a value nothing in the pipeline computes anymore +// (the model reports a file `line`). Every review posted with just the summary body while the +// summary still claimed N findings were shown. The old suite stubbed `createReview` wholesale, so +// nothing inspected the request body. +function clientWithCapturedRequest() { + const client = new GitHubClient({} as never, '123'); + const sent: { url: string; body: any }[] = []; + + // `request` is the single choke point every GitHub call funnels through. + vi.spyOn(client as any, 'request').mockImplementation(async (...args: unknown[]) => { + const [url, init] = args as [string, RequestInit | undefined]; + sent.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + return new Response(JSON.stringify({ id: 555 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + + return { client, sent }; +} + +const comment = (over: Partial = {}): ReviewComment => ({ + path: 'src/app.ts', + line: 12, + body: 'Something is wrong here.', + ...over, +}); + +describe('createReview inline comment payload', () => { + it('sends line-addressed comments through to GitHub', async () => { + const { client, sent } = clientWithCapturedRequest(); + + await client.createReview('o', 'r', 7, { + commitSha: 'abc123', + event: 'COMMENT', + body: 'summary', + comments: [comment(), comment({ path: 'src/b.ts', line: 40 })], + }); + + const payload = sent.at(-1)!.body; + // The regression: this array used to arrive empty. + expect(payload.comments).toHaveLength(2); + expect(payload.comments[0]).toMatchObject({ path: 'src/app.ts', line: 12, side: 'RIGHT' }); + expect(payload.comments[1]).toMatchObject({ path: 'src/b.ts', line: 40, side: 'RIGHT' }); + expect(payload.commit_id).toBe('abc123'); + }); + + it('honours an explicit side', async () => { + const { client, sent } = clientWithCapturedRequest(); + await client.createReview('o', 'r', 7, { + commitSha: 'abc123', event: 'COMMENT', body: 'summary', + comments: [comment({ side: 'LEFT' })], + }); + expect(sent.at(-1)!.body.comments[0].side).toBe('LEFT'); + }); + + it('still supports legacy position-addressed comments', async () => { + const { client, sent } = clientWithCapturedRequest(); + await client.createReview('o', 'r', 7, { + commitSha: 'abc123', event: 'COMMENT', body: 'summary', + comments: [{ path: 'src/app.ts', position: 4, body: 'legacy' }], + }); + const [only] = sent.at(-1)!.body.comments; + expect(only).toMatchObject({ path: 'src/app.ts', position: 4 }); + expect(only.line).toBeUndefined(); + }); + + it('drops only the comments that have no usable anchor', async () => { + const { client, sent } = clientWithCapturedRequest(); + await client.createReview('o', 'r', 7, { + commitSha: 'abc123', event: 'COMMENT', body: 'summary', + comments: [ + comment({ line: 3 }), + { path: 'src/x.ts', body: 'no anchor at all' }, + { path: 'src/y.ts', line: 0, body: 'zero is not a line' }, + ], + }); + const { comments } = sent.at(-1)!.body; + expect(comments).toHaveLength(1); + expect(comments[0].line).toBe(3); + }); + + it('keeps the summary body when GitHub rejects the inline comments', async () => { + const client = new GitHubClient({} as never, '123'); + const sent: any[] = []; + let call = 0; + vi.spyOn(client as any, 'request').mockImplementation(async (...args: unknown[]) => { + const [, init] = args as [string, RequestInit]; + sent.push(JSON.parse(String(init.body))); + call += 1; + // First attempt (with comments) is rejected the way GitHub rejects an + // out-of-diff line; the retry must still land the summary. + if (call === 1) { + return new Response('{"message":"line must be part of the diff"}', { status: 422 }); + } + return new Response(JSON.stringify({ id: 777 }), { + status: 200, headers: { 'content-type': 'application/json' }, + }); + }); + + const review = await client.createReview('o', 'r', 7, { + commitSha: 'abc123', event: 'COMMENT', body: 'summary', comments: [comment()], + }); + + expect(review.id).toBe(777); + expect(sent[0].comments).toHaveLength(1); + expect(sent[1].comments).toHaveLength(0); + expect(sent[1].body).toBe('summary'); + // Nothing was actually shown, so nothing may be recorded as posted -- otherwise the finding + // would be suppressed on every later commit without a human ever having seen it. + expect(review.postedIndices).toEqual([]); + }); + + it('reports which comments GitHub accepted, by caller index', async () => { + const client = new GitHubClient({} as never, '123'); + vi.spyOn(client as any, 'request').mockResolvedValue( + new Response(JSON.stringify({ id: 42 }), { status: 200, headers: { 'content-type': 'application/json' } }), + ); + + const review = await client.createReview('o', 'r', 7, { + commitSha: 'abc123', + event: 'COMMENT', + body: 'summary', + comments: [ + comment({ line: 3 }), + // Unaddressable: dropped before the request, so its index must not be reported. + comment({ line: 0, position: 0 }), + comment({ line: 9 }), + ], + }); + + expect(review.postedIndices).toEqual([0, 2]); + }); +}); diff --git a/test/review/fragmented-packing.spec.ts b/test/review/fragmented-packing.spec.ts index 19338d17..6acb3ae5 100644 --- a/test/review/fragmented-packing.spec.ts +++ b/test/review/fragmented-packing.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; import { planReviewUnits, unitFiles } from '@server/core/review'; -import { wantsFileContext } from '@server/prompts/file-review'; -import { filterReviewableFiles } from '@server/core/diff'; +import { wantsFileContext } from '@codraoss/core/prompts/file-review'; +import { filterReviewableFiles } from '@codraoss/core/diff'; import { defaultRepoConfig, reviewMaxFilesRange } from '@codraoss/schema'; import { MAX_TOTAL_DIFF_CHARS } from '../../packages/core/src/constants'; -import type { FileDiff } from '@server/core/diff'; +import type { FileDiff } from '@codraoss/core/diff'; // Bins deliberately get no whole-file context: they exist to save subrequests, and four extra GitHub // fetches per bin inverts that. The alternative is to pull the one kind of file that suffers most from diff --git a/test/review/pack.spec.ts b/test/review/pack.spec.ts index a8e592d9..f7aca074 100644 --- a/test/review/pack.spec.ts +++ b/test/review/pack.spec.ts @@ -7,7 +7,7 @@ import { planReviewUnits, unitFiles, } from '@server/core/review'; -import type { FileDiff } from '@server/core/diff'; +import type { FileDiff } from '@codraoss/core/diff'; function file(path: string, lineCount: number, contentWidth = 20): FileDiff { return { diff --git a/test/review/pipeline-regression.spec.ts b/test/review/pipeline-regression.spec.ts index d3b64db0..1bd1fce7 100644 --- a/test/review/pipeline-regression.spec.ts +++ b/test/review/pipeline-regression.spec.ts @@ -1,143 +1,143 @@ -import { describe, expect, it } from 'vitest'; -import { parseFileReviewResponse } from '@server/core/model-output'; -import { DEFAULT_DENIED_CLAIM_TYPES } from '@codraoss/schema'; -import type { FileDiff } from '@server/core/diff'; - -// Regression over the full parse chain (JSON extraction, grounding, denylist, labels, fingerprints), one pass. -// Replaces a 3.4MB corpus that caught regressions without naming the broken behavior. Not an accuracy benchmark; see comment_feedback for that. - -const file: FileDiff = { - path: 'src/server/db/stats.ts', - previousPath: null, - isNew: false, - isDeleted: false, - isBinary: false, - lineCount: 8, - hunks: [{ - header: '@@ -10,4 +10,8 @@', - lines: [ - { kind: 'context', content: 'export async function getStats(env: Env, tz: string) {', newLineNumber: 10, oldLineNumber: 10, position: 1 }, - { kind: 'add', content: ' const rows = await sql`SELECT * FROM jobs WHERE tz = ${tz}`;', newLineNumber: 11, position: 2 }, - { kind: 'add', content: ' try { await refresh(); } catch (e) {}', newLineNumber: 12, position: 3 }, - { kind: 'add', content: ' uses: actions/checkout@v7', newLineNumber: 13, position: 4 }, - { kind: 'add', content: ' return rows;', newLineNumber: 14, position: 5 }, - { kind: 'context', content: '}', newLineNumber: 15, oldLineNumber: 11, position: 6 }, - ], - }], -}; - -// Six findings, each dropped by a different gate; markdown-fenced like real model output. -const response = `Here is my review. - -\`\`\`json -{ - "findings": [ - { - "evidence": "try { await refresh(); } catch (e) {}", - "code_location": { "absolute_file_path": "src/server/db/stats.ts", "line": 12 }, - "claim_type": "swallowed_error", - "title": "Empty catch swallows the refresh failure", - "body": "The catch block discards the error with no log and no rethrow.", - "priority": 2 - }, - { - "evidence": "const rows = await sql\`SELECT * FROM jobs WHERE tz = \${tz}\`;", - "code_location": { "absolute_file_path": "src/server/db/stats.ts", "line": 11 }, - "claim_type": "react_hook_missing_deps", - "title": "Missing hook dependency", - "body": "A claim type that cannot be decided from a diff hunk.", - "priority": 2 - }, - { - "evidence": "uses: actions/checkout@v7", - "code_location": { "absolute_file_path": "src/server/db/stats.ts", "line": 13 }, - "claim_type": "other", - "title": "Invalid GitHub Action version", - "body": "actions/checkout@v7 does not exist; the latest release is v4.", - "priority": 0 - }, - { - "evidence": "const cached = await redis.get(cacheKey);", - "code_location": { "absolute_file_path": "src/server/db/stats.ts", "line": 11 }, - "claim_type": "resource_leak", - "title": "Unclosed Redis connection", - "body": "Field-perfect, and about a line that does not exist in this diff.", - "priority": 1 - }, - { - "evidence": "}", - "code_location": { "absolute_file_path": "src/server/db/stats.ts", "line": 15 }, - "claim_type": "other", - "title": "Brace placement", - "body": "Evidence too short to discriminate between dozens of lines.", - "priority": 3 - }, - { - "code_location": { "absolute_file_path": "src/server/db/stats.ts", "line": 11 }, - "claim_type": "sql_injection", - "title": "Unparameterised timezone interpolation", - "body": "No evidence field at all, despite the prompt demanding one in four places.", - "priority": 0 - } - ], - "overall_explanation": "Several issues found.", - "overall_correctness": "patch is incorrect", - "overall_confidence_score": 0.9 -} -\`\`\``; - -describe('the parse-time chain, composed', () => { - const parsed = parseFileReviewResponse(response, file, { - deniedClaimTypes: DEFAULT_DENIED_CLAIM_TYPES, - }); - - - // Asserts the surviving title, not a count, so a broken gate shows up specifically. - it('surfaces only the finding that is both grounded and decidable', () => { - expect(parsed.comments.map((c) => c.title)).toEqual([ - 'Empty catch swallows the refresh failure', - ]); - }); - - it('withholds the three findings whose evidence does not resolve', () => { - // unmatched: off-diff quote. weak: `}` matches many lines. absent: no evidence field. - // matched=3 (not 1): grounding runs before the denylist, so denied findings still resolve. - expect(parsed.evidenceStats).toMatchObject({ total: 6, matched: 3, unmatched: 1, weak: 1, absent: 1 }); - }); - - it('denies the claim types that cannot be decided from a diff hunk', () => { - expect(parsed.deniedClaimCounts.react_hook_missing_deps).toBe(1); - expect(parsed.deniedClaimCounts.external_version_claim).toBe(1); - }); - - // Arrives labelled `other`; this family posted two P0s against SHA-pinned actions while CI was green. - it('relabels a version-existence claim before denying it', () => { - expect(parsed.claimTypeCounts.external_version_claim).toBe(1); - expect(parsed.comments.some((c) => c.title.includes('Invalid GitHub Action'))).toBe(false); - }); - - it('gives the surviving finding both identities and an anchor', () => { - const [comment] = parsed.comments; - expect(comment.fingerprint).toMatch(/^[0-9a-f]{8}$/); - expect(comment.fingerprintV2).toMatch(/^[0-9a-f]{8}$/); - expect(comment.anchorHash).toMatch(/^[0-9a-f]{8}$/); - // Anchored by the quote, not the model's reported line number. - expect(comment.line).toBe(12); - }); - -}); - -describe('a clean response', () => { - it('produces no findings and approves', () => { - const parsed = parseFileReviewResponse( - '{"findings":[],"overall_explanation":"No issues.","overall_correctness":"patch is correct","overall_confidence_score":0.9}', - file, - { deniedClaimTypes: DEFAULT_DENIED_CLAIM_TYPES }, - ); - - expect(parsed.comments).toEqual([]); - expect(parsed.fileSummary).not.toContain('Off-diff'); - expect(parsed.verdict).toBe('approve'); - expect(parsed.evidenceStats.total).toBe(0); - }); -}); +import { describe, expect, it } from 'vitest'; +import { parseFileReviewResponse } from '@codraoss/core/model-output'; +import { DEFAULT_DENIED_CLAIM_TYPES } from '@codraoss/schema'; +import type { FileDiff } from '@codraoss/core/diff'; + +// Regression over the full parse chain (JSON extraction, grounding, denylist, labels, fingerprints), one pass. +// Replaces a 3.4MB corpus that caught regressions without naming the broken behavior. Not an accuracy benchmark; see comment_feedback for that. + +const file: FileDiff = { + path: 'src/server/db/stats.ts', + previousPath: null, + isNew: false, + isDeleted: false, + isBinary: false, + lineCount: 8, + hunks: [{ + header: '@@ -10,4 +10,8 @@', + lines: [ + { kind: 'context', content: 'export async function getStats(env: Env, tz: string) {', newLineNumber: 10, oldLineNumber: 10, position: 1 }, + { kind: 'add', content: ' const rows = await sql`SELECT * FROM jobs WHERE tz = ${tz}`;', newLineNumber: 11, position: 2 }, + { kind: 'add', content: ' try { await refresh(); } catch (e) {}', newLineNumber: 12, position: 3 }, + { kind: 'add', content: ' uses: actions/checkout@v7', newLineNumber: 13, position: 4 }, + { kind: 'add', content: ' return rows;', newLineNumber: 14, position: 5 }, + { kind: 'context', content: '}', newLineNumber: 15, oldLineNumber: 11, position: 6 }, + ], + }], +}; + +// Six findings, each dropped by a different gate; markdown-fenced like real model output. +const response = `Here is my review. + +\`\`\`json +{ + "findings": [ + { + "evidence": "try { await refresh(); } catch (e) {}", + "code_location": { "absolute_file_path": "src/server/db/stats.ts", "line": 12 }, + "claim_type": "swallowed_error", + "title": "Empty catch swallows the refresh failure", + "body": "The catch block discards the error with no log and no rethrow.", + "priority": 2 + }, + { + "evidence": "const rows = await sql\`SELECT * FROM jobs WHERE tz = \${tz}\`;", + "code_location": { "absolute_file_path": "src/server/db/stats.ts", "line": 11 }, + "claim_type": "react_hook_missing_deps", + "title": "Missing hook dependency", + "body": "A claim type that cannot be decided from a diff hunk.", + "priority": 2 + }, + { + "evidence": "uses: actions/checkout@v7", + "code_location": { "absolute_file_path": "src/server/db/stats.ts", "line": 13 }, + "claim_type": "other", + "title": "Invalid GitHub Action version", + "body": "actions/checkout@v7 does not exist; the latest release is v4.", + "priority": 0 + }, + { + "evidence": "const cached = await redis.get(cacheKey);", + "code_location": { "absolute_file_path": "src/server/db/stats.ts", "line": 11 }, + "claim_type": "resource_leak", + "title": "Unclosed Redis connection", + "body": "Field-perfect, and about a line that does not exist in this diff.", + "priority": 1 + }, + { + "evidence": "}", + "code_location": { "absolute_file_path": "src/server/db/stats.ts", "line": 15 }, + "claim_type": "other", + "title": "Brace placement", + "body": "Evidence too short to discriminate between dozens of lines.", + "priority": 3 + }, + { + "code_location": { "absolute_file_path": "src/server/db/stats.ts", "line": 11 }, + "claim_type": "sql_injection", + "title": "Unparameterised timezone interpolation", + "body": "No evidence field at all, despite the prompt demanding one in four places.", + "priority": 0 + } + ], + "overall_explanation": "Several issues found.", + "overall_correctness": "patch is incorrect", + "overall_confidence_score": 0.9 +} +\`\`\``; + +describe('the parse-time chain, composed', () => { + const parsed = parseFileReviewResponse(response, file, { + deniedClaimTypes: DEFAULT_DENIED_CLAIM_TYPES, + }); + + + // Asserts the surviving title, not a count, so a broken gate shows up specifically. + it('surfaces only the finding that is both grounded and decidable', () => { + expect(parsed.comments.map((c) => c.title)).toEqual([ + 'Empty catch swallows the refresh failure', + ]); + }); + + it('withholds the three findings whose evidence does not resolve', () => { + // unmatched: off-diff quote. weak: `}` matches many lines. absent: no evidence field. + // matched=3 (not 1): grounding runs before the denylist, so denied findings still resolve. + expect(parsed.evidenceStats).toMatchObject({ total: 6, matched: 3, unmatched: 1, weak: 1, absent: 1 }); + }); + + it('denies the claim types that cannot be decided from a diff hunk', () => { + expect(parsed.deniedClaimCounts.react_hook_missing_deps).toBe(1); + expect(parsed.deniedClaimCounts.external_version_claim).toBe(1); + }); + + // Arrives labelled `other`; this family posted two P0s against SHA-pinned actions while CI was green. + it('relabels a version-existence claim before denying it', () => { + expect(parsed.claimTypeCounts.external_version_claim).toBe(1); + expect(parsed.comments.some((c) => c.title.includes('Invalid GitHub Action'))).toBe(false); + }); + + it('gives the surviving finding both identities and an anchor', () => { + const [comment] = parsed.comments; + expect(comment.fingerprint).toMatch(/^[0-9a-f]{8}$/); + expect(comment.fingerprintV2).toMatch(/^[0-9a-f]{8}$/); + expect(comment.anchorHash).toMatch(/^[0-9a-f]{8}$/); + // Anchored by the quote, not the model's reported line number. + expect(comment.line).toBe(12); + }); + +}); + +describe('a clean response', () => { + it('produces no findings and approves', () => { + const parsed = parseFileReviewResponse( + '{"findings":[],"overall_explanation":"No issues.","overall_correctness":"patch is correct","overall_confidence_score":0.9}', + file, + { deniedClaimTypes: DEFAULT_DENIED_CLAIM_TYPES }, + ); + + expect(parsed.comments).toEqual([]); + expect(parsed.fileSummary).not.toContain('Off-diff'); + expect(parsed.verdict).toBe('approve'); + expect(parsed.evidenceStats.total).toBe(0); + }); +}); diff --git a/test/review/quota-deferral.spec.ts b/test/review/quota-deferral.spec.ts index 993dd803..57f3d92f 100644 --- a/test/review/quota-deferral.spec.ts +++ b/test/review/quota-deferral.spec.ts @@ -1,214 +1,214 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { isRetryableModelError } from '@codraoss/models'; -import { createTestEnv, saveTestProviderApiKey } from '../helpers'; -import { defaultRepoConfig } from '@codraoss/schema'; -import { makeModelFactory } from '@server/adapters/services'; - -const file = { - path: 'src/app.ts', - lineCount: 1, - hunks: [], - isDeleted: false, - isBinary: false, - isNew: false, - previousPath: null, -}; - -// Free-tier body puts cool-off in message, not headers. -function quotaResponse(retryInSeconds: number, model = 'gemini-3.1-pro-preview') { - return new Response( - JSON.stringify({ - error: { - code: 429, - status: 'RESOURCE_EXHAUSTED', - message: - 'You exceeded your current quota, please check your plan and billing details. ' - + `* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_input_token_count, limit: 16000, model: ${model}` - + `\nPlease retry in ${retryInSeconds}s.`, - }, - }), - { status: 429, headers: { 'content-type': 'application/json' } }, - ); -} - -describe('quota 429 handling', () => { - afterEach(() => vi.restoreAllMocks()); - - it('stops walking a long fallback chain after two quota failures and defers the file', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => quotaResponse(56)); - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const service = makeModelFactory(env)('job-x', undefined as any); - - await expect( - service.reviewFile({ - file, - prTitle: 'Test', - prDescription: null, - totalLineCount: 1, - config: { - ...defaultRepoConfig, - model: { - main: 'gemini-3.1-pro-preview', - fallbacks: ['gemini-2.5-pro', 'gemini-3.1-flash-lite', 'gemini-3.5-flash-lite', 'gemini-3.6-flash'], - size_overrides: [], - }, - }, - }), - ).rejects.toSatisfy(isRetryableModelError); - - expect(fetchMock).toHaveBeenCalledTimes(2); - const attempted = fetchMock.mock.calls.map((call) => String(call[0])); - expect(attempted.some((url) => url.includes('gemini-3.1-pro-preview'))).toBe(true); - expect(attempted.some((url) => url.includes('gemini-2.5-pro'))).toBe(true); - // Only seeded GOOGLE_TEST_MODEL_IDS models fetch. - expect(attempted.some((url) => url.includes('gemini-3.1-flash-lite'))).toBe(false); - }); -}); - -function reviewResponse() { - return new Response( - JSON.stringify({ - candidates: [{ - content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok"}' }] }, - finishReason: 'STOP', - }], - usageMetadata: { promptTokenCount: 100, candidatesTokenCount: 10 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); -} - -// Single metered head model avoids MAX_QUOTA_FAILURES_PER_FILE masking tests. -const chain = { - ...defaultRepoConfig, - model: { - main: 'gemini-3.1-pro-preview', - fallbacks: ['gemini-3.1-flash-lite'], - size_overrides: [], - }, -}; - -// Learns provider rate limits from 429 bodies to avoid wasted subrequests. -describe('learning a provider rate limit from its own 429', () => { - afterEach(() => vi.restoreAllMocks()); - - function googleMock(onMetered: () => Response) { - return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - return url.includes('pro-preview') ? onMetered() : reviewResponse(); - }); - } - - it('skips a cooling-off model for subsequent files instead of re-probing it', async () => { - const fetchMock = googleMock(() => quotaResponse(56)); - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const service = makeModelFactory(env)('job-x', undefined as any); - const params = { prTitle: 'Test', prDescription: null, totalLineCount: 1, config: chain }; - - await service.reviewFile({ ...params, file }); - const afterFirst = fetchMock.mock.calls.length; - expect(fetchMock.mock.calls.some((c) => String(c[0]).includes('pro-preview'))).toBe(true); - - fetchMock.mockClear(); - await service.reviewFile({ ...params, file: { ...file, path: 'src/second.ts' } }); - - expect(fetchMock.mock.calls.map((c) => String(c[0])).some((url) => url.includes('pro-preview'))).toBe(false); - expect(fetchMock.mock.calls).toHaveLength(1); - expect(afterFirst).toBeGreaterThan(1); - }); - - it('skips a model whose whole token bucket is smaller than the prompt', async () => { - const fetchMock = googleMock(() => quotaResponse(1)); - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const service = makeModelFactory(env)('job-x', undefined as any); - const params = { prTitle: 'Test', prDescription: null, totalLineCount: 1, config: chain }; - - // Teach bucket size, then let cool-off lapse. - await service.reviewFile({ ...params, file }); - await new Promise((resolve) => setTimeout(resolve, 1100)); - - // Exceeds 16k tokens but fits chunk cap. - const hugeFile = { - ...file, - path: 'src/huge.ts', - lineCount: 300, - hunks: [{ - header: '@@ -1,300 +1,300 @@', - lines: Array.from({ length: 300 }, (_, i) => ({ - kind: 'add' as const, - content: `const value${i} = ${'x'.repeat(240)};`, - newLineNumber: i + 1, - position: i + 1, - })), - }], - }; - - fetchMock.mockClear(); - await service.reviewFile({ ...params, file: hugeFile }); - - expect(fetchMock.mock.calls.map((c) => String(c[0])).some((url) => url.includes('pro-preview'))).toBe(false); - expect(fetchMock.mock.calls).toHaveLength(1); - }); - - it('carries a cool-off to the next invocation of the same job', async () => { - const fetchMock = googleMock(() => quotaResponse(56)); - // MemoryKV mimics continuation handoff. - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const params = { prTitle: 'Test', prDescription: null, totalLineCount: 1, config: chain }; - - const first = makeModelFactory(env)('job-continuation', undefined as any); - await first.reviewFile({ ...params, file }); - expect(fetchMock.mock.calls.some((c) => String(c[0]).includes('pro-preview'))).toBe(true); - - // Brand-new service mimics fresh invocation. - fetchMock.mockClear(); - const next = makeModelFactory(env)('job-continuation', undefined as any); - await next.reviewFile({ ...params, file: { ...file, path: 'src/second.ts' } }); - - expect(fetchMock.mock.calls.map((c) => String(c[0])).some((url) => url.includes('pro-preview'))).toBe(false); - expect(fetchMock.mock.calls).toHaveLength(1); - }); - - it('keeps a cool-off scoped to its own job and model', async () => { - const fetchMock = googleMock(() => quotaResponse(56)); - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const params = { prTitle: 'Test', prDescription: null, totalLineCount: 1, config: chain }; - - await makeModelFactory(env)('job-a', undefined as any).reviewFile({ ...params, file }); - - // Unrelated jobs must not inherit cool-offs. - fetchMock.mockClear(); - await makeModelFactory(env)('job-b', undefined as any).reviewFile({ ...params, file }); - - expect(fetchMock.mock.calls.map((c) => String(c[0])).some((url) => url.includes('pro-preview'))).toBe(true); - }); - - it('returns to the primary model once its cool-off has expired', async () => { - let meteredCalls = 0; - const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - if (!url.includes('pro-preview')) return reviewResponse(); - meteredCalls += 1; - return meteredCalls === 1 ? quotaResponse(1) : reviewResponse(); - }); - - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const service = makeModelFactory(env)('job-x', undefined as any); - const params = { prTitle: 'Test', prDescription: null, totalLineCount: 1, config: chain }; - - await service.reviewFile({ ...params, file }); - await new Promise((resolve) => setTimeout(resolve, 1100)); - - fetchMock.mockClear(); - const second = await service.reviewFile({ ...params, file: { ...file, path: 'src/third.ts' } }); - - expect(second.modelUsed).toContain('pro-preview'); - expect(fetchMock.mock.calls.map((c) => String(c[0])).some((url) => url.includes('pro-preview'))).toBe(true); - }); -}); +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { isRetryableModelError } from '@codraoss/models'; +import { createTestEnv, saveTestProviderApiKey } from '../helpers'; +import { defaultRepoConfig } from '@codraoss/schema'; +import { makeModelFactory } from '@server/adapters/services'; + +const file = { + path: 'src/app.ts', + lineCount: 1, + hunks: [], + isDeleted: false, + isBinary: false, + isNew: false, + previousPath: null, +}; + +// Free-tier body puts cool-off in message, not headers. +function quotaResponse(retryInSeconds: number, model = 'gemini-3.1-pro-preview') { + return new Response( + JSON.stringify({ + error: { + code: 429, + status: 'RESOURCE_EXHAUSTED', + message: + 'You exceeded your current quota, please check your plan and billing details. ' + + `* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_input_token_count, limit: 16000, model: ${model}` + + `\nPlease retry in ${retryInSeconds}s.`, + }, + }), + { status: 429, headers: { 'content-type': 'application/json' } }, + ); +} + +describe('quota 429 handling', () => { + afterEach(() => vi.restoreAllMocks()); + + it('stops walking a long fallback chain after two quota failures and defers the file', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => quotaResponse(56)); + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const service = makeModelFactory(env)('job-x', undefined as any); + + await expect( + service.reviewFile({ + file, + prTitle: 'Test', + prDescription: null, + totalLineCount: 1, + config: { + ...defaultRepoConfig, + model: { + main: 'gemini-3.1-pro-preview', + fallbacks: ['gemini-2.5-pro', 'gemini-3.1-flash-lite', 'gemini-3.5-flash-lite', 'gemini-3.6-flash'], + size_overrides: [], + }, + }, + }), + ).rejects.toSatisfy(isRetryableModelError); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const attempted = fetchMock.mock.calls.map((call) => String(call[0])); + expect(attempted.some((url) => url.includes('gemini-3.1-pro-preview'))).toBe(true); + expect(attempted.some((url) => url.includes('gemini-2.5-pro'))).toBe(true); + // Only seeded GOOGLE_TEST_MODEL_IDS models fetch. + expect(attempted.some((url) => url.includes('gemini-3.1-flash-lite'))).toBe(false); + }); +}); + +function reviewResponse() { + return new Response( + JSON.stringify({ + candidates: [{ + content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok"}' }] }, + finishReason: 'STOP', + }], + usageMetadata: { promptTokenCount: 100, candidatesTokenCount: 10 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); +} + +// Single metered head model avoids MAX_QUOTA_FAILURES_PER_FILE masking tests. +const chain = { + ...defaultRepoConfig, + model: { + main: 'gemini-3.1-pro-preview', + fallbacks: ['gemini-3.1-flash-lite'], + size_overrides: [], + }, +}; + +// Learns provider rate limits from 429 bodies to avoid wasted subrequests. +describe('learning a provider rate limit from its own 429', () => { + afterEach(() => vi.restoreAllMocks()); + + function googleMock(onMetered: () => Response) { + return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + return url.includes('pro-preview') ? onMetered() : reviewResponse(); + }); + } + + it('skips a cooling-off model for subsequent files instead of re-probing it', async () => { + const fetchMock = googleMock(() => quotaResponse(56)); + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const service = makeModelFactory(env)('job-x', undefined as any); + const params = { prTitle: 'Test', prDescription: null, totalLineCount: 1, config: chain }; + + await service.reviewFile({ ...params, file }); + const afterFirst = fetchMock.mock.calls.length; + expect(fetchMock.mock.calls.some((c) => String(c[0]).includes('pro-preview'))).toBe(true); + + fetchMock.mockClear(); + await service.reviewFile({ ...params, file: { ...file, path: 'src/second.ts' } }); + + expect(fetchMock.mock.calls.map((c) => String(c[0])).some((url) => url.includes('pro-preview'))).toBe(false); + expect(fetchMock.mock.calls).toHaveLength(1); + expect(afterFirst).toBeGreaterThan(1); + }); + + it('skips a model whose whole token bucket is smaller than the prompt', async () => { + const fetchMock = googleMock(() => quotaResponse(1)); + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const service = makeModelFactory(env)('job-x', undefined as any); + const params = { prTitle: 'Test', prDescription: null, totalLineCount: 1, config: chain }; + + // Teach bucket size, then let cool-off lapse. + await service.reviewFile({ ...params, file }); + await new Promise((resolve) => setTimeout(resolve, 1100)); + + // Exceeds 16k tokens but fits chunk cap. + const hugeFile = { + ...file, + path: 'src/huge.ts', + lineCount: 300, + hunks: [{ + header: '@@ -1,300 +1,300 @@', + lines: Array.from({ length: 300 }, (_, i) => ({ + kind: 'add' as const, + content: `const value${i} = ${'x'.repeat(240)};`, + newLineNumber: i + 1, + position: i + 1, + })), + }], + }; + + fetchMock.mockClear(); + await service.reviewFile({ ...params, file: hugeFile }); + + expect(fetchMock.mock.calls.map((c) => String(c[0])).some((url) => url.includes('pro-preview'))).toBe(false); + expect(fetchMock.mock.calls).toHaveLength(1); + }); + + it('carries a cool-off to the next invocation of the same job', async () => { + const fetchMock = googleMock(() => quotaResponse(56)); + // MemoryKV mimics continuation handoff. + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const params = { prTitle: 'Test', prDescription: null, totalLineCount: 1, config: chain }; + + const first = makeModelFactory(env)('job-continuation', undefined as any); + await first.reviewFile({ ...params, file }); + expect(fetchMock.mock.calls.some((c) => String(c[0]).includes('pro-preview'))).toBe(true); + + // Brand-new service mimics fresh invocation. + fetchMock.mockClear(); + const next = makeModelFactory(env)('job-continuation', undefined as any); + await next.reviewFile({ ...params, file: { ...file, path: 'src/second.ts' } }); + + expect(fetchMock.mock.calls.map((c) => String(c[0])).some((url) => url.includes('pro-preview'))).toBe(false); + expect(fetchMock.mock.calls).toHaveLength(1); + }); + + it('keeps a cool-off scoped to its own job and model', async () => { + const fetchMock = googleMock(() => quotaResponse(56)); + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const params = { prTitle: 'Test', prDescription: null, totalLineCount: 1, config: chain }; + + await makeModelFactory(env)('job-a', undefined as any).reviewFile({ ...params, file }); + + // Unrelated jobs must not inherit cool-offs. + fetchMock.mockClear(); + await makeModelFactory(env)('job-b', undefined as any).reviewFile({ ...params, file }); + + expect(fetchMock.mock.calls.map((c) => String(c[0])).some((url) => url.includes('pro-preview'))).toBe(true); + }); + + it('returns to the primary model once its cool-off has expired', async () => { + let meteredCalls = 0; + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + if (!url.includes('pro-preview')) return reviewResponse(); + meteredCalls += 1; + return meteredCalls === 1 ? quotaResponse(1) : reviewResponse(); + }); + + const env = createTestEnv(); + await saveTestProviderApiKey(env); + const service = makeModelFactory(env)('job-x', undefined as any); + const params = { prTitle: 'Test', prDescription: null, totalLineCount: 1, config: chain }; + + await service.reviewFile({ ...params, file }); + await new Promise((resolve) => setTimeout(resolve, 1100)); + + fetchMock.mockClear(); + const second = await service.reviewFile({ ...params, file: { ...file, path: 'src/third.ts' } }); + + expect(second.modelUsed).toContain('pro-preview'); + expect(fetchMock.mock.calls.map((c) => String(c[0])).some((url) => url.includes('pro-preview'))).toBe(true); + }); +}); diff --git a/test/review/secondary-reviewer.spec.ts b/test/review/secondary-reviewer.spec.ts index 06383338..be60b156 100644 --- a/test/review/secondary-reviewer.spec.ts +++ b/test/review/secondary-reviewer.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { reviewAndPersistFile } from '../../packages/core/src/review/file-runner'; import { budgetAwareFileLimit, estimatedSubrequestsPerFile } from '@server/core/review'; import { defaultRepoConfig, type RepoConfig } from '@codraoss/schema'; -import type { FileDiff } from '@server/core/diff'; +import type { FileDiff } from '@codraoss/core/diff'; // Two reviewers, unioned. The measured gain (F1 0.200 against 0.149 for the best single model) is // entirely coverage, so the rules that matter are: never drop what only one reviewer found, never diff --git a/test/token-tracker.spec.ts b/test/token-tracker.spec.ts index 52b50dea..d88f31e7 100644 --- a/test/token-tracker.spec.ts +++ b/test/token-tracker.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { TokenTracker } from '@server/core/token-tracker'; +import { TokenTracker } from '@codraoss/core/token-tracker'; // Regression for subrequest-exhaustion incident (job bb9cf692): nothing checked remaining budget before more concurrent work. diff --git a/tsconfig.base.json b/tsconfig.base.json index 1185103f..9dad94d4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -5,11 +5,6 @@ "module": "ESNext", "moduleResolution": "Bundler", "ignoreDeprecations": "6.0", - "paths": { - "@client/*": ["./src/client/*", "../../src/client/*"], - "@server/*": ["./src/server/*", "../../src/server/*"], - "@/*": ["./src/client/*", "../../src/client/*"] - }, "strict": true, "composite": true, "declaration": true, diff --git a/tsconfig.json b/tsconfig.json index 24e1bb4b..0d5645fa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,9 +5,9 @@ "module": "ESNext", "moduleResolution": "Bundler", "paths": { - "@client/*": ["./src/client/*"], - "@server/*": ["./src/server/*"], - "@/*": ["./src/client/*"] + "@client/*": ["./apps/dashboard/src/*"], + "@server/*": ["./apps/worker/src/*"], + "@/*": ["./apps/dashboard/src/*"] }, "jsx": "react-jsx", "strict": true, @@ -26,11 +26,11 @@ }, "include": [ "apps/worker/worker-configuration.d.ts", - "apps/worker/src/worker-env.d.ts", + "apps/worker/src/**/*.ts", "vite.config.ts", "vitest.config.ts", - "src/**/*.ts", - "src/**/*.tsx", + "apps/dashboard/src/**/*.ts", + "apps/dashboard/src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx", // Package sources are already pulled in transitively wherever src/ imports them, but their own diff --git a/vite.config.ts b/vite.config.ts index bcc67f50..8f884889 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,17 +8,17 @@ const rootDir = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig(({ mode }) => ({ plugins: [react(), tailwindcss()], - root: '.', - publicDir: 'public', + // The dashboard app is the Vite root, so publicDir and outDir stay absolute and output still lands at the repo root where wrangler.jsonc expects ../../dist/client. + root: path.resolve(rootDir, 'apps/dashboard'), + publicDir: path.resolve(rootDir, 'public'), resolve: { alias: { - '@client': path.resolve(rootDir, 'src/client'), - '@server': path.resolve(rootDir, 'src/server'), - '@': path.resolve(rootDir, 'src/client'), + '@client': path.resolve(rootDir, 'apps/dashboard/src'), + '@': path.resolve(rootDir, 'apps/dashboard/src'), }, }, build: { - outDir: 'dist/client', + outDir: path.resolve(rootDir, 'dist/client'), emptyOutDir: mode !== 'development', rollupOptions: { output: { diff --git a/vitest.config.ts b/vitest.config.ts index a266a661..ee941c94 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,9 +6,9 @@ export default defineConfig({ plugins: [react()], resolve: { alias: { - '@server': resolve(__dirname, './src/server'), - '@client': resolve(__dirname, './src/client'), - '@': resolve(__dirname, './src/client'), + '@server': resolve(__dirname, './apps/worker/src'), + '@client': resolve(__dirname, './apps/dashboard/src'), + '@': resolve(__dirname, './apps/dashboard/src'), 'cloudflare:workers': resolve(__dirname, './test/mocks/cloudflare-workers.ts'), }, }, From 3d30a2c17323ee7574fed0d752279bdeaf7a59f5 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Fri, 21 Aug 2026 00:47:49 +0530 Subject: [PATCH 5/6] chore(release): bump packages to 0.9.5, target PRs at dev branch --- CONTRIBUTING.md | 10 +++++++--- README.md | 2 +- apps/worker/package.json | 2 +- package.json | 2 +- packages/api/CHANGELOG.md | 15 +++++++++++++++ packages/api/package.json | 12 ++++++------ packages/core/CHANGELOG.md | 9 +++++++++ packages/core/package.json | 4 ++-- packages/db/CHANGELOG.md | 11 +++++++++++ packages/db/package.json | 6 +++--- packages/models/CHANGELOG.md | 10 ++++++++++ packages/models/package.json | 6 +++--- packages/provider-github/CHANGELOG.md | 10 ++++++++++ packages/provider-github/package.json | 6 +++--- packages/schema/CHANGELOG.md | 7 +++++++ packages/schema/package.json | 2 +- packages/ui/CHANGELOG.md | 9 +++++++++ packages/ui/package.json | 4 ++-- 18 files changed, 101 insertions(+), 26 deletions(-) create mode 100644 packages/api/CHANGELOG.md create mode 100644 packages/core/CHANGELOG.md create mode 100644 packages/db/CHANGELOG.md create mode 100644 packages/models/CHANGELOG.md create mode 100644 packages/provider-github/CHANGELOG.md create mode 100644 packages/schema/CHANGELOG.md create mode 100644 packages/ui/CHANGELOG.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0859131e..5395d094 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,10 +95,14 @@ npm run typecheck ## 🚀 Pull Request Process -1. **Fork & Branch**: Create a feature branch from `main`. +Contributions are merged into `dev` first and reach `main` when a release is cut, so `main` always +reflects what is deployed and published. Pull requests opened against `main` will be asked to +retarget to `dev`. + +1. **Fork & Branch**: Create a feature branch from `dev`. 2. **Atomic Commits**: Keep your commits focused and descriptive. -3. **Sync**: Ensure your branch is up to date with `main`. -4. **Target Branch**: Open pull requests against `main`. +3. **Sync**: Ensure your branch is up to date with `dev`. +4. **Target Branch**: Open pull requests against `dev`. 5. **PR Description**: Use the provided template (if available) or clearly explain the *what* and *why* of your changes. 6. **CLA Check**: Once you open the PR, an automated check will verify your CLA status. If you haven't signed yet, follow the link in the check output. diff --git a/README.md b/README.md index c85fecf6..91130fa7 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ The full setup and operations guides live at [codra.run/docs](https://codra.run/ ## Contributing -Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request against `main`. Codra uses a Contributor License Agreement for contributions. +Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request against `dev`. Codra uses a Contributor License Agreement for contributions. ## License diff --git a/apps/worker/package.json b/apps/worker/package.json index fbd91f5e..159c8f23 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -1,6 +1,6 @@ { "name": "@codraoss/worker", - "version": "0.9.4", + "version": "0.9.5", "private": true, "type": "module", "dependencies": { diff --git a/package.json b/package.json index d04aa34b..2b92ca70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codra", - "version": "0.9.4", + "version": "0.9.5", "description": "Open-source code review engine", "private": true, "author": "Devarshi Shimpi", diff --git a/packages/api/CHANGELOG.md b/packages/api/CHANGELOG.md new file mode 100644 index 00000000..c17749e5 --- /dev/null +++ b/packages/api/CHANGELOG.md @@ -0,0 +1,15 @@ +# @codraoss/api + +## 0.9.5 + +### Patch Changes + +- Add optional extension points. `createApiRouter(options?)` accepts `beforeAuth`, `afterAuth`, `pages`, `publicPages` and `routes`; `routes` is invoked last so anything mounted under `/api/*` still inherits the session and CSRF middleware. `ApiRouterDeps` gains optional `authz` and `checkQuota` ports, and every mutating endpoint is now checked against them. Both default to allow-all, so calling `createApiRouter()` with no arguments is unchanged. Quota denial on the webhook path answers 202-ignored rather than 429, because GitHub redelivers failed deliveries. +- Updated dependencies +- Updated dependencies +- Updated dependencies + - @codraoss/core@0.9.5 + - @codraoss/db@0.9.5 + - @codraoss/schema@0.9.5 + - @codraoss/models@0.9.5 + - @codraoss/provider-github@0.9.5 diff --git a/packages/api/package.json b/packages/api/package.json index 58c3b8cd..ab0c8ae0 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@codraoss/api", - "version": "0.9.4", + "version": "0.9.5", "description": "Codra's HTTP surface as a mountable Hono router, wired through ports.", "author": "Devarshi Shimpi", "license": "AGPL-3.0-only", @@ -44,11 +44,11 @@ "dependencies": { "hono": "^4.12.25", "zod": "^4.3.6", - "@codraoss/schema": "^0.9.4", - "@codraoss/core": "^0.9.4", - "@codraoss/db": "^0.9.4", - "@codraoss/provider-github": "^0.9.4", - "@codraoss/models": "^0.9.4" + "@codraoss/schema": "^0.9.5", + "@codraoss/core": "^0.9.5", + "@codraoss/db": "^0.9.5", + "@codraoss/provider-github": "^0.9.5", + "@codraoss/models": "^0.9.5" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md new file mode 100644 index 00000000..cfe1e298 --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1,9 @@ +# @codraoss/core + +## 0.9.5 + +### Patch Changes + +- Raise `MAX_TOTAL_DIFF_CHARS` to 4,000,000 and centralise it in the package constants. Files dropped by the file-count and diff-size limits now mark a review as partial and are named in the job status, rather than appearing only in the pull request comment. +- Updated dependencies + - @codraoss/schema@0.9.5 diff --git a/packages/core/package.json b/packages/core/package.json index e2a4434a..56c8a71f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@codraoss/core", - "version": "0.9.4", + "version": "0.9.5", "description": "The Codra review engine: pure, transport- and platform-agnostic code review logic behind ports.", "author": "Devarshi Shimpi", "license": "AGPL-3.0-only", @@ -118,7 +118,7 @@ "postpack": "node ../../scripts/swap-publish-exports.mjs restore" }, "dependencies": { - "@codraoss/schema": "^0.9.4", + "@codraoss/schema": "^0.9.5", "jsonrepair": "^3.15.0", "picomatch": "^4.0.5", "zod": "^4.3.6" diff --git a/packages/db/CHANGELOG.md b/packages/db/CHANGELOG.md new file mode 100644 index 00000000..049396b1 --- /dev/null +++ b/packages/db/CHANGELOG.md @@ -0,0 +1,11 @@ +# @codraoss/db + +## 0.9.5 + +### Patch Changes + +- Publish `migrations/` and `scripts/`, which were excluded from the tarball, so an installed copy can create its schema. The runner now accepts `--extra-dir` or `CODRA_EXTRA_MIGRATIONS_DIR`: those migrations run after the built-in set inside the same transaction and advisory lock, and are tracked under an `extra:` prefix so filenames cannot collide with future core migrations. Env-file lookup checks the working directory first so it works when run from `node_modules`. +- Updated dependencies +- Updated dependencies + - @codraoss/core@0.9.5 + - @codraoss/schema@0.9.5 diff --git a/packages/db/package.json b/packages/db/package.json index 106680e1..19ad1ca1 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@codraoss/db", - "version": "0.9.4", + "version": "0.9.5", "description": "Codra's Postgres persistence layer behind repository interfaces.", "author": "Devarshi Shimpi", "license": "AGPL-3.0-only", @@ -67,8 +67,8 @@ "postpack": "node ../../scripts/swap-publish-exports.mjs restore" }, "dependencies": { - "@codraoss/schema": "^0.9.4", - "@codraoss/core": "^0.9.4", + "@codraoss/schema": "^0.9.5", + "@codraoss/core": "^0.9.5", "postgres": "^3.4.9" }, "devDependencies": { diff --git a/packages/models/CHANGELOG.md b/packages/models/CHANGELOG.md new file mode 100644 index 00000000..21bab044 --- /dev/null +++ b/packages/models/CHANGELOG.md @@ -0,0 +1,10 @@ +# @codraoss/models + +## 0.9.5 + +### Patch Changes + +- Updated dependencies +- Updated dependencies + - @codraoss/core@0.9.5 + - @codraoss/schema@0.9.5 diff --git a/packages/models/package.json b/packages/models/package.json index 750d6279..e1dabbba 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -1,6 +1,6 @@ { "name": "@codraoss/models", - "version": "0.9.4", + "version": "0.9.5", "description": "LLM provider adapters and model catalog for the Codra review engine.", "author": "Devarshi Shimpi", "license": "AGPL-3.0-only", @@ -78,8 +78,8 @@ "postpack": "node ../../scripts/swap-publish-exports.mjs restore" }, "dependencies": { - "@codraoss/schema": "^0.9.4", - "@codraoss/core": "^0.9.4" + "@codraoss/schema": "^0.9.5", + "@codraoss/core": "^0.9.5" }, "devDependencies": { "tsup": "^8.0.0" diff --git a/packages/provider-github/CHANGELOG.md b/packages/provider-github/CHANGELOG.md new file mode 100644 index 00000000..f3189ffd --- /dev/null +++ b/packages/provider-github/CHANGELOG.md @@ -0,0 +1,10 @@ +# @codraoss/provider-github + +## 0.9.5 + +### Patch Changes + +- Updated dependencies +- Updated dependencies + - @codraoss/core@0.9.5 + - @codraoss/schema@0.9.5 diff --git a/packages/provider-github/package.json b/packages/provider-github/package.json index 460e0b2a..1d4fec1e 100644 --- a/packages/provider-github/package.json +++ b/packages/provider-github/package.json @@ -1,6 +1,6 @@ { "name": "@codraoss/provider-github", - "version": "0.9.4", + "version": "0.9.5", "description": "GitHub git-provider adapter for Codra: App auth, webhooks, OAuth, and review posting.", "author": "Devarshi Shimpi", "license": "AGPL-3.0-only", @@ -53,8 +53,8 @@ "postpack": "node ../../scripts/swap-publish-exports.mjs restore" }, "dependencies": { - "@codraoss/core": "^0.9.4", - "@codraoss/schema": "^0.9.4" + "@codraoss/core": "^0.9.5", + "@codraoss/schema": "^0.9.5" }, "devDependencies": { "tsup": "^8.0.0" diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md new file mode 100644 index 00000000..4a9d2817 --- /dev/null +++ b/packages/schema/CHANGELOG.md @@ -0,0 +1,7 @@ +# @codraoss/schema + +## 0.9.5 + +### Patch Changes + +- Export `apiActions`, the vocabulary of permission identifiers the API checks, and `ApiAction` as an open union so consumers can add their own names. `AuthSessionResponse` gains an optional `permissions` field; omitting it means every action is allowed. diff --git a/packages/schema/package.json b/packages/schema/package.json index 423cc147..fd0ce87b 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@codraoss/schema", - "version": "0.9.4", + "version": "0.9.5", "description": "Shared types and Zod contracts for Codra, the open-source code review engine.", "author": "Devarshi Shimpi", "license": "AGPL-3.0-only", diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md new file mode 100644 index 00000000..f7b7254e --- /dev/null +++ b/packages/ui/CHANGELOG.md @@ -0,0 +1,9 @@ +# @codraoss/ui + +## 0.9.5 + +### Patch Changes + +- Ship the design tokens as `@codraoss/ui/styles`. The components reference `--ui-*`, `--btn-primary-*`, `surface`, `skeleton`, `ui-panel`, `ui-well` and `ui-font-*`, none of which were published before, so the package rendered unstyled outside this repository. Import it after `@import "tailwindcss"` and include the package in your Tailwind source globs. +- Updated dependencies + - @codraoss/schema@0.9.5 diff --git a/packages/ui/package.json b/packages/ui/package.json index c2662d4c..1832f294 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codraoss/ui", - "version": "0.9.4", + "version": "0.9.5", "description": "Codra's reusable React design-system primitives and hooks.", "author": "Devarshi Shimpi", "license": "AGPL-3.0-only", @@ -105,7 +105,7 @@ "sonner": ">=2.0.0" }, "dependencies": { - "@codraoss/schema": "^0.9.4", + "@codraoss/schema": "^0.9.5", "@base-ui/react": "^1.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", From 08cd593bfe3fe7375fbd91d0103bb2d53c232ff9 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Fri, 21 Aug 2026 02:19:42 +0530 Subject: [PATCH 6/6] fix: match weak ETags on job polling and drop invalid Gemini propertyOrdering --- apps/worker/src/services/formatter.ts | 9 +- package-lock.json | 260 +++++++++--------- packages/api/CHANGELOG.md | 8 + packages/api/package.json | 4 +- packages/api/src/routes/api/jobs.ts | 10 +- packages/models/CHANGELOG.md | 6 + packages/models/package.json | 2 +- packages/models/src/gemini-schema.ts | 6 +- .../models/test/model/gemini-schema.spec.ts | 10 +- test/api/jobs.spec.ts | 27 ++ test/findings/review-overview.spec.ts | 18 +- 11 files changed, 213 insertions(+), 147 deletions(-) diff --git a/apps/worker/src/services/formatter.ts b/apps/worker/src/services/formatter.ts index 7dc5c218..2d1f24ce 100644 --- a/apps/worker/src/services/formatter.ts +++ b/apps/worker/src/services/formatter.ts @@ -87,9 +87,10 @@ export class FormatterService { // A clean review used to say "here are some automated review suggestions" and then list none, // which reads as a failure rather than a pass. Say what was checked and that nothing came of it. + // With findings the original wording stays. const headline = postedFindings === 0 ? `✅ **Nothing to flag.** Reviewed ${plural(filesReviewed, 'file')} (${plural(linesReviewed, 'changed line')}) and found no issues worth raising.` - : `Found ${plural(postedFindings, 'issue')} worth a look, commented inline below.`; + : 'Here are some automated review suggestions for this pull request.'; const notes: string[] = []; if (input.filesFailed > 0) { @@ -105,6 +106,10 @@ export class FormatterService { ? '\n' + notes.map((line) => `> [!NOTE]\n> ${line}`).join('\n\n') + '\n' : ''; + const aboutOutcome = postedFindings === 0 + ? 'Every review posts a summary here. A clean pass also gets a 👍 on the pull request itself.' + : 'If Codra has suggestions, it will comment; otherwise it will react with 👍.'; + return `### Codra Review ${headline} @@ -121,7 +126,7 @@ ${noteBlock} - **Open** a pull request for review - **Mark** a draft as ready -Every review posts a summary here. A clean pass also gets a 👍 on the pull request itself. +${aboutOutcome} `; } diff --git a/package-lock.json b/package-lock.json index 13532691..2d4a5833 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codra", - "version": "0.9.4", + "version": "0.9.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codra", - "version": "0.9.4", + "version": "0.9.5", "license": "AGPL-3.0-only", "workspaces": [ "packages/*", @@ -74,7 +74,7 @@ }, "apps/worker": { "name": "@codraoss/worker", - "version": "0.9.4", + "version": "0.9.5", "dependencies": { "@codraoss/api": "*", "@codraoss/core": "*", @@ -9678,9 +9678,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", "cpu": [ "ppc64" ], @@ -9695,9 +9695,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", "cpu": [ "arm" ], @@ -9712,9 +9712,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", "cpu": [ "arm64" ], @@ -9729,9 +9729,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", "cpu": [ "x64" ], @@ -9746,9 +9746,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", "cpu": [ "arm64" ], @@ -9763,9 +9763,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", "cpu": [ "x64" ], @@ -9780,9 +9780,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", "cpu": [ "arm64" ], @@ -9797,9 +9797,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", "cpu": [ "x64" ], @@ -9814,9 +9814,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", "cpu": [ "arm" ], @@ -9831,9 +9831,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", "cpu": [ "arm64" ], @@ -9848,9 +9848,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", "cpu": [ "ia32" ], @@ -9865,9 +9865,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", "cpu": [ "loong64" ], @@ -9882,9 +9882,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", "cpu": [ "mips64el" ], @@ -9899,9 +9899,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", "cpu": [ "ppc64" ], @@ -9916,9 +9916,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", "cpu": [ "riscv64" ], @@ -9933,9 +9933,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", "cpu": [ "s390x" ], @@ -9950,9 +9950,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", "cpu": [ "x64" ], @@ -9967,9 +9967,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", "cpu": [ "arm64" ], @@ -9984,9 +9984,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", "cpu": [ "x64" ], @@ -10001,9 +10001,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", "cpu": [ "arm64" ], @@ -10018,9 +10018,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", "cpu": [ "x64" ], @@ -10035,9 +10035,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", "cpu": [ "arm64" ], @@ -10052,9 +10052,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", "cpu": [ "x64" ], @@ -10069,9 +10069,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", "cpu": [ "arm64" ], @@ -10086,9 +10086,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", "cpu": [ "ia32" ], @@ -10103,9 +10103,9 @@ } }, "node_modules/tsup/node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", "cpu": [ "x64" ], @@ -10120,9 +10120,9 @@ } }, "node_modules/tsup/node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -10133,32 +10133,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" } }, "node_modules/tsup/node_modules/tinyexec": { @@ -11044,14 +11044,14 @@ }, "packages/api": { "name": "@codraoss/api", - "version": "0.9.4", + "version": "0.9.6", "license": "AGPL-3.0-only", "dependencies": { - "@codraoss/core": "^0.9.4", - "@codraoss/db": "^0.9.4", - "@codraoss/models": "^0.9.4", - "@codraoss/provider-github": "^0.9.4", - "@codraoss/schema": "^0.9.4", + "@codraoss/core": "^0.9.5", + "@codraoss/db": "^0.9.5", + "@codraoss/models": "^0.9.6", + "@codraoss/provider-github": "^0.9.5", + "@codraoss/schema": "^0.9.5", "hono": "^4.12.25", "zod": "^4.3.6" }, @@ -11079,10 +11079,10 @@ }, "packages/core": { "name": "@codraoss/core", - "version": "0.9.4", + "version": "0.9.5", "license": "AGPL-3.0-only", "dependencies": { - "@codraoss/schema": "^0.9.4", + "@codraoss/schema": "^0.9.5", "jsonrepair": "^3.15.0", "picomatch": "^4.0.5", "zod": "^4.3.6" @@ -11094,11 +11094,11 @@ }, "packages/db": { "name": "@codraoss/db", - "version": "0.9.4", + "version": "0.9.5", "license": "AGPL-3.0-only", "dependencies": { - "@codraoss/core": "^0.9.4", - "@codraoss/schema": "^0.9.4", + "@codraoss/core": "^0.9.5", + "@codraoss/schema": "^0.9.5", "postgres": "^3.4.9" }, "devDependencies": { @@ -11125,11 +11125,11 @@ }, "packages/models": { "name": "@codraoss/models", - "version": "0.9.4", + "version": "0.9.6", "license": "AGPL-3.0-only", "dependencies": { - "@codraoss/core": "^0.9.4", - "@codraoss/schema": "^0.9.4" + "@codraoss/core": "^0.9.5", + "@codraoss/schema": "^0.9.5" }, "devDependencies": { "tsup": "^8.0.0" @@ -11137,11 +11137,11 @@ }, "packages/provider-github": { "name": "@codraoss/provider-github", - "version": "0.9.4", + "version": "0.9.5", "license": "AGPL-3.0-only", "dependencies": { - "@codraoss/core": "^0.9.4", - "@codraoss/schema": "^0.9.4" + "@codraoss/core": "^0.9.5", + "@codraoss/schema": "^0.9.5" }, "devDependencies": { "tsup": "^8.0.0" @@ -11149,7 +11149,7 @@ }, "packages/schema": { "name": "@codraoss/schema", - "version": "0.9.4", + "version": "0.9.5", "license": "AGPL-3.0-only", "dependencies": { "zod": "^4.3.6" @@ -11160,11 +11160,11 @@ }, "packages/ui": { "name": "@codraoss/ui", - "version": "0.9.4", + "version": "0.9.5", "license": "AGPL-3.0-only", "dependencies": { "@base-ui/react": "^1.6.0", - "@codraoss/schema": "^0.9.4", + "@codraoss/schema": "^0.9.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "sugar-high": "^2.0.0", diff --git a/packages/api/CHANGELOG.md b/packages/api/CHANGELOG.md index c17749e5..db620f29 100644 --- a/packages/api/CHANGELOG.md +++ b/packages/api/CHANGELOG.md @@ -1,5 +1,13 @@ # @codraoss/api +## 0.9.6 + +### Patch Changes + +- Compare `If-None-Match` on `GET /api/jobs/:id` as a weak validator. Cloudflare's edge rewrites strong ETags to `W/"..."` when it compresses the response, so a strict equality check never matched and the 304 path never fired in production; every poll re-serialized the full job detail and could exhaust the CPU limit on a large running job. +- Updated dependencies + - @codraoss/models@0.9.6 + ## 0.9.5 ### Patch Changes diff --git a/packages/api/package.json b/packages/api/package.json index ab0c8ae0..81fd69a6 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@codraoss/api", - "version": "0.9.5", + "version": "0.9.6", "description": "Codra's HTTP surface as a mountable Hono router, wired through ports.", "author": "Devarshi Shimpi", "license": "AGPL-3.0-only", @@ -48,7 +48,7 @@ "@codraoss/core": "^0.9.5", "@codraoss/db": "^0.9.5", "@codraoss/provider-github": "^0.9.5", - "@codraoss/models": "^0.9.5" + "@codraoss/models": "^0.9.6" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/packages/api/src/routes/api/jobs.ts b/packages/api/src/routes/api/jobs.ts index 5cad8292..18ede283 100644 --- a/packages/api/src/routes/api/jobs.ts +++ b/packages/api/src/routes/api/jobs.ts @@ -17,6 +17,14 @@ function jobEtag(input: { id: string; status: string; updatedAt: string; fileCou return `"job-${input.id}-${input.status}-${input.fileCount}-${input.commentCount}-${new Date(input.updatedAt).getTime()}"`; } +// Cloudflare's edge rewrites strong ETags to weak (W/"...") when it compresses the response, so the +// client echoes back a weak validator; compare weakly or the 304 path never fires in production. +function etagMatches(header: string | undefined, etag: string) { + if (!header) return false; + const bare = etag.replace(/^W\//, ''); + return header.split(',').some((candidate) => candidate.trim().replace(/^W\//, '') === bare); +} + function getExecutionContext(c: Context) { try { return c.executionCtx; @@ -52,7 +60,7 @@ export function createJobsRouter() { const etag = jobEtag(job); const lastModified = new Date(job.updatedAt).toUTCString(); - if (c.req.header('if-none-match') === etag) { + if (etagMatches(c.req.header('if-none-match'), etag)) { return new Response(null, { status: 304, headers: { diff --git a/packages/models/CHANGELOG.md b/packages/models/CHANGELOG.md index 21bab044..64c84674 100644 --- a/packages/models/CHANGELOG.md +++ b/packages/models/CHANGELOG.md @@ -1,5 +1,11 @@ # @codraoss/models +## 0.9.6 + +### Patch Changes + +- Stop sending `propertyOrdering` in Gemini response grammars. The keyword belongs to the legacy OpenAPI-style `responseSchema`; inside `responseJsonSchema` the API rejects it with a bare 400 `invalid argument`, so every constrained Google review fell back to unconstrained decoding and frequently returned prose that failed the reviewable-output gate. Property declaration order already carries the ordering the grammar relied on. + ## 0.9.5 ### Patch Changes diff --git a/packages/models/package.json b/packages/models/package.json index e1dabbba..5d18172b 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -1,6 +1,6 @@ { "name": "@codraoss/models", - "version": "0.9.5", + "version": "0.9.6", "description": "LLM provider adapters and model catalog for the Codra review engine.", "author": "Devarshi Shimpi", "license": "AGPL-3.0-only", diff --git a/packages/models/src/gemini-schema.ts b/packages/models/src/gemini-schema.ts index 99eee9a3..ee23f0eb 100644 --- a/packages/models/src/gemini-schema.ts +++ b/packages/models/src/gemini-schema.ts @@ -33,8 +33,10 @@ function adapt(node: unknown): unknown { } } - // Declaration order is the mechanism (`evidence` first); `propertyOrdering` states it outright. - out.propertyOrdering ??= Object.keys(out.properties); + // `propertyOrdering` belongs to the legacy OpenAPI `responseSchema`; inside `responseJsonSchema` + // Gemini rejects it with a bare 400 "invalid argument". Declaration order (`evidence` first) + // already carries the ordering, so drop the keyword rather than send it. + delete out.propertyOrdering; return out; } diff --git a/packages/models/test/model/gemini-schema.spec.ts b/packages/models/test/model/gemini-schema.spec.ts index b536ad14..368b3be8 100644 --- a/packages/models/test/model/gemini-schema.spec.ts +++ b/packages/models/test/model/gemini-schema.spec.ts @@ -9,11 +9,14 @@ describe('toGeminiResponseJsonSchema', () => { const reviewSchema = () => buildReviewResponseSchema(10).schema; const findingProps = (out: any) => out.properties.findings.items.properties; - it('adapts both review grammars: ordering stated, code_location union collapsed', () => { + it('adapts both review grammars: ordering by declaration, code_location union collapsed', () => { const out = toGeminiResponseJsonSchema(reviewSchema()) as any; const expected = ['evidence', 'code_location', 'claim_type', 'title', 'body', 'priority', 'code_suggestion']; expect(Object.keys(findingProps(out))).toEqual(expected); - expect(out.properties.findings.items.propertyOrdering).toEqual(expected); + // `propertyOrdering` is an OpenAPI `responseSchema` keyword; inside `responseJsonSchema` Gemini + // 400s on it, which silently disabled constrained decoding for every review. Declaration order + // above carries the ordering; the keyword must never reach the wire. + expect(out.properties.findings.items.propertyOrdering).toBeUndefined(); const location = findingProps(out).code_location; expect(location.anyOf).toBeUndefined(); @@ -24,7 +27,8 @@ describe('toGeminiResponseJsonSchema', () => { const verify = toGeminiResponseJsonSchema(VERIFY_RESPONSE_SCHEMA.schema as Record) as any; // `reason` then `decidable`, both before `verdict`: the verifier justifies, and states whether the // window it was given can settle the claim at all, before it is allowed to emit a decision token. - expect(verify.properties.results.items.propertyOrdering).toEqual(['index', 'reason', 'decidable', 'verdict', 'confidence']); + expect(Object.keys(verify.properties.results.items.properties)).toEqual(['index', 'reason', 'decidable', 'verdict', 'confidence']); + expect(verify.properties.results.items.propertyOrdering).toBeUndefined(); // The batch grammar nests one level deeper; the same transforms must reach it. const batch = toGeminiResponseJsonSchema(buildBatchReviewResponseSchema(10, 4).schema) as any; diff --git a/test/api/jobs.spec.ts b/test/api/jobs.spec.ts index 42a2b768..0d78350b 100644 --- a/test/api/jobs.spec.ts +++ b/test/api/jobs.spec.ts @@ -47,6 +47,33 @@ describe('Dashboard API: jobs, stats and queue messages', () => { return match ? match[1] : ''; } + it('answers 304 to a matching If-None-Match, including the weak validator the edge rewrites it to', async () => { + const env = createTestEnv(); + const token = await getAuthCookie(env); + const job = await insertJob(env, { + installationId: '123', owner: 'api-test-owner', repo: uniqueName('etag'), prNumber: 1, + prTitle: 'Etag', prAuthor: 'author', commitSha: 'a'.repeat(40), baseSha: 'b'.repeat(40), + trigger: 'auto', headRef: 'feature', baseRef: 'main', + }); + const headers = { Cookie: `codra_session=${token}`, 'x-requested-with': 'XMLHttpRequest' }; + + const first = await app.request(`/api/jobs/${job.id}`, { headers }, env); + expect(first.status).toBe(200); + const etag = first.headers.get('etag'); + expect(etag).toBeTruthy(); + + const strong = await app.request(`/api/jobs/${job.id}`, { headers: { ...headers, 'if-none-match': etag! } }, env); + expect(strong.status).toBe(304); + + // Cloudflare compresses responses at the edge and rewrites strong ETags to weak ones, + // so browsers echo back W/"..."; that must still short-circuit to 304. + const weak = await app.request(`/api/jobs/${job.id}`, { headers: { ...headers, 'if-none-match': `W/${etag}` } }, env); + expect(weak.status).toBe(304); + + const stale = await app.request(`/api/jobs/${job.id}`, { headers: { ...headers, 'if-none-match': '"job-other"' } }, env); + expect(stale.status).toBe(200); + }); + it('reruns a job from start: creates a fresh job that does NOT inherit the parent (no retryOfJobId)', async () => { const env = createTestEnv(); const token = await getAuthCookie(env); diff --git a/test/findings/review-overview.spec.ts b/test/findings/review-overview.spec.ts index adbe25bd..2d98489d 100644 --- a/test/findings/review-overview.spec.ts +++ b/test/findings/review-overview.spec.ts @@ -26,10 +26,10 @@ describe('formatReviewOverview', () => { expect(body).not.toContain('automated review suggestions'); }); - it('reports the count when findings were posted', () => { + it('keeps the original suggestions wording when findings were posted', () => { const body = overview({ postedFindings: 5 }); - expect(body).toContain('Found 5 issues'); + expect(body).toContain('Here are some automated review suggestions for this pull request.'); expect(body).not.toContain('Nothing to flag'); }); @@ -51,7 +51,7 @@ describe('formatReviewOverview', () => { } }); - it('describes the thumbs-up as accompanying the summary, not replacing it', () => { + it('describes the thumbs-up as accompanying the summary on a clean pass', () => { const body = overview(); expect(body).toContain('👍'); @@ -59,6 +59,12 @@ describe('formatReviewOverview', () => { expect(body).not.toContain('otherwise it will react'); }); + it('keeps the original comment-or-react wording when findings were posted', () => { + const body = overview({ postedFindings: 2 }); + + expect(body).toContain('If Codra has suggestions, it will comment; otherwise it will react with 👍.'); + }); + // "No issues" is a weaker claim when candidates were dropped for failing to ground themselves. it('admits when a clean result had candidates dropped by the gates', () => { const body = overview({ withheldFindings: 3 }); @@ -81,11 +87,11 @@ describe('formatReviewOverview', () => { }); it('gets singular and plural right', () => { - const one = overview({ postedFindings: 1, filesReviewed: 1, linesReviewed: 1, filesFailed: 1 }); + const one = overview({ filesReviewed: 1, linesReviewed: 1, filesFailed: 1 }); - expect(one).toContain('Found 1 issue worth'); + expect(one).toContain('Reviewed 1 file (1 changed line)'); expect(one).toContain('1 file could not be reviewed'); - expect(one).not.toContain('1 issues'); expect(one).not.toContain('1 files'); + expect(one).not.toContain('1 changed lines'); }); });