From 11f28acd14a31b07cb6c2bb11573de575d062c4c Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Wed, 12 Aug 2026 22:02:45 +0530 Subject: [PATCH 1/6] add: transition repository to npm workspaces monorepo structure --- CONTRIBUTING.md | 23 ++++++++++++++++++ apps/worker/index.ts | 1 + apps/worker/package.json | 5 ++++ eslint.config.js | 46 +++++++++++++++++++++++++++++++++++ package-lock.json | 23 ++++++++++++++++++ package.json | 12 +++++++-- packages/core/package.json | 9 +++++++ packages/core/src/index.ts | 1 + packages/core/tsconfig.json | 8 ++++++ packages/schema/package.json | 6 +++++ packages/schema/src/index.ts | 4 +++ packages/schema/tsconfig.json | 5 ++++ tsconfig.base.json | 20 +++++++++++++++ vitest.workspace.ts | 7 ++++++ 14 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 apps/worker/index.ts create mode 100644 apps/worker/package.json create mode 100644 packages/core/package.json create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/schema/package.json create mode 100644 packages/schema/src/index.ts create mode 100644 packages/schema/tsconfig.json create mode 100644 tsconfig.base.json create mode 100644 vitest.workspace.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2696611f..72be07f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,29 @@ 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): + +```text +packages/ +├── schema/ # Shared types + zod contracts (zero dependencies) +├── core/ # Review engine (pure ports, depends on schema) +├── db/ # Postgres interactions and migrations (depends on schema, core) +├── models/ # LLM provider integrations (depends on schema, core) +├── provider-github/ # GitHub API adapter (depends on schema, core) +├── api/ # Hono router and API routes (depends on schema, core, db, models, provider-github) +└── ui/ # React design system and primitives (depends on schema) + +apps/ +├── worker/ # Cloudflare Worker entrypoint (wires bindings to api ports) +└── 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. + +--- + ## 🛠️ Local Development Setup Codra is a monorepo-style project built with **Hono** (Worker), **React** (Vite), and **Cloudflare Workers**. diff --git a/apps/worker/index.ts b/apps/worker/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/apps/worker/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/apps/worker/package.json b/apps/worker/package.json new file mode 100644 index 00000000..cc7105fc --- /dev/null +++ b/apps/worker/package.json @@ -0,0 +1,5 @@ +{ + "name": "@codra/worker", + "version": "0.9.4", + "private": true +} diff --git a/eslint.config.js b/eslint.config.js index bb56b9df..94f3ea23 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -152,4 +152,50 @@ export default tseslint.config( }, }, }, + + { + files: ['packages/**/*.{ts,tsx}', 'apps/**/*.{ts,tsx}'], + rules: { + 'import-x/no-restricted-paths': ['error', { + zones: [ + { + target: 'packages/schema/**/*', + from: ['packages/core/**/*', 'packages/provider-github/**/*', 'packages/db/**/*', 'packages/models/**/*', 'packages/api/**/*', 'packages/ui/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] + }, + { + target: 'packages/core/**/*', + from: ['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-lock.json b/package-lock.json index 76e3e545..670115d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "codra", "version": "0.9.4", "license": "AGPL-3.0-only", + "workspaces": [ + "packages/*", + "apps/*" + ], "dependencies": { "@base-ui/react": "^1.6.0", "class-variance-authority": "^0.7.1", @@ -553,6 +557,14 @@ "node": ">=16" } }, + "node_modules/@codra/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@codra/schema": { + "resolved": "packages/schema", + "link": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -8607,6 +8619,17 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "packages/core": { + "name": "@codra/core", + "version": "0.0.0", + "dependencies": { + "@codra/schema": "*" + } + }, + "packages/schema": { + "name": "@codra/schema", + "version": "0.0.0" } } } diff --git a/package.json b/package.json index 18c5534f..09625eaa 100644 --- a/package.json +++ b/package.json @@ -13,21 +13,29 @@ "bugs": { "url": "https://github.com/devarshishimpi/codra/issues" }, + "workspaces": [ + "packages/*", + "apps/*" + ], "scripts": { "build": "vite build && npm run cf-typegen", + "build:all": "npm run build --workspaces --if-present", "cf-typegen": "wrangler types ./src/server/worker-env.d.ts", "deploy": "npm run build && npm run migrate && 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": "wrangler dev --local", - "lint": "eslint src test scripts", + "lint": "eslint src test scripts packages apps", + "lint:all": "npm run lint --workspaces --if-present", "density": "node scripts/comment-density.mjs --top", "start": "npm run dev", "setup:cloudflare": "node scripts/setup-cloudflare.js", "migrate": "node scripts/migrate.mjs", "test": "node scripts/test.mjs", + "test:all": "npm run test --workspaces --if-present", "test:watch": "vitest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "typecheck:all": "npm run typecheck --workspaces --if-present" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..2d34ea36 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,9 @@ +{ + "name": "@codra/core", + "version": "0.9.4", + "private": true, + "main": "src/index.ts", + "dependencies": { + "@codra/schema": "*" + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 00000000..6f8970b3 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": {}, + "include": ["src/**/*"], + "references": [ + { "path": "../schema" } + ] +} diff --git a/packages/schema/package.json b/packages/schema/package.json new file mode 100644 index 00000000..513e0552 --- /dev/null +++ b/packages/schema/package.json @@ -0,0 +1,6 @@ +{ + "name": "@codra/schema", + "version": "0.9.4", + "private": true, + "main": "src/index.ts" +} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts new file mode 100644 index 00000000..eeabe323 --- /dev/null +++ b/packages/schema/src/index.ts @@ -0,0 +1,4 @@ +// This file contains an intentional upward import to prove the boundary lint rule works. +// Schema is not allowed to import from Core. +// eslint-disable-next-line import-x/no-restricted-paths +import {} from '@codra/core'; diff --git a/packages/schema/tsconfig.json b/packages/schema/tsconfig.json new file mode 100644 index 00000000..cab8ed24 --- /dev/null +++ b/packages/schema/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": {}, + "include": ["src/**/*"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..d47a857f --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2024", + "lib": ["ES2024"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "composite": true, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/vitest.workspace.ts b/vitest.workspace.ts new file mode 100644 index 00000000..bb82febd --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1,7 @@ +import { defineWorkspace } from 'vitest/config'; + +export default defineWorkspace([ + 'vitest.config.ts', + 'packages/*/vitest.config.ts', + 'apps/*/vitest.config.ts', +]); From 6cb86bc9b30ae5ca44f6a4f43c1ffe5665299ef5 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Thu, 13 Aug 2026 01:07:46 +0530 Subject: [PATCH 2/6] refactor: migrate from @shared/schema to @codra/schema --- eslint.config.js | 6 +- package-lock.json | 16 ++- package.json | 1 + packages/schema/package.json | 15 +- {src/shared => packages/schema/src}/api.ts | 0 packages/schema/src/config.ts | 1 + {src/shared => packages/schema/src}/github.ts | 4 - {src/shared => packages/schema/src}/hex.ts | 0 packages/schema/src/index.ts | 5 +- .../schema/src}/review-limits.ts | 5 - packages/schema/src/schema-claims.ts | 106 ++++++++++++++ .../schema/src}/schema-enums.ts | 0 .../schema/src}/schema-repo-config.ts | 14 -- {src/shared => packages/schema/src}/schema.ts | 55 ++------ .../schema/src}/timezone.ts | 0 packages/schema/src/transient-errors.ts | 23 +++ packages/schema/vitest.config.ts | 7 + .../features/account/details-section.tsx | 2 +- .../features/account/profile-card.tsx | 2 +- .../dashboard/updates-email-prompt.tsx | 2 +- .../features/job-detail/comment-card.tsx | 2 +- .../job-detail/diff-file-panel-utils.ts | 2 +- .../features/job-detail/diff-file-panel.tsx | 2 +- .../features/job-detail/diff-file-tree.tsx | 2 +- .../features/job-detail/file-finding.tsx | 2 +- .../features/job-detail/job-chips.tsx | 2 +- .../features/job-detail/job-diffs.tsx | 2 +- .../features/job-detail/job-findings-list.tsx | 4 +- .../features/job-detail/job-header.tsx | 2 +- .../features/job-detail/job-meta-cards.tsx | 2 +- .../features/job-detail/job-progress.tsx | 2 +- .../job-detail/job-review-overview.tsx | 4 +- .../features/repos/repo-model-modal.tsx | 2 +- .../components/features/repos/repo-route.ts | 2 +- .../components/features/repos/repo-row.tsx | 2 +- .../features/reviews/live-review-stepper.tsx | 2 +- .../settings/default-models-section.tsx | 2 +- .../features/settings/provider-list.tsx | 2 +- .../features/settings/provider-row.tsx | 2 +- .../features/settings/review-section.tsx | 4 +- .../features/settings/settings-support.ts | 4 +- .../features/stats/metrics-grid-charts.tsx | 2 +- .../features/stats/metrics-grid.tsx | 2 +- .../features/stats/overview-stats.tsx | 2 +- src/client/components/layout/account-menu.tsx | 2 +- src/client/components/layout/app-shell.tsx | 2 +- src/client/components/shared/jobs-table.tsx | 2 +- src/client/components/ui/badge.tsx | 2 +- src/client/hooks/use-job-detail.ts | 2 +- src/client/hooks/use-provider-settings.ts | 4 +- src/client/hooks/use-review-settings.ts | 4 +- src/client/lib/api.ts | 4 +- src/client/lib/batch-groups.ts | 2 +- src/client/lib/file-tree.ts | 2 +- src/client/lib/job-format.ts | 2 +- src/client/lib/timezone.ts | 2 +- src/client/pages/account.tsx | 2 +- src/client/pages/dashboard.tsx | 2 +- src/client/pages/job-logs.tsx | 2 +- src/client/pages/jobs.tsx | 2 +- src/client/pages/repos.tsx | 2 +- src/client/pages/stats.tsx | 2 +- src/server/core/config.ts | 4 +- src/server/core/diff/index.ts | 2 +- src/server/core/finding-gates.ts | 2 +- src/server/core/model-output/batch.ts | 2 +- src/server/core/model-output/dedupe.ts | 2 +- src/server/core/model-output/index.ts | 2 +- src/server/core/model-output/json-batch.ts | 2 +- src/server/core/model-output/json.ts | 2 +- src/server/core/oauth.ts | 2 +- src/server/core/review/bin-runner.ts | 2 +- src/server/core/review/diff-cache.ts | 2 +- src/server/core/review/file-runner.ts | 2 +- src/server/core/review/finalize.ts | 2 +- src/server/core/review/gate-pipeline.ts | 2 +- src/server/core/review/index.ts | 4 +- src/server/core/review/phase.ts | 2 +- src/server/core/review/prepare.ts | 2 +- src/server/core/review/request.ts | 4 +- src/server/core/review/retry-policy.ts | 4 +- src/server/core/rules/detect.ts | 4 +- src/server/core/rules/table.ts | 2 +- src/server/core/sessions.ts | 2 +- src/server/core/verify.ts | 2 +- src/server/db/app-settings.ts | 2 +- src/server/db/file-reviews-bulk.ts | 2 +- src/server/db/file-reviews.ts | 2 +- src/server/db/jobs-mapping.ts | 2 +- src/server/db/jobs.ts | 4 +- src/server/db/learning.ts | 2 +- src/server/db/model-configs.ts | 2 +- src/server/db/repo-configs.ts | 2 +- src/server/db/review-comment-sql.ts | 2 +- src/server/db/stats.ts | 4 +- src/server/env.ts | 2 +- src/server/index.ts | 2 +- src/server/models/catalog.ts | 2 +- src/server/prompts/file-review.ts | 2 +- src/server/routes/api/auth.ts | 2 +- src/server/routes/api/jobs.ts | 2 +- src/server/routes/api/models.ts | 2 +- src/server/routes/api/repos.ts | 2 +- src/server/routes/api/settings.ts | 2 +- src/server/routes/webhook.ts | 2 +- src/server/services/formatter.ts | 2 +- src/server/services/model-chain-runner.ts | 2 +- src/server/services/model-review-batch.ts | 2 +- src/server/services/model-review-chain.ts | 4 +- src/server/services/model-review-file.ts | 2 +- src/server/services/model-support.ts | 4 +- src/server/services/model.ts | 2 +- src/server/workflows/review.ts | 2 +- src/shared/config.ts | 10 -- src/shared/schema-claims.ts | 131 ------------------ src/shared/transient-errors.ts | 31 ----- test/api/auth.spec.ts | 4 +- test/api/jobs.spec.ts | 4 +- test/api/models.spec.ts | 2 +- test/api/repos.spec.ts | 4 +- test/comment-feedback.spec.ts | 2 +- test/db/bulk-upsert.spec.ts | 2 +- test/diff.spec.ts | 2 +- test/e2e/batch-grouping.spec.ts | 2 +- test/findings/claim-types.spec.ts | 2 +- test/findings/gold-set.spec.ts | 2 +- test/findings/prompts-batch-review.spec.ts | 2 +- test/findings/prompts-file-review.spec.ts | 2 +- test/findings/review-verify.spec.ts | 2 +- test/findings/rules-detect.spec.ts | 2 +- test/findings/rules-pipeline.spec.ts | 2 +- test/findings/suppression.spec.ts | 2 +- test/jsonb-encoding.spec.ts | 2 +- test/model/chain-resume.spec.ts | 2 +- test/model/output.spec.ts | 2 +- test/model/service-chunking.spec.ts | 2 +- test/model/service-fallbacks.spec.ts | 2 +- test/model/service-grammar-rejection.spec.ts | 2 +- test/model/service-requests.spec.ts | 2 +- test/model/service-retries.spec.ts | 2 +- test/review/async-batch.spec.ts | 2 +- test/review/batch-flow.spec.ts | 4 +- test/review/chunk-concurrency.spec.ts | 2 +- test/review/flow-chunking.spec.ts | 4 +- test/review/flow-lifecycle.spec.ts | 4 +- test/review/flow-retry.spec.ts | 4 +- test/review/max-files.spec.ts | 2 +- test/review/pipeline-regression.spec.ts | 2 +- test/review/quota-deferral.spec.ts | 2 +- test/review/resilience.spec.ts | 2 +- tsconfig.json | 1 - vite.config.ts | 1 - vitest.config.ts | 1 - 153 files changed, 336 insertions(+), 403 deletions(-) rename {src/shared => packages/schema/src}/api.ts (100%) create mode 100644 packages/schema/src/config.ts rename {src/shared => packages/schema/src}/github.ts (82%) rename {src/shared => packages/schema/src}/hex.ts (100%) rename {src/shared => packages/schema/src}/review-limits.ts (55%) create mode 100644 packages/schema/src/schema-claims.ts rename {src/shared => packages/schema/src}/schema-enums.ts (100%) rename {src/shared => packages/schema/src}/schema-repo-config.ts (79%) rename {src/shared => packages/schema/src}/schema.ts (78%) rename {src/shared => packages/schema/src}/timezone.ts (100%) create mode 100644 packages/schema/src/transient-errors.ts create mode 100644 packages/schema/vitest.config.ts delete mode 100644 src/shared/config.ts delete mode 100644 src/shared/schema-claims.ts delete mode 100644 src/shared/transient-errors.ts diff --git a/eslint.config.js b/eslint.config.js index 94f3ea23..874f53ff 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,8 +22,6 @@ export default tseslint.config( files: ['**/*.{ts,tsx,js,mjs}'], plugins: { 'import-x': importX, 'react-hooks': reactHooks }, settings: { - // The resolver has to understand the @server/@client/@shared aliases from tsconfig, or every - // internal import reads as unresolved and no-cycle/no-self-import are silently useless. 'import-x/resolver-next': [ createTypeScriptImportResolver({ project: './tsconfig.json' }), ], @@ -101,7 +99,7 @@ export default tseslint.config( { group: ['**/core/review/*', '@server/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/*'], message: 'Import from @server/core/model-output, not a sibling.' }, { group: ['**/core/diff/position', '@server/core/diff/position'], message: 'Import from @server/core/diff, not a sibling.' }, - { group: ['**/shared/schema-claims', '**/shared/schema-repo-config', '**/shared/schema-enums', '@shared/schema-claims', '@shared/schema-repo-config', '@shared/schema-enums'], message: 'Import from @shared/schema, not a sibling. (@shared/review-limits is exempt: the client imports it directly to keep zod out of the browser bundle.)' }, + { group: ['**/schema-claims', '**/schema-repo-config', '**/schema-enums', '@codra/schema/schema-claims', '@codra/schema/schema-repo-config', '@codra/schema/schema-enums'], message: 'Import from @codra/schema, not a sibling. (@codra/schema/review-limits is exempt: the client imports it directly to keep zod out of the browser bundle.)' }, ], }], }, @@ -129,7 +127,7 @@ export default tseslint.config( 'src/server/core/review/index.ts', 'src/server/core/diff/index.ts', 'src/server/core/model-output/index.ts', - 'src/shared/schema.ts', + 'packages/schema/src/schema.ts', ], rules: { 'no-restricted-imports': 'off', diff --git a/package-lock.json b/package-lock.json index 670115d7..e8fce7cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ ], "dependencies": { "@base-ui/react": "^1.6.0", + "@codra/schema": "*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "hono": "^4.12.25", @@ -63,6 +64,10 @@ "wrangler": "^4.114.0" } }, + "apps/worker": { + "name": "@codra/worker", + "version": "0.9.4" + }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "dev": true, @@ -565,6 +570,10 @@ "resolved": "packages/schema", "link": true }, + "node_modules/@codra/worker": { + "resolved": "apps/worker", + "link": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -8622,14 +8631,17 @@ }, "packages/core": { "name": "@codra/core", - "version": "0.0.0", + "version": "0.9.4", "dependencies": { "@codra/schema": "*" } }, "packages/schema": { "name": "@codra/schema", - "version": "0.0.0" + "version": "0.9.4", + "dependencies": { + "zod": "^4.3.6" + } } } } diff --git a/package.json b/package.json index 09625eaa..b3d72189 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "wrangler": "^4.114.0" }, "dependencies": { + "@codra/schema": "*", "@base-ui/react": "^1.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/schema/package.json b/packages/schema/package.json index 513e0552..6b94753e 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -2,5 +2,18 @@ "name": "@codra/schema", "version": "0.9.4", "private": true, - "main": "src/index.ts" + "type": "module", + "exports": { + ".": "./src/index.ts", + "./api": "./src/api.ts", + "./config": "./src/config.ts", + "./github": "./src/github.ts", + "./hex": "./src/hex.ts", + "./review-limits": "./src/review-limits.ts", + "./timezone": "./src/timezone.ts", + "./transient-errors": "./src/transient-errors.ts" + }, + "dependencies": { + "zod": "^4.3.6" + } } diff --git a/src/shared/api.ts b/packages/schema/src/api.ts similarity index 100% rename from src/shared/api.ts rename to packages/schema/src/api.ts diff --git a/packages/schema/src/config.ts b/packages/schema/src/config.ts new file mode 100644 index 00000000..558a0446 --- /dev/null +++ b/packages/schema/src/config.ts @@ -0,0 +1 @@ +export const REPO_CONFIG_CACHE_VERSION = 'v7'; diff --git a/src/shared/github.ts b/packages/schema/src/github.ts similarity index 82% rename from src/shared/github.ts rename to packages/schema/src/github.ts index 9eb02c3b..9d5fdfa9 100644 --- a/src/shared/github.ts +++ b/packages/schema/src/github.ts @@ -6,7 +6,6 @@ export function isSupportedGitHubWebhookEvent(eventName: string): eventName is G return (supportedGitHubWebhookEvents as readonly string[]).includes(eventName); } -// Deliberately separate from `supportedGitHubWebhookEvents` (queue-consumed, produces review jobs); these are handled inline and never enqueue. Requires the GitHub App to subscribe to "Pull request review comment/thread" or no feedback ever arrives. export const feedbackGitHubWebhookEvents = ['pull_request_review_comment', 'pull_request_review_thread'] as const; export type FeedbackGitHubWebhookEventName = typeof feedbackGitHubWebhookEvents[number]; @@ -15,12 +14,10 @@ export function isFeedbackGitHubWebhookEvent(eventName: string): eventName is Fe return (feedbackGitHubWebhookEvents as readonly string[]).includes(eventName); } -// The review-comment object shared by both feedback events. Only the fields we actually read. export type GitHubReviewCommentPayload = { id: number; body: string | null; path?: string | null; - // Null once the comment goes outdated, which is why we never key feedback on it. line?: number | null; user?: { login?: string | null } | null; }; @@ -38,7 +35,6 @@ export type PullRequestReviewThreadWebhookPayload = { installation?: { id: number }; repository: { owner: { login: string }; name: string }; pull_request: { number: number }; - // `thread` carries only `node_id` and `comments` -- there is no numeric thread id to key on. thread: { node_id?: string; comments: GitHubReviewCommentPayload[] }; }; diff --git a/src/shared/hex.ts b/packages/schema/src/hex.ts similarity index 100% rename from src/shared/hex.ts rename to packages/schema/src/hex.ts diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index eeabe323..e27a6e2f 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1,4 +1 @@ -// This file contains an intentional upward import to prove the boundary lint rule works. -// Schema is not allowed to import from Core. -// eslint-disable-next-line import-x/no-restricted-paths -import {} from '@codra/core'; +export * from './schema'; diff --git a/src/shared/review-limits.ts b/packages/schema/src/review-limits.ts similarity index 55% rename from src/shared/review-limits.ts rename to packages/schema/src/review-limits.ts index e3cf7d8d..501c8440 100644 --- a/src/shared/review-limits.ts +++ b/packages/schema/src/review-limits.ts @@ -1,14 +1,9 @@ -// Zod-free by design: the client imports these runtime values directly from this module, not from -// @shared/schema, which drags in the whole zod dependency chain (including the side-effecting -// `repoConfigSchema.parse({})` at module load) the moment any single export is touched. export const reviewSeverities = ['P0', 'P1', 'P2', 'P3', 'nit'] as const; export const reviewConcurrencyLevels = ['low', 'medium', 'high', 'max'] as const; export type ReviewConcurrencyLevel = typeof reviewConcurrencyLevels[number]; -// Instance-wide, not per-repo: bounds the Workers subrequest budget and provider rate limit, both -// shared across every repository. export const REVIEW_CONCURRENCY_LIMITS: Record = { low: 1, medium: 2, diff --git a/packages/schema/src/schema-claims.ts b/packages/schema/src/schema-claims.ts new file mode 100644 index 00000000..8a511ecf --- /dev/null +++ b/packages/schema/src/schema-claims.ts @@ -0,0 +1,106 @@ +import { reviewCategories } from './schema-enums'; + +// Enforced, not just labelled; drops whole types and filters candidates. +export const claimTypes = [ + 'react_hook_missing_deps', + 'react_missing_cleanup', + 'missing_await', + 'unhandled_promise_rejection', + 'resource_leak', + 'null_or_undefined_deref', + 'sql_injection', + 'unsafe_dom_sink', + 'unsafe_dynamic_code', + 'insecure_randomness', + 'hardcoded_secret', + 'redos_regex', + 'swallowed_error', + 'mutable_default_arg', + 'destructive_migration', + 'external_version_claim', + 'other', +] as const; + +export type ClaimType = typeof claimTypes[number]; + +export const CLAIM_TYPE_CATEGORY: Record = { + sql_injection: 'security', + unsafe_dom_sink: 'security', + unsafe_dynamic_code: 'security', + insecure_randomness: 'security', + hardcoded_secret: 'security', + missing_await: 'bugs', + unhandled_promise_rejection: 'bugs', + null_or_undefined_deref: 'bugs', + react_hook_missing_deps: 'bugs', + swallowed_error: 'bugs', + mutable_default_arg: 'bugs', + resource_leak: 'performance', + redos_regex: 'performance', + destructive_migration: 'correctness', + react_missing_cleanup: 'correctness', + external_version_claim: 'correctness', + other: 'quality', +}; + +export function toClaimType(value: unknown): ClaimType { + return (claimTypes as readonly string[]).includes(value as string) ? (value as ClaimType) : 'other'; +} + +// Decidable from diff hunk alone? A Record, so new types are COMPILE ERRORS until classified. +export const CLAIM_TYPE_DECIDABILITY: Record = { + sql_injection: 'diff_local', + unsafe_dom_sink: 'diff_local', + unsafe_dynamic_code: 'diff_local', + insecure_randomness: 'diff_local', + hardcoded_secret: 'diff_local', + mutable_default_arg: 'diff_local', + destructive_migration: 'diff_local', + swallowed_error: 'diff_local', + unhandled_promise_rejection: 'diff_local', + missing_await: 'diff_local', + other: 'diff_local', + + react_hook_missing_deps: 'needs_whole_file', + react_missing_cleanup: 'needs_whole_file', + resource_leak: 'needs_whole_file', + redos_regex: 'needs_whole_file', + + null_or_undefined_deref: 'needs_whole_file', + + external_version_claim: 'needs_external_facts', +}; + +// Derived from table: anything undecidable from the diff. +export const DEFAULT_DENIED_CLAIM_TYPES: ClaimType[] = claimTypes.filter( + (type) => CLAIM_TYPE_DECIDABILITY[type] !== 'diff_local', +); + +// Generate candidates, never post. +export const DEFAULT_SHADOW_RULE_IDS = [ + 'empty-catch', + 'debugger-statement', + 'focused-test', + 'dynamic-code-exec', + 'dynamic-html-sink', + 'mutable-default-arg', + 'destructive-migration', + 'hardcoded-secret', + 'insecure-random', +] as const; + +// How finding ended its life; READ-VALIDATING. Retire by marking historical. +export const findingDispositions = [ + 'posted', + 'severity', + 'confidence', + 'suppression', + 'dedupe', + 'verify', + 'verify_unanswered', + 'rule_unverified', + 'cap', + 'unverifiable_passthrough', +] as const; + +export type FindingDisposition = typeof findingDispositions[number]; diff --git a/src/shared/schema-enums.ts b/packages/schema/src/schema-enums.ts similarity index 100% rename from src/shared/schema-enums.ts rename to packages/schema/src/schema-enums.ts diff --git a/src/shared/schema-repo-config.ts b/packages/schema/src/schema-repo-config.ts similarity index 79% rename from src/shared/schema-repo-config.ts rename to packages/schema/src/schema-repo-config.ts index 5e457ac2..f273dce1 100644 --- a/src/shared/schema-repo-config.ts +++ b/packages/schema/src/schema-repo-config.ts @@ -19,27 +19,15 @@ export const reviewConfigSchema = z.object({ skip_files: z .array(z.string().min(1)) .default(['**/*.lock', 'dist/**', 'build/**', '.next/**', '*.generated.*', 'coverage/**']), - // max_files moved to reviewSettingsSchema.maxFiles: the limit it protects (subrequest budget, - // provider rate limits) is shared across repos, not owned by one. Stale keys are ignored on parse. 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), - // Packs small files into shared model calls, amortising the ~2,800-token preamble. Config-driven - // so it lands in configSnapshot and a retry re-derives the same bin plan. batch_small_files: z.boolean().default(true), max_total_diff_chars: z.number().int().min(1).max(500_000).default(150_000), max_comments: z.number().int().min(1).max(150).default(10), - // 'P3', not 'nit': model-flagged cosmetic findings are what gets a review bot ignored. Applies - // only to new repos -- changing this needs a data migration plus a cache-version bump. min_severity: z.enum(reviewSeverities).default('P3'), - // Defaults OFF: confidence is not weak here but INVERTED -- the worst claim family averaged 0.964 - // while the only area with a true positive averaged 0.775. Kept and provider-independent for an - // operator who opts in; grounding is enforced by evidence provenance instead. min_confidence: z.number().min(0).max(1).default(0), focus: z.array(z.enum(reviewCategories)).default([...reviewCategories]), - // Enforced at parse time so it binds every provider. Config-driven so it lands in the job's - // replayable snapshot, and a retried job filters against the same list it originally ran with. deny_claim_types: z.array(z.enum(claimTypes)).default([...DEFAULT_DENIED_CLAIM_TYPES]), - // Deterministic rule channel (no model call). shadow_rule_ids lists rules scored but never posted -- every rule starts there since the triage filter is zero-shot. rules: z .object({ enabled: z.boolean().default(true), @@ -161,6 +149,4 @@ export function normalizeRepoConfig(config: RepoConfig): RepoConfig { }; } -// Textually last: this is a module-load side effect (repoConfigSchema.parse({})), so anything -// added below it would silently run before this line executes. export const defaultRepoConfig = repoConfigSchema.parse({}); diff --git a/src/shared/schema.ts b/packages/schema/src/schema.ts similarity index 78% rename from src/shared/schema.ts rename to packages/schema/src/schema.ts index 31e8302a..3e4c3d38 100644 --- a/src/shared/schema.ts +++ b/packages/schema/src/schema.ts @@ -39,8 +39,7 @@ import { defaultRepoConfig, } from './schema-repo-config'; -// Re-exported for server use; the client imports @shared/review-limits directly, to keep zod out -// of the browser bundle. +// Re-exported for server use; client imports directly to keep zod out of browser bundle. export { reviewSeverities, reviewConcurrencyLevels, @@ -102,49 +101,35 @@ export const parsedReviewCommentSchema = z.object({ body: z.string().min(1), codeSuggestion: z.string().min(1).nullable().optional(), confidenceScore: z.number().min(0).max(1).nullable().optional(), - // The verbatim line the finding claims to be about: anchors the comment and proves the claim - // is grounded in code that exists in the diff. + // Verbatim line the finding is about, proving the claim is grounded in the diff. evidence: z.string().min(1).nullable().optional(), - // Stable identity (path + title), recognising the same issue across re-reviews. fingerprint: z.string().min(1).nullable().optional(), - // Hash of the anchored line's content: a change means the code changed, so a previously-posted - // finding is legitimately raised again. + // Hash of anchored line content; a change means the finding is raised again. anchorHash: z.string().min(1).nullable().optional(), - // Whether this finding reached the PR: without it, 11 generated / 1 posted looks like 11 posted. posted: z.boolean().nullable().optional(), - // Never fold into the title: buildFindingFingerprint hashes it, so a format change resets - // cross-run suppression and unmatches every human dismissal in comment_feedback. + // Never fold into title; format changes reset suppression and unmatch dismissals. claimType: z.enum(claimTypes).nullable().optional(), - // Captured at parse time: 003 nulls diff_input and the KV diff cache expires after 6h, so - // historical findings have no other retrievable context. contextSnippet: z.string().nullable().optional(), disposition: z.enum(findingDispositions).nullable().optional(), - // The verifier's justification, for kept findings as well as dropped: the tuning surface. verifyReason: z.string().nullable().optional(), - // A human's dashboard verdict. `null` means UNLABELLED, not "wrong": compute precision over the - // labelled subset only. humanLabel: z.enum(['marked_right', 'marked_wrong']).nullable().optional(), - // Title-independent identity, OR-matched with `fingerprint` for cross-run suppression. + // Title-independent identity for suppression. fingerprintV2: z.string().min(1).nullable().optional(), - // Absent means 'llm', so pre-existing rows read correctly with no backfill. Always test - // `=== 'rule'` positively, or counts silently include deterministic hits. + // Absent means 'llm'. Always test `=== 'rule'` positively. source: z.enum(['llm', 'rule']).nullable().optional(), - // The retirement signal, when source is 'rule': many generated and none posted means the - // filter always rejects it. + // Retirement signal when source is 'rule'. ruleId: z.string().min(1).nullable().optional(), }); export const findingLabelSchema = z.object({ label: z.enum(['right', 'wrong']) }); -// Shared with the batched schema: divergence would mean a finding that parses on only one path. const reviewFindingSchema = z.object({ title: z.string().max(100), body: z.string().min(1), confidence_score: z.number().min(0).max(1).optional(), - // In lockstep with normalizeFinding's clamp and the grammar: tighter here fails the whole file. priority: z.number().int().min(0).max(4).optional(), evidence: z.string().optional(), - // `unknown`, not `string`: one bad label would discard the whole file. toClaimType coerces it. + // `unknown`, not `string` to prevent file discard; toClaimType coerces. claim_type: z.unknown().optional(), code_location: z.object({ absolute_file_path: z.string(), @@ -164,13 +149,11 @@ export const fileReviewModelOutputSchema = z.object({ overall_confidence_score: z.number().min(0).max(1).optional(), }); -// One entry per packed file, so the nesting carries file identity. `.min(1)` throws on an empty -// response, so the chain falls through instead of marking the bin clean. +// One entry per packed file. `.min(1)` throws on empty response. export const batchReviewModelOutputSchema = z.object({ files: z.array( z.object({ absolute_file_path: z.string(), - // Required, not defaulted: an absent array would report a file the model skipped as clean. findings: z.array(reviewFindingSchema), overall_correctness: z.string().optional().default('patch is correct'), overall_explanation: z.string().optional().default('Review completed (partial output).'), @@ -193,11 +176,9 @@ export const reviewJobMessageSchema = z.object({ commitSha: z.string().min(1).optional(), trigger: z.enum(reviewTriggers).optional(), requestId: z.string().optional(), - // Injected by the workflow so runReviewJob can bind it to the resolved job row; webhook jobs - // can't be bound at instance-create time. + // Injected by workflow to bind to job row. workflowInstanceId: z.string().optional(), - // Set by lease recovery so the consumer creates a FRESH instance keyed on deliveryId, instead - // of colliding with the dead one still keyed on jobId. + // Forces a fresh instance keyed on deliveryId. forceFreshInstance: z.boolean().optional(), }).superRefine((message, ctx) => { if (message.jobId || message.eventName) { @@ -264,10 +245,7 @@ const fileReviewRecordSchema = z.object({ filePath: z.string(), fileStatus: z.enum(fileStatuses), modelUsed: z.string(), - // Nullable, not just optional: the column has no default, so every path that inserts without - // resolving a provider (bulkRecordRetryableFileReviewFailures, bulkMarkFilesFailed) stores NULL, - // and JSON_BUILD_OBJECT emits it as null. `.optional()` alone rejected that and made getJobDetail - // throw for the whole job -- one deferred bin took the entire dashboard page down. + // Nullable, not just optional to prevent getJobDetail from throwing. modelProvider: z.string().nullable().optional(), diffLineCount: z.number().int().nullable(), diffInput: z.string().nullable(), @@ -280,11 +258,7 @@ const fileReviewRecordSchema = z.object({ fileSummary: z.string().nullable(), overallCorrectness: z.string().nullable().optional(), confidenceScore: z.number().nullable().optional(), - // How many files shared this file's model call: 1 alone, N batched, null for pre-batching rows. - // The token columns are a proportional share of that one call, so the two must be read together. batchSize: z.number().int().nullable().optional(), - // Findings the gates dropped before they could be posted. Nullable: only review paths write it, - // and the deferral paths deliberately clear it. withheldCounts: z .object({ evidence: z.number().int(), claimDenied: z.number().int() }) .partial() @@ -327,7 +301,6 @@ export const statsSchema = z.object({ outputTokens: z.number().int(), comments: z.number().int(), }), - // One point per bucket, not per day: long ranges are collapsed server-side so the chart stays legible. trend: z.array( z.object({ day: z.string(), @@ -339,7 +312,7 @@ export const statsSchema = z.object({ comments: z.number().int(), }), ), - /** Days rolled up into each `trend` point. 1 = daily. */ + /** Days per trend point. */ trendBucketDays: z.number().int().positive(), verdicts: z.array( z.object({ @@ -429,7 +402,7 @@ export type StatsPayload = z.infer; export const reviewSettingsSchema = z.object({ concurrencyLevel: z.enum(reviewConcurrencyLevels).default('medium'), maxComments: z.union([z.literal(5), z.literal(10), z.literal(15), z.literal(20)]).default(10), - // Instance-wide, not per-repo: the subrequest budget and provider rate limit are both shared. + // Instance-wide, shared budget and limit. maxFiles: z .number() .int() diff --git a/src/shared/timezone.ts b/packages/schema/src/timezone.ts similarity index 100% rename from src/shared/timezone.ts rename to packages/schema/src/timezone.ts diff --git a/packages/schema/src/transient-errors.ts b/packages/schema/src/transient-errors.ts new file mode 100644 index 00000000..91289e0e --- /dev/null +++ b/packages/schema/src/transient-errors.ts @@ -0,0 +1,23 @@ +export const SHARED_TRANSIENT_ERROR_SUBSTRINGS = [ + 'unavailable', + 'high demand', + 'returned no review content', + 'empty response', + '[redacted]', +] as const; + +export function isTimeoutMessage(lowerMessage: string): boolean { + return lowerMessage.includes('timed out') || lowerMessage.includes('timeout'); +} + +export function isSubrequestBudgetMessage(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? ''); + return message.toLowerCase().includes('subrequest'); +} + +export function matchesAnyTransientSubstring( + lowerMessage: string, + substrings: readonly string[] = SHARED_TRANSIENT_ERROR_SUBSTRINGS, +): boolean { + return substrings.some((substring) => lowerMessage.includes(substring)); +} diff --git a/packages/schema/vitest.config.ts b/packages/schema/vitest.config.ts new file mode 100644 index 00000000..41c37831 --- /dev/null +++ b/packages/schema/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.spec.ts'], + }, +}); diff --git a/src/client/components/features/account/details-section.tsx b/src/client/components/features/account/details-section.tsx index fec9b946..143b1ef1 100644 --- a/src/client/components/features/account/details-section.tsx +++ b/src/client/components/features/account/details-section.tsx @@ -12,7 +12,7 @@ import { resolvedTimeZone, timeZoneOffsetLabel, } from '@client/lib/timezone'; -import type { AccountSettings, AuthSessionUser } from '@shared/api'; +import type { AccountSettings, AuthSessionUser } from '@codra/schema/api'; import { DetailGroup, RevealOnClick, DetailRow } from './detail-rows'; diff --git a/src/client/components/features/account/profile-card.tsx b/src/client/components/features/account/profile-card.tsx index 28a098a7..331df63f 100644 --- a/src/client/components/features/account/profile-card.tsx +++ b/src/client/components/features/account/profile-card.tsx @@ -7,7 +7,7 @@ import { Badge } from '@client/components/ui/badge'; import { Skeleton } from '@client/components/shared/skeleton'; import { GithubMark } from '@client/components/shared/github-mark'; import { ExternalLink, Pencil, Check, X } from 'lucide-react'; -import type { AccountSettings, AuthSessionUser } from '@shared/api'; +import type { AccountSettings, AuthSessionUser } from '@codra/schema/api'; export function ProfileCard({ user, diff --git a/src/client/components/features/dashboard/updates-email-prompt.tsx b/src/client/components/features/dashboard/updates-email-prompt.tsx index 3a827207..b4b19d66 100644 --- a/src/client/components/features/dashboard/updates-email-prompt.tsx +++ b/src/client/components/features/dashboard/updates-email-prompt.tsx @@ -4,7 +4,7 @@ import { Check, Mail } from 'lucide-react'; import { Button } from '@client/components/ui/button'; import { Input } from '@client/components/ui/input'; import { api } from '@client/lib/api'; -import type { UpdatesEmailResponse } from '@shared/api'; +import type { UpdatesEmailResponse } from '@codra/schema/api'; export function UpdatesEmailPrompt() { const [status, setStatus] = useState(null); diff --git a/src/client/components/features/job-detail/comment-card.tsx b/src/client/components/features/job-detail/comment-card.tsx index fa8faccd..20d37810 100644 --- a/src/client/components/features/job-detail/comment-card.tsx +++ b/src/client/components/features/job-detail/comment-card.tsx @@ -6,7 +6,7 @@ import { cn } from '@client/lib/utils'; import { api } from '@client/lib/api'; import { CopyButton } from '@client/components/shared/copy-button'; import { preventToggleOnTextSelection } from '@client/lib/selection'; -import type { ParsedReviewComment } from '@shared/schema'; +import type { ParsedReviewComment } from '@codra/schema'; import { severityConfig } from './constants'; import { ContextSnippet } from './context-snippet'; diff --git a/src/client/components/features/job-detail/diff-file-panel-utils.ts b/src/client/components/features/job-detail/diff-file-panel-utils.ts index ee924c62..1ebc0bef 100644 --- a/src/client/components/features/job-detail/diff-file-panel-utils.ts +++ b/src/client/components/features/job-detail/diff-file-panel-utils.ts @@ -1,6 +1,6 @@ import type { CSSProperties } from 'react'; import type { DiffRow } from '@client/lib/prompt-diff'; -import type { ParsedReviewComment } from '@shared/schema'; +import type { ParsedReviewComment } from '@codra/schema'; export const LARGE_DIFF_ROWS = 300; diff --git a/src/client/components/features/job-detail/diff-file-panel.tsx b/src/client/components/features/job-detail/diff-file-panel.tsx index 6363187b..fbf9a8c2 100644 --- a/src/client/components/features/job-detail/diff-file-panel.tsx +++ b/src/client/components/features/job-detail/diff-file-panel.tsx @@ -4,7 +4,7 @@ import { Badge, StatusBadge } from '@client/components/ui/badge'; import { parsePromptDiff, diffStats, type DiffRow } from '@client/lib/prompt-diff'; import { highlightLine, langForPath } from '@client/lib/highlight'; import { cn } from '@client/lib/utils'; -import type { FileReviewRecord, ParsedReviewComment } from '@shared/schema'; +import type { FileReviewRecord, ParsedReviewComment } from '@codra/schema'; import { CommentCard } from './comment-card'; import { LARGE_DIFF_ROWS, diff --git a/src/client/components/features/job-detail/diff-file-tree.tsx b/src/client/components/features/job-detail/diff-file-tree.tsx index fb1bd43f..661ceb6c 100644 --- a/src/client/components/features/job-detail/diff-file-tree.tsx +++ b/src/client/components/features/job-detail/diff-file-tree.tsx @@ -2,7 +2,7 @@ import { Check, FileText, Folder, FolderOpen } from 'lucide-react'; import { type TreeNode } from '@client/lib/file-tree'; import { diffStats } from '@client/lib/prompt-diff'; import { cn } from '@client/lib/utils'; -import type { FileReviewRecord } from '@shared/schema'; +import type { FileReviewRecord } from '@codra/schema'; export interface TreeProps { nodes: TreeNode[]; diff --git a/src/client/components/features/job-detail/file-finding.tsx b/src/client/components/features/job-detail/file-finding.tsx index 298c43c8..b2884d98 100644 --- a/src/client/components/features/job-detail/file-finding.tsx +++ b/src/client/components/features/job-detail/file-finding.tsx @@ -1,7 +1,7 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { ChevronRight } from 'lucide-react'; -import type { FileReviewRecord, ParsedReviewComment } from '@shared/schema'; +import type { FileReviewRecord, ParsedReviewComment } from '@codra/schema'; import { CommentCard } from './comment-card'; import { preventToggleOnTextSelection } from '@client/lib/selection'; import { MonoPath, StatusDot, VerdictPill } from './job-chips'; diff --git a/src/client/components/features/job-detail/job-chips.tsx b/src/client/components/features/job-detail/job-chips.tsx index 8c854dc7..f0b336f8 100644 --- a/src/client/components/features/job-detail/job-chips.tsx +++ b/src/client/components/features/job-detail/job-chips.tsx @@ -7,7 +7,7 @@ import { CheckCircle2, MessageSquare, type LucideIcon } from 'lucide-react'; import { cn } from '@client/lib/utils'; import { STATUS_DOT, jobDuration, statusLabel } from '@client/lib/job-format'; -import type { JobDetail, JobSummary } from '@shared/schema'; +import type { JobDetail, JobSummary } from '@codra/schema'; /** Status dot alone, for rows that render their own label. */ diff --git a/src/client/components/features/job-detail/job-diffs.tsx b/src/client/components/features/job-detail/job-diffs.tsx index 4d7403bd..8c445efd 100644 --- a/src/client/components/features/job-detail/job-diffs.tsx +++ b/src/client/components/features/job-detail/job-diffs.tsx @@ -12,7 +12,7 @@ import { api } from '@client/lib/api'; import { buildTree } from '@client/lib/file-tree'; import { diffStats } from '@client/lib/prompt-diff'; import { readDiffsCache, writeDiffsCache } from '@client/lib/diffs-cache'; -import type { FileReviewRecord, JobDetail } from '@shared/schema'; +import type { FileReviewRecord, JobDetail } from '@codra/schema'; import { FileDiff } from './diff-file-panel'; import { panelCvStyle, fileAnchorId } from './diff-file-panel-utils'; diff --git a/src/client/components/features/job-detail/job-findings-list.tsx b/src/client/components/features/job-detail/job-findings-list.tsx index 6e50bc4a..dc0c25a4 100644 --- a/src/client/components/features/job-detail/job-findings-list.tsx +++ b/src/client/components/features/job-detail/job-findings-list.tsx @@ -1,7 +1,7 @@ import { useState, type ReactNode } from 'react'; import { FileText } from 'lucide-react'; -import type { JobDetail } from '@shared/schema'; -import { reviewSeverities } from '@shared/review-limits'; +import type { JobDetail } from '@codra/schema'; +import { reviewSeverities } from '@codra/schema/review-limits'; import { Tabs, TabsList, TabsTrigger } from '@client/components/motion/tabs'; import { FileFinding } from './file-finding'; import { CommentCard } from './comment-card'; diff --git a/src/client/components/features/job-detail/job-header.tsx b/src/client/components/features/job-detail/job-header.tsx index 8a737b58..9c19913a 100644 --- a/src/client/components/features/job-detail/job-header.tsx +++ b/src/client/components/features/job-detail/job-header.tsx @@ -19,7 +19,7 @@ import { ConfirmDialog } from '@client/components/ui/confirm-dialog'; 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 '@shared/schema'; +import type { JobDetail } from '@codra/schema'; // Lucide's CircleStop strokes the inner square too, which reads as a blob at 14px; filling it // instead keeps the stop symbol legible. 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 794a6d8a..1e44745a 100644 --- a/src/client/components/features/job-detail/job-meta-cards.tsx +++ b/src/client/components/features/job-detail/job-meta-cards.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react'; import { AtSign, ExternalLink, Info, ListChecks, RotateCcw, Zap } from 'lucide-react'; import { Link } from 'react-router-dom'; import { cn, formatPreciseDuration } from '@client/lib/utils'; -import type { JobDetail, JobStep } from '@shared/schema'; +import type { JobDetail, JobStep } from '@codra/schema'; import { EmptyValue, JobStatusLine, diff --git a/src/client/components/features/job-detail/job-progress.tsx b/src/client/components/features/job-detail/job-progress.tsx index d5952f21..dca35f20 100644 --- a/src/client/components/features/job-detail/job-progress.tsx +++ b/src/client/components/features/job-detail/job-progress.tsx @@ -1,5 +1,5 @@ import { FileCode2, Hourglass } from 'lucide-react'; -import type { JobDetail } from '@shared/schema'; +import type { JobDetail } from '@codra/schema'; interface JobProgressProps { job: JobDetail; 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 f5460902..5383a109 100644 --- a/src/client/components/features/job-detail/job-review-overview.tsx +++ b/src/client/components/features/job-detail/job-review-overview.tsx @@ -1,8 +1,8 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { CheckCircle2, ClipboardList, TriangleAlert } from 'lucide-react'; -import type { JobDetail } from '@shared/schema'; -import { reviewSeverities } from '@shared/review-limits'; +import type { JobDetail } from '@codra/schema'; +import { reviewSeverities } from '@codra/schema/review-limits'; import { OutlinePill } from './job-chips'; import { safeRehypePlugins } from '@client/lib/markdown-plugins'; diff --git a/src/client/components/features/repos/repo-model-modal.tsx b/src/client/components/features/repos/repo-model-modal.tsx index 8188693e..b348f0ee 100644 --- a/src/client/components/features/repos/repo-model-modal.tsx +++ b/src/client/components/features/repos/repo-model-modal.tsx @@ -5,7 +5,7 @@ import { api } from '@client/lib/api'; import { Button } from '@client/components/ui/button'; import { Alert } from '@client/components/ui/alert'; import { Save, RotateCcw, X } from 'lucide-react'; -import type { RepoConfigRecord } from '@shared/schema'; +import type { RepoConfigRecord } from '@codra/schema'; import { ModelRouteEditor } from '@client/components/features/models/model-chain'; import { EMPTY_MODEL_ROUTE, diff --git a/src/client/components/features/repos/repo-route.ts b/src/client/components/features/repos/repo-route.ts index d20fc5b1..c3e5ccff 100644 --- a/src/client/components/features/repos/repo-route.ts +++ b/src/client/components/features/repos/repo-route.ts @@ -1,5 +1,5 @@ import { formatDateTime } from '@client/lib/timezone'; -import type { RepoConfig, RepoConfigRecord } from '@shared/schema'; +import type { RepoConfig, RepoConfigRecord } from '@codra/schema'; import { EMPTY_MODEL_ROUTE, normalizeModelRoute, routesEqual, type ModelRouteConfig } from '@client/components/features/models/model-route'; // Shared by the repos page, its rows and the strategy dialog, so it can't live in any single one. diff --git a/src/client/components/features/repos/repo-row.tsx b/src/client/components/features/repos/repo-row.tsx index 9a1f5541..cb62f73f 100644 --- a/src/client/components/features/repos/repo-row.tsx +++ b/src/client/components/features/repos/repo-row.tsx @@ -2,7 +2,7 @@ import { Button } from '@client/components/ui/button'; import { Badge } from '@client/components/ui/badge'; import { Switch } from '@client/components/ui/switch'; import { Settings2 } from 'lucide-react'; -import type { RepoConfigRecord } from '@shared/schema'; +import type { RepoConfigRecord } from '@codra/schema'; import { describeModelRoute, type ModelOption, type ModelRouteConfig } from '@client/components/features/models/model-route'; import { getRepoRoute, hasMeaningfulCustomStrategy, formatLastActivity, type GlobalModelConfig } from './repo-route'; diff --git a/src/client/components/features/reviews/live-review-stepper.tsx b/src/client/components/features/reviews/live-review-stepper.tsx index faa662b6..6c1f05b3 100644 --- a/src/client/components/features/reviews/live-review-stepper.tsx +++ b/src/client/components/features/reviews/live-review-stepper.tsx @@ -1,4 +1,4 @@ -import type { JobSummary } from '@shared/schema'; +import type { JobSummary } from '@codra/schema'; interface LiveReviewStepperProps { job: JobSummary; diff --git a/src/client/components/features/settings/default-models-section.tsx b/src/client/components/features/settings/default-models-section.tsx index 457061bf..96660b51 100644 --- a/src/client/components/features/settings/default-models-section.tsx +++ b/src/client/components/features/settings/default-models-section.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import type { ModelConfig } from '@shared/schema'; +import type { ModelConfig } from '@codra/schema'; import { Skeleton } from '@client/components/shared/skeleton'; import { ModelRouteEditor } from '@client/components/features/models/model-chain'; import type { diff --git a/src/client/components/features/settings/provider-list.tsx b/src/client/components/features/settings/provider-list.tsx index db1ceeb9..95863f50 100644 --- a/src/client/components/features/settings/provider-list.tsx +++ b/src/client/components/features/settings/provider-list.tsx @@ -1,5 +1,5 @@ import { toast } from 'sonner'; -import type { LlmProvider } from '@shared/schema'; +import type { LlmProvider } from '@codra/schema'; import { Skeleton } from '@client/components/shared/skeleton'; import { ProviderRow } from './provider-row'; import type { ProviderDraft } from './settings-support'; diff --git a/src/client/components/features/settings/provider-row.tsx b/src/client/components/features/settings/provider-row.tsx index 7fb18743..e896bc6c 100644 --- a/src/client/components/features/settings/provider-row.tsx +++ b/src/client/components/features/settings/provider-row.tsx @@ -5,7 +5,7 @@ import { Select } from '@client/components/ui/select'; import { Switch } from '@client/components/ui/switch'; import { Badge } from '@client/components/ui/badge'; import { cn } from '@client/lib/utils'; -import type { LlmApiFormat, LlmProvider } from '@shared/schema'; +import type { LlmApiFormat, LlmProvider } from '@codra/schema'; import { FieldLabel } from './field-label'; import { API_FORMAT_OPTIONS, diff --git a/src/client/components/features/settings/review-section.tsx b/src/client/components/features/settings/review-section.tsx index 84e677d5..f89318cf 100644 --- a/src/client/components/features/settings/review-section.tsx +++ b/src/client/components/features/settings/review-section.tsx @@ -3,8 +3,8 @@ import { Input } from '@client/components/ui/input'; import { Skeleton } from '@client/components/shared/skeleton'; import { SteppedSlider } from '@client/components/motion/stepped-slider'; import { ConfirmDialog } from '@client/components/ui/confirm-dialog'; -import type { ReviewSettings } from '@shared/schema'; -import { REVIEW_CONCURRENCY_LIMITS, reviewMaxFilesRange } from '@shared/review-limits'; +import type { ReviewSettings } from '@codra/schema'; +import { REVIEW_CONCURRENCY_LIMITS, reviewMaxFilesRange } from '@codra/schema/review-limits'; import { FieldLabel } from './field-label'; import { CONCURRENCY_LEVEL_LABEL, diff --git a/src/client/components/features/settings/settings-support.ts b/src/client/components/features/settings/settings-support.ts index f1b31ebd..27c3f9c1 100644 --- a/src/client/components/features/settings/settings-support.ts +++ b/src/client/components/features/settings/settings-support.ts @@ -1,5 +1,5 @@ -import type { LlmApiFormat, LlmProvider } from '@shared/schema'; -import { REVIEW_CONCURRENCY_LIMITS, reviewMaxCommentsOptions, type ReviewConcurrencyLevel } from '@shared/review-limits'; +import type { LlmApiFormat, LlmProvider } from '@codra/schema'; +import { REVIEW_CONCURRENCY_LIMITS, reviewMaxCommentsOptions, type ReviewConcurrencyLevel } from '@codra/schema/review-limits'; // Pure and render-free, so the settings page and its sections can all depend on it without depending on each other. diff --git a/src/client/components/features/stats/metrics-grid-charts.tsx b/src/client/components/features/stats/metrics-grid-charts.tsx index 8a1174eb..7ff815f8 100644 --- a/src/client/components/features/stats/metrics-grid-charts.tsx +++ b/src/client/components/features/stats/metrics-grid-charts.tsx @@ -13,7 +13,7 @@ import { YAxis, } from 'recharts'; import { Activity, Boxes, Coins, FolderGit2, ShieldCheck } from 'lucide-react'; -import type { StatsPayload } from '@shared/schema'; +import type { StatsPayload } from '@codra/schema'; import { ChartDefs, ChartTooltip, diff --git a/src/client/components/features/stats/metrics-grid.tsx b/src/client/components/features/stats/metrics-grid.tsx index 1f8f6dc5..40ec05d0 100644 --- a/src/client/components/features/stats/metrics-grid.tsx +++ b/src/client/components/features/stats/metrics-grid.tsx @@ -1,5 +1,5 @@ import React, { Suspense } from 'react'; -import type { StatsPayload } from '@shared/schema'; +import type { StatsPayload } from '@codra/schema'; import { MetricsGridSkeleton } from './chart-primitives'; // Recharts is only needed once stats have loaded, so it stays out of the initial bundle and the diff --git a/src/client/components/features/stats/overview-stats.tsx b/src/client/components/features/stats/overview-stats.tsx index 56be25c7..93518466 100644 --- a/src/client/components/features/stats/overview-stats.tsx +++ b/src/client/components/features/stats/overview-stats.tsx @@ -3,7 +3,7 @@ import { Activity, ArrowUpRight, Cpu, MessageSquare } from 'lucide-react'; import { StatsGrid, type StatDelta } from './stats-grid'; import { fmtStat } from '@client/lib/utils'; import { useIsDarkMode } from '@client/hooks/use-is-dark-mode'; -import type { StatsPayload } from '@shared/schema'; +import type { StatsPayload } from '@codra/schema'; interface OverviewStatsProps { stats: StatsPayload | null; diff --git a/src/client/components/layout/account-menu.tsx b/src/client/components/layout/account-menu.tsx index 369fc758..451becd8 100644 --- a/src/client/components/layout/account-menu.tsx +++ b/src/client/components/layout/account-menu.tsx @@ -4,7 +4,7 @@ import { api } from '@client/lib/api'; import { LogOut, ChevronsUpDown, UserRound } from 'lucide-react'; import { GithubMark } from '@client/components/shared/github-mark'; import { cn } from '@client/lib/utils'; -import type { AuthSessionUser } from '@shared/api'; +import type { AuthSessionUser } from '@codra/schema/api'; /** * Built from scratch (no shared dropdown primitive): a local popover anchored diff --git a/src/client/components/layout/app-shell.tsx b/src/client/components/layout/app-shell.tsx index 2d2515e8..e435793f 100644 --- a/src/client/components/layout/app-shell.tsx +++ b/src/client/components/layout/app-shell.tsx @@ -7,7 +7,7 @@ import { cn } from '@client/lib/utils'; import { useTheme } from '@client/lib/theme'; import codraDark from '@/assets/codra-fullicon-dark.svg'; import codraLight from '@/assets/codra-fullicon-light.svg'; -import type { AuthSessionUser } from '@shared/api'; +import type { AuthSessionUser } from '@codra/schema/api'; import { SidebarNavItem } from '@client/components/layout/sidebar-nav-item'; import { AccountMenu } from '@client/components/layout/account-menu'; diff --git a/src/client/components/shared/jobs-table.tsx b/src/client/components/shared/jobs-table.tsx index 7c442171..326b4fc7 100644 --- a/src/client/components/shared/jobs-table.tsx +++ b/src/client/components/shared/jobs-table.tsx @@ -6,7 +6,7 @@ import { cn } from '@client/lib/utils'; import { formatDateTime } from '@client/lib/timezone'; import { STATUS_DOT, formatRelativeDate, jobDuration, statusLabel } from '@client/lib/job-format'; -import type { JobSummary } from '@shared/schema'; +import type { JobSummary } from '@codra/schema'; type Column = | 'title' diff --git a/src/client/components/ui/badge.tsx b/src/client/components/ui/badge.tsx index 65b6b048..a9b077c4 100644 --- a/src/client/components/ui/badge.tsx +++ b/src/client/components/ui/badge.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { type VariantProps } from 'class-variance-authority'; import { cn } from '@client/lib/utils'; -import type { JobSummary } from '@shared/schema'; +import type { JobSummary } from '@codra/schema'; import { LiveReviewStepper } from '@client/components/features/reviews/live-review-stepper'; import { badgeVariants } from '@client/components/ui/badge-variants'; diff --git a/src/client/hooks/use-job-detail.ts b/src/client/hooks/use-job-detail.ts index 308472d4..f7303da1 100644 --- a/src/client/hooks/use-job-detail.ts +++ b/src/client/hooks/use-job-detail.ts @@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { api } from '@client/lib/api'; -import type { JobDetail } from '@shared/schema'; +import type { JobDetail } from '@codra/schema'; /* Job detail carries every file's full diff, so cache writes are best-effort (quota is swallowed). */ function jobCacheKey(id: string) { diff --git a/src/client/hooks/use-provider-settings.ts b/src/client/hooks/use-provider-settings.ts index d36e2058..892ad228 100644 --- a/src/client/hooks/use-provider-settings.ts +++ b/src/client/hooks/use-provider-settings.ts @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { api, type ProviderPayload } from '@client/lib/api'; -import type { LlmProvider, ModelConfig, ReviewSettings } from '@shared/schema'; -import type { ModelConfigsResponse } from '@shared/api'; +import type { LlmProvider, ModelConfig, ReviewSettings } from '@codra/schema'; +import type { ModelConfigsResponse } from '@codra/schema/api'; import { normalizeModelRoute, routesEqual, diff --git a/src/client/hooks/use-review-settings.ts b/src/client/hooks/use-review-settings.ts index 5f1d6373..7494c91c 100644 --- a/src/client/hooks/use-review-settings.ts +++ b/src/client/hooks/use-review-settings.ts @@ -1,8 +1,8 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { api } from '@client/lib/api'; -import type { ReviewSettings } from '@shared/schema'; -import { reviewMaxFilesRange } from '@shared/review-limits'; +import type { ReviewSettings } from '@codra/schema'; +import { reviewMaxFilesRange } from '@codra/schema/review-limits'; import { CONCURRENCY_LEVEL_LABEL, CONCURRENCY_MAX_VALUE, diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index 3ff91add..12cbe802 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -11,8 +11,8 @@ import type { StatsResponse, SyncReposResponse, UpdatesEmailResponse, -} from '@shared/api'; -import type { LlmApiFormat, LlmProvider, RepoConfig, ReviewSettings } from '@shared/schema'; +} from '@codra/schema/api'; +import type { LlmApiFormat, LlmProvider, RepoConfig, ReviewSettings } from '@codra/schema'; import { resolvedTimeZone } from '@client/lib/timezone'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); diff --git a/src/client/lib/batch-groups.ts b/src/client/lib/batch-groups.ts index aeaf649d..718a7eef 100644 --- a/src/client/lib/batch-groups.ts +++ b/src/client/lib/batch-groups.ts @@ -1,4 +1,4 @@ -import type { FileReviewRecord } from '@shared/schema'; +import type { FileReviewRecord } from '@codra/schema'; // Bin membership is never persisted (pack.ts derives it rather than storing it). But every file in // a bin is written with the SAME shared response, so grouping on `rawAiOutput` reconstructs the bins diff --git a/src/client/lib/file-tree.ts b/src/client/lib/file-tree.ts index 63461c74..9f6bd8b8 100644 --- a/src/client/lib/file-tree.ts +++ b/src/client/lib/file-tree.ts @@ -1,4 +1,4 @@ -import type { FileReviewRecord } from '@shared/schema'; +import type { FileReviewRecord } from '@codra/schema'; /** Builds the collapsed directory tree the diff viewer's file list renders. */ diff --git a/src/client/lib/job-format.ts b/src/client/lib/job-format.ts index 839a907e..40bcbac6 100644 --- a/src/client/lib/job-format.ts +++ b/src/client/lib/job-format.ts @@ -1,4 +1,4 @@ -import type { JobSummary } from '@shared/schema'; +import type { JobSummary } from '@codra/schema'; /** Shared job status/duration formatting - keep it here, not duplicated per-component (previous copies diverged). */ diff --git a/src/client/lib/timezone.ts b/src/client/lib/timezone.ts index 82e02d29..fcdc0f43 100644 --- a/src/client/lib/timezone.ts +++ b/src/client/lib/timezone.ts @@ -1,4 +1,4 @@ -import { isSupportedTimeZone } from '@shared/timezone'; +import { isSupportedTimeZone } from '@codra/schema/timezone'; /** * Display timezone for dashboard timestamps; storage is always TIMESTAMPTZ, so this is purely diff --git a/src/client/pages/account.tsx b/src/client/pages/account.tsx index 0d4e555e..c65edcf6 100644 --- a/src/client/pages/account.tsx +++ b/src/client/pages/account.tsx @@ -8,7 +8,7 @@ import { resolvedTimeZone, setStoredTimeZone, } from '@client/lib/timezone'; -import type { AccountSettings, AuthSessionUser } from '@shared/api'; +import type { AccountSettings, AuthSessionUser } from '@codra/schema/api'; import { ProfileCard } from '@client/components/features/account/profile-card'; import { AccountDetailsSection } from '@client/components/features/account/details-section'; diff --git a/src/client/pages/dashboard.tsx b/src/client/pages/dashboard.tsx index 80a82051..c1a73845 100644 --- a/src/client/pages/dashboard.tsx +++ b/src/client/pages/dashboard.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { api } from '@client/lib/api'; -import type { StatsPayload, JobSummary } from '@shared/schema'; +import type { StatsPayload, JobSummary } from '@codra/schema'; import { ArrowRight, GitPullRequest, Activity } from 'lucide-react'; import { JobsTable } from '@client/components/shared/jobs-table'; import { EmptyState } from '@client/components/shared/empty-state'; diff --git a/src/client/pages/job-logs.tsx b/src/client/pages/job-logs.tsx index 0f450608..5fe38511 100644 --- a/src/client/pages/job-logs.tsx +++ b/src/client/pages/job-logs.tsx @@ -15,7 +15,7 @@ import { useJobDetail } from '@client/hooks/use-job-detail'; import { JobDetailSkeleton } from '@client/components/features/job-detail/job-skeleton'; import { Badge } from '@client/components/ui/badge'; import { api } from '@client/lib/api'; -import type { FileReviewRecord } from '@shared/schema'; +import type { FileReviewRecord } from '@codra/schema'; import { formatPreciseDuration } from '@client/lib/utils'; diff --git a/src/client/pages/jobs.tsx b/src/client/pages/jobs.tsx index a885b05b..e60c7a9d 100644 --- a/src/client/pages/jobs.tsx +++ b/src/client/pages/jobs.tsx @@ -9,7 +9,7 @@ import { LoadError } from '@client/components/shared/load-error'; import { PageHeader } from '@client/components/layout/page-header'; import { usePolling } from '@client/hooks/use-polling'; import { Activity, ChevronLeft, ChevronRight, ListFilter, RefreshCw, Search } from 'lucide-react'; -import type { JobSummary } from '@shared/schema'; +import type { JobSummary } from '@codra/schema'; export function JobsPage() { const [jobs, setJobs] = useState([]); diff --git a/src/client/pages/repos.tsx b/src/client/pages/repos.tsx index 2ba51124..47cc0b6d 100644 --- a/src/client/pages/repos.tsx +++ b/src/client/pages/repos.tsx @@ -10,7 +10,7 @@ import { Input } from '@client/components/ui/input'; import { Select } from '@client/components/ui/select'; import { GitBranch, RefreshCw, ArrowUpRight, Search } from 'lucide-react'; import { cn } from '@client/lib/utils'; -import type { RepoConfigRecord } from '@shared/schema'; +import type { RepoConfigRecord } from '@codra/schema'; import { EMPTY_MODEL_ROUTE, normalizeModelRoute, diff --git a/src/client/pages/stats.tsx b/src/client/pages/stats.tsx index 6ef3bb24..6f39cfaa 100644 --- a/src/client/pages/stats.tsx +++ b/src/client/pages/stats.tsx @@ -6,7 +6,7 @@ import { useIsDarkMode } from '@client/hooks/use-is-dark-mode'; 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 '@shared/schema'; +import type { StatsPayload } from '@codra/schema'; import { MetricsGridSkeleton } from '@client/components/features/stats/chart-primitives'; diff --git a/src/server/core/config.ts b/src/server/core/config.ts index 6438022a..3c77c7cf 100644 --- a/src/server/core/config.ts +++ b/src/server/core/config.ts @@ -1,5 +1,5 @@ -import { defaultRepoConfig, normalizeRepoModelConfig, repoConfigSchema, type RepoConfig } from '@shared/schema'; -import { REPO_CONFIG_CACHE_VERSION } from '@shared/config'; +import { defaultRepoConfig, normalizeRepoModelConfig, repoConfigSchema, type RepoConfig } from '@codra/schema'; +import { REPO_CONFIG_CACHE_VERSION } from '@codra/schema/config'; import type { AppBindings } from '@server/env'; import { getRepoConfigRecord, syncRepoConfig } from '@server/db/repo-configs'; diff --git a/src/server/core/diff/index.ts b/src/server/core/diff/index.ts index ff6d459e..bb99f459 100644 --- a/src/server/core/diff/index.ts +++ b/src/server/core/diff/index.ts @@ -1,5 +1,5 @@ import picomatch from 'picomatch'; -import type { RepoConfig } from '@shared/schema'; +import type { RepoConfig } from '@codra/schema'; import { type DiffLineKind, type DiffLine, diff --git a/src/server/core/finding-gates.ts b/src/server/core/finding-gates.ts index 155501d7..2cd4057e 100644 --- a/src/server/core/finding-gates.ts +++ b/src/server/core/finding-gates.ts @@ -1,4 +1,4 @@ -import type { FindingDisposition, ParsedReviewComment, RepoConfig } from '@shared/schema'; +import type { FindingDisposition, ParsedReviewComment, RepoConfig } from '@codra/schema'; import type { FileDiff } from './diff'; import type { ModelService } from '../services/model'; import { renderDiffSnippet, parseVerifyResponse, type VerifyCandidate } from '../prompts/verify'; diff --git a/src/server/core/model-output/batch.ts b/src/server/core/model-output/batch.ts index 9b6fc54b..5022b62b 100644 --- a/src/server/core/model-output/batch.ts +++ b/src/server/core/model-output/batch.ts @@ -1,5 +1,5 @@ // Splits one batched response into per-file reviews, then grounds each through the same groundParsedFindings the single-file path uses. -import type { ClaimType } from '@shared/schema'; +import type { ClaimType } from '@codra/schema'; import type { FileDiff } from '../diff'; import { generatorFindingCap } from '@server/prompts/file-review'; import { logger } from '../logger'; diff --git a/src/server/core/model-output/dedupe.ts b/src/server/core/model-output/dedupe.ts index 64a0b493..c60d5223 100644 --- a/src/server/core/model-output/dedupe.ts +++ b/src/server/core/model-output/dedupe.ts @@ -1,4 +1,4 @@ -import type { ParsedReviewComment } from '@shared/schema'; +import type { ParsedReviewComment } from '@codra/schema'; import { normalizeFindingTitle } from '../fingerprint'; const SEVERITY_RANK: Record = { P0: 0, P1: 1, P2: 2, P3: 3, nit: 4 }; diff --git a/src/server/core/model-output/index.ts b/src/server/core/model-output/index.ts index 7d8d5c79..0229d588 100644 --- a/src/server/core/model-output/index.ts +++ b/src/server/core/model-output/index.ts @@ -6,7 +6,7 @@ import { type ClaimType, type ParsedReviewComment, reviewSeverities, -} from '@shared/schema'; +} from '@codra/schema'; import { renderDiffSnippet } from '@server/prompts/verify'; import { logger } from '../logger'; import { z } from 'zod'; diff --git a/src/server/core/model-output/json-batch.ts b/src/server/core/model-output/json-batch.ts index 72674793..c3d730f3 100644 --- a/src/server/core/model-output/json-batch.ts +++ b/src/server/core/model-output/json-batch.ts @@ -1,5 +1,5 @@ // Batched-response payload extraction. Separate from the single-file parser, whose force-filled `findings: []` would approve an unexamined file here. -import { batchReviewModelOutputSchema, fileReviewModelOutputSchema } from '@shared/schema'; +import { batchReviewModelOutputSchema, fileReviewModelOutputSchema } from '@codra/schema'; import { jsonrepair } from 'jsonrepair'; import { z } from 'zod'; import { logger } from '../logger'; diff --git a/src/server/core/model-output/json.ts b/src/server/core/model-output/json.ts index 9e2b763b..b70dd9d9 100644 --- a/src/server/core/model-output/json.ts +++ b/src/server/core/model-output/json.ts @@ -1,4 +1,4 @@ -import { fileReviewModelOutputSchema } from '@shared/schema'; +import { fileReviewModelOutputSchema } from '@codra/schema'; import { jsonrepair } from 'jsonrepair'; import { z } from 'zod'; import { logger } from '../logger'; diff --git a/src/server/core/oauth.ts b/src/server/core/oauth.ts index 6fd3b1fe..19a0227d 100644 --- a/src/server/core/oauth.ts +++ b/src/server/core/oauth.ts @@ -1,4 +1,4 @@ -import { randomHex } from '@shared/hex'; +import { randomHex } from '@codra/schema/hex'; import type { AppBindings } from '@server/env'; const OAUTH_STATE_TTL_SECONDS = 60 * 10; diff --git a/src/server/core/review/bin-runner.ts b/src/server/core/review/bin-runner.ts index dd50e0a9..a6971f26 100644 --- a/src/server/core/review/bin-runner.ts +++ b/src/server/core/review/bin-runner.ts @@ -1,5 +1,5 @@ import { logger } from '../logger'; -import type { RepoConfig } from '@shared/schema'; +import type { RepoConfig } from '@codra/schema'; import type { AppBindings } from '@server/env'; import { type BulkFileReviewInput, diff --git a/src/server/core/review/diff-cache.ts b/src/server/core/review/diff-cache.ts index 6a6c7a85..6e412cb4 100644 --- a/src/server/core/review/diff-cache.ts +++ b/src/server/core/review/diff-cache.ts @@ -1,5 +1,5 @@ import type { AppBindings } from '@server/env'; -import { reviewMaxFilesRange, type RepoConfig } from '@shared/schema'; +import { reviewMaxFilesRange, type RepoConfig } from '@codra/schema'; import { filterReviewableFiles, parseUnifiedDiff, type FileDiff } from '../diff'; import type { GitHubService } from '../../services/github'; import { logger } from '../logger'; diff --git a/src/server/core/review/file-runner.ts b/src/server/core/review/file-runner.ts index bd1512e9..e6bbae92 100644 --- a/src/server/core/review/file-runner.ts +++ b/src/server/core/review/file-runner.ts @@ -1,5 +1,5 @@ import { logger } from '../logger'; -import { type ParsedReviewComment, type RepoConfig } from '@shared/schema'; +import { type ParsedReviewComment, type RepoConfig } from '@codra/schema'; import type { AppBindings } from '@server/env'; import { recordRetryableFileReviewFailure, upsertFileReview } from '@server/db/file-reviews'; import { parseUnifiedDiff, type FileDiff } from '../diff'; diff --git a/src/server/core/review/finalize.ts b/src/server/core/review/finalize.ts index fd4eafb7..cacb88d0 100644 --- a/src/server/core/review/finalize.ts +++ b/src/server/core/review/finalize.ts @@ -1,5 +1,5 @@ import { logger } from '../logger'; -import { defaultRepoConfig, type ParsedReviewComment, type RepoConfig } from '@shared/schema'; +import { defaultRepoConfig, type ParsedReviewComment, type RepoConfig } from '@codra/schema'; import type { AppBindings } from '@server/env'; import { bulkMarkFilesFailed, getFileReviewsForJobs, markCommentDispositions, markCommentsPosted } from '@server/db/file-reviews'; import { completeJob, markJobCheckRunCompleted, updateJobStep } from '@server/db/jobs'; diff --git a/src/server/core/review/gate-pipeline.ts b/src/server/core/review/gate-pipeline.ts index 0db854a3..16d74ba1 100644 --- a/src/server/core/review/gate-pipeline.ts +++ b/src/server/core/review/gate-pipeline.ts @@ -1,6 +1,6 @@ import { dedupeFindings } from '../model-output'; import { verifyFindings } from '../finding-gates'; -import type { FindingDisposition, ParsedReviewComment, RepoConfig } from '@shared/schema'; +import type { FindingDisposition, ParsedReviewComment, RepoConfig } from '@codra/schema'; import type { AppBindings } from '@server/env'; import type { FileDiff } from '../diff'; import type { PersistedReviewJob } from './phase-control'; diff --git a/src/server/core/review/index.ts b/src/server/core/review/index.ts index 86f3c133..1020604b 100644 --- a/src/server/core/review/index.ts +++ b/src/server/core/review/index.ts @@ -1,6 +1,6 @@ import { logger } from '../logger'; -import { isSupportedGitHubWebhookEvent, type GitHubWebhookPayload, type PullRequestWebhookPayload } from '@shared/github'; -import { REVIEW_CONCURRENCY_LIMITS, type ReviewJobMessage } from '@shared/schema'; +import { isSupportedGitHubWebhookEvent, type GitHubWebhookPayload, type PullRequestWebhookPayload } from '@codra/schema/github'; +import { REVIEW_CONCURRENCY_LIMITS, type ReviewJobMessage } from '@codra/schema'; import type { AppBindings } from '@server/env'; import { getFileReviewsForJobs } from '@server/db/file-reviews'; import { diff --git a/src/server/core/review/phase.ts b/src/server/core/review/phase.ts index feb5b290..5df56cfd 100644 --- a/src/server/core/review/phase.ts +++ b/src/server/core/review/phase.ts @@ -1,5 +1,5 @@ import { logger } from '../logger'; -import { defaultRepoConfig, REVIEW_CONCURRENCY_LIMITS, type ParsedReviewComment, type RepoConfig } from '@shared/schema'; +import { defaultRepoConfig, REVIEW_CONCURRENCY_LIMITS, type ParsedReviewComment, type RepoConfig } from '@codra/schema'; import type { AppBindings } from '@server/env'; import { bulkInheritFileReviews, getFileReviewsForJobs, upsertFileReview } from '@server/db/file-reviews'; import { markJobContinuationQueued, resetJobContinuationCount, updateJobStep } from '@server/db/jobs'; diff --git a/src/server/core/review/prepare.ts b/src/server/core/review/prepare.ts index 9f0e11c7..585a9dc8 100644 --- a/src/server/core/review/prepare.ts +++ b/src/server/core/review/prepare.ts @@ -1,5 +1,5 @@ import { logger } from '../logger'; -import { defaultRepoConfig, type RepoConfig } from '@shared/schema'; +import { defaultRepoConfig, type RepoConfig } from '@codra/schema'; import type { AppBindings } from '@server/env'; import { completePreparationStep, diff --git a/src/server/core/review/request.ts b/src/server/core/review/request.ts index a80001a2..637eeb86 100644 --- a/src/server/core/review/request.ts +++ b/src/server/core/review/request.ts @@ -3,8 +3,8 @@ import type { GitHubWebhookPayload, IssueCommentWebhookPayload, PullRequestWebhookPayload, -} from '@shared/github'; -import type { RepoConfig } from '@shared/schema'; +} from '@codra/schema/github'; +import type { RepoConfig } from '@codra/schema'; // Pure (no env/I/O) so the webhook-to-review-request mapping stays testable in isolation. function shouldTriggerFromPullRequest(action: PullRequestWebhookPayload['action'], config: RepoConfig['review']) { diff --git a/src/server/core/review/retry-policy.ts b/src/server/core/review/retry-policy.ts index b88013ca..20a1c549 100644 --- a/src/server/core/review/retry-policy.ts +++ b/src/server/core/review/retry-policy.ts @@ -1,6 +1,6 @@ import { logger } from '../logger'; -import { normalizeModelId, type RepoConfig } from '@shared/schema'; -import { isSubrequestBudgetMessage, isTimeoutMessage, matchesAnyTransientSubstring } from '@shared/transient-errors'; +import { normalizeModelId, type RepoConfig } from '@codra/schema'; +import { isSubrequestBudgetMessage, isTimeoutMessage, matchesAnyTransientSubstring } from '@codra/schema/transient-errors'; import type { AppBindings } from '@server/env'; import { getResolvedModelConfig } from '@server/db/model-configs'; import { RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS } from './phase-control'; diff --git a/src/server/core/rules/detect.ts b/src/server/core/rules/detect.ts index 9bbaf1c7..8a3d4cac 100644 --- a/src/server/core/rules/detect.ts +++ b/src/server/core/rules/detect.ts @@ -1,8 +1,8 @@ -import type { ClaimType, ParsedReviewComment } from '@shared/schema'; +import type { ClaimType, ParsedReviewComment } from '@codra/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 '@shared/schema'; +import { CLAIM_TYPE_CATEGORY } from '@codra/schema'; import { RULES, type Rule } from './table'; // Cap on added lines scanned per file: the binding constraint is the 10ms CPU budget, not memory. Reported as `truncated` rather than silently applied. diff --git a/src/server/core/rules/table.ts b/src/server/core/rules/table.ts index a6d3496e..af5234c5 100644 --- a/src/server/core/rules/table.ts +++ b/src/server/core/rules/table.ts @@ -1,4 +1,4 @@ -import type { ClaimType, reviewSeverities } from '@shared/schema'; +import type { ClaimType, reviewSeverities } from '@codra/schema'; type ReviewSeverity = typeof reviewSeverities[number]; diff --git a/src/server/core/sessions.ts b/src/server/core/sessions.ts index 550a8b94..94aefcf0 100644 --- a/src/server/core/sessions.ts +++ b/src/server/core/sessions.ts @@ -1,4 +1,4 @@ -import { randomHex } from '@shared/hex'; +import { randomHex } from '@codra/schema/hex'; import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; import type { Context } from 'hono'; import type { AppEnv, DashboardSessionUser } from '@server/env'; diff --git a/src/server/core/verify.ts b/src/server/core/verify.ts index c4b16b79..4a97e869 100644 --- a/src/server/core/verify.ts +++ b/src/server/core/verify.ts @@ -1,4 +1,4 @@ -import { hexToBytes } from '@shared/hex'; +import { hexToBytes } from '@codra/schema/hex'; const encoder = new TextEncoder(); diff --git a/src/server/db/app-settings.ts b/src/server/db/app-settings.ts index b1c2903d..b4c500c6 100644 --- a/src/server/db/app-settings.ts +++ b/src/server/db/app-settings.ts @@ -1,7 +1,7 @@ import type { AppBindings } from '@server/env'; import { queryRows } from './client'; import { logger } from '@server/core/logger'; -import { reviewConcurrencyLevels, reviewMaxCommentsOptions, reviewMaxFilesRange, reviewSettingsSchema, type ReviewSettings } from '@shared/schema'; +import { reviewConcurrencyLevels, reviewMaxCommentsOptions, reviewMaxFilesRange, reviewSettingsSchema, type ReviewSettings } from '@codra/schema'; const CONCURRENCY_KEY = 'review_concurrency_level'; const MAX_COMMENTS_KEY = 'review_max_comments'; diff --git a/src/server/db/file-reviews-bulk.ts b/src/server/db/file-reviews-bulk.ts index 05cb5f58..9666116b 100644 --- a/src/server/db/file-reviews-bulk.ts +++ b/src/server/db/file-reviews-bulk.ts @@ -1,4 +1,4 @@ -import type { ParsedReviewComment } from '@shared/schema'; +import type { ParsedReviewComment } from '@codra/schema'; import type { AppBindings } from '@server/env'; import { queryRows, queryTransaction } from './client'; import { diff --git a/src/server/db/file-reviews.ts b/src/server/db/file-reviews.ts index ae1708a3..1e5b485d 100644 --- a/src/server/db/file-reviews.ts +++ b/src/server/db/file-reviews.ts @@ -1,4 +1,4 @@ -import type { ParsedReviewComment } from '@shared/schema'; +import type { ParsedReviewComment } from '@codra/schema'; import type { AppBindings } from '@server/env'; import { parseJsonColumn, queryRows, queryTransaction } from './client'; import { diff --git a/src/server/db/jobs-mapping.ts b/src/server/db/jobs-mapping.ts index 0cd59bbf..dc16b28d 100644 --- a/src/server/db/jobs-mapping.ts +++ b/src/server/db/jobs-mapping.ts @@ -1,5 +1,5 @@ import { parseJsonColumn } from './client'; -import { defaultRepoConfig, jobSummarySchema, repoConfigSchema, type RepoConfig } from '@shared/schema'; +import { defaultRepoConfig, jobSummarySchema, repoConfigSchema, type RepoConfig } from '@codra/schema'; // Import from db/jobs.ts, not from here: eight specs vi.mock the '@server/db/jobs' specifier, and a direct sibling import silently bypasses them. // Deliberately imports NONE of the other jobs-* siblings, so it stays the leaf they can all depend on. diff --git a/src/server/db/jobs.ts b/src/server/db/jobs.ts index 197ee679..c2edcf95 100644 --- a/src/server/db/jobs.ts +++ b/src/server/db/jobs.ts @@ -1,7 +1,7 @@ -import { hexToBytes } from '@shared/hex'; +import { hexToBytes } from '@codra/schema/hex'; import type { AppBindings } from '@server/env'; import { parseJsonColumn, queryRows } from './client'; -import { defaultRepoConfig, jobDetailSchema, repoConfigSchema, type RepoConfig } from '@shared/schema'; +import { defaultRepoConfig, jobDetailSchema, repoConfigSchema, type RepoConfig } from '@codra/schema'; import { getOrCreateRepository } from './repositories'; import { reviewCommentJsonObject } from './review-comment-sql'; import { type JobRow, bytesToHex, mapJob } from './jobs-mapping'; diff --git a/src/server/db/learning.ts b/src/server/db/learning.ts index f0a72cc3..f697cf11 100644 --- a/src/server/db/learning.ts +++ b/src/server/db/learning.ts @@ -1,6 +1,6 @@ import type { AppBindings } from '@server/env'; import { queryRows } from './client'; -import type { ClaimType } from '@shared/schema'; +import type { ClaimType } from '@codra/schema'; // Reads over findings a human has already judged: report only over the LABELLED subset, always with n. The absence of a label is not a signal. diff --git a/src/server/db/model-configs.ts b/src/server/db/model-configs.ts index 4a6a0a62..405eaa86 100644 --- a/src/server/db/model-configs.ts +++ b/src/server/db/model-configs.ts @@ -7,7 +7,7 @@ import { type LlmApiFormat, type LlmProvider, type ModelConfig, -} from '@shared/schema'; +} from '@codra/schema'; type ProviderRow = { id: string; diff --git a/src/server/db/repo-configs.ts b/src/server/db/repo-configs.ts index 94fbb164..8b9baba0 100644 --- a/src/server/db/repo-configs.ts +++ b/src/server/db/repo-configs.ts @@ -1,6 +1,6 @@ import type { AppBindings } from '@server/env'; import { parseJsonColumn, queryRows } from './client'; -import { defaultRepoConfig, normalizeRepoConfig, repoConfigRecordSchema, repoConfigSchema, type RepoConfig } from '@shared/schema'; +import { defaultRepoConfig, normalizeRepoConfig, repoConfigRecordSchema, repoConfigSchema, type RepoConfig } from '@codra/schema'; import { getOrCreateRepository } from './repositories'; type RepoConfigRow = { diff --git a/src/server/db/review-comment-sql.ts b/src/server/db/review-comment-sql.ts index 179d57e9..dff5f083 100644 --- a/src/server/db/review-comment-sql.ts +++ b/src/server/db/review-comment-sql.ts @@ -1,4 +1,4 @@ -import type { ParsedReviewComment } from '@shared/schema'; +import type { ParsedReviewComment } from '@codra/schema'; // One definition of the `review_comments` field list, shared by every reader/writer. Exception: `bulkInheritFileReviews` in file-reviews-bulk.ts hand-writes its own, so columns added here go there too. diff --git a/src/server/db/stats.ts b/src/server/db/stats.ts index ccc5a84e..82e535f1 100644 --- a/src/server/db/stats.ts +++ b/src/server/db/stats.ts @@ -1,7 +1,7 @@ -import { isSupportedTimeZone } from '@shared/timezone'; +import { isSupportedTimeZone } from '@codra/schema/timezone'; import type { AppBindings } from '@server/env'; import { queryRows } from './client'; -import { statsSchema, jobStatuses, reviewTriggers, reviewSeverities, reviewCategories } from '@shared/schema'; +import { statsSchema, jobStatuses, reviewTriggers, reviewSeverities, reviewCategories } from '@codra/schema'; import { getModelUsageStats } from './file-reviews'; // Guard the zone before it reaches SQL, so an unknown name can't error the query. diff --git a/src/server/env.ts b/src/server/env.ts index e15e2a3d..a03e8934 100644 --- a/src/server/env.ts +++ b/src/server/env.ts @@ -1,4 +1,4 @@ -import type { ReviewJobMessage } from '@shared/schema'; +import type { ReviewJobMessage } from '@codra/schema'; export interface WorkersAiBinding { run(model: string, input: Record, options?: { signal?: AbortSignal }): Promise; diff --git a/src/server/index.ts b/src/server/index.ts index 3e2fde4e..f3f8107b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,7 +1,7 @@ import { createApp } from './app'; import { ReviewWorkflow } from './workflows/review'; import type { AppBindings } from './env'; -import { reviewJobMessageSchema } from '@shared/schema'; +import { reviewJobMessageSchema } from '@codra/schema'; import { logger } from '@server/core/logger'; import { disposeRpc } from '@server/core/rpc'; import { runWithDb } from '@server/db/client'; diff --git a/src/server/models/catalog.ts b/src/server/models/catalog.ts index 75731dc4..cf360609 100644 --- a/src/server/models/catalog.ts +++ b/src/server/models/catalog.ts @@ -1,4 +1,4 @@ -import type { LlmApiFormat } from '@shared/schema'; +import type { LlmApiFormat } from '@codra/schema'; import { withTimeout } from '@server/core/timeout'; import { assertPublicBaseUrl } from './url-guard'; diff --git a/src/server/prompts/file-review.ts b/src/server/prompts/file-review.ts index af3c3151..bf146a3e 100644 --- a/src/server/prompts/file-review.ts +++ b/src/server/prompts/file-review.ts @@ -1,4 +1,4 @@ -import { claimTypes, type RepoConfig } from '@shared/schema'; +import { claimTypes, type RepoConfig } from '@codra/schema'; import type { FileDiff } from '@server/core/diff'; import type { ModelResponseSchema } from '@server/models/types'; import { getLanguageForFile } from './languages'; diff --git a/src/server/routes/api/auth.ts b/src/server/routes/api/auth.ts index 04da08b0..39b44599 100644 --- a/src/server/routes/api/auth.ts +++ b/src/server/routes/api/auth.ts @@ -1,4 +1,4 @@ -import { isSupportedTimeZone } from '@shared/timezone'; +import { isSupportedTimeZone } from '@codra/schema/timezone'; import { Hono } from 'hono'; import { z } from 'zod'; import { jsonError } from '@server/core/http'; diff --git a/src/server/routes/api/jobs.ts b/src/server/routes/api/jobs.ts index 59f6b45f..959e0729 100644 --- a/src/server/routes/api/jobs.ts +++ b/src/server/routes/api/jobs.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import type { Context } from 'hono'; -import { defaultRepoConfig, findingLabelSchema, jobsQuerySchema } from '@shared/schema'; +import { defaultRepoConfig, findingLabelSchema, jobsQuerySchema } from '@codra/schema'; import { getFindingLabelTarget } from '@server/db/file-reviews'; import { clearDashboardFeedback, upsertDashboardFeedback } from '@server/db/comment-feedback'; import type { AppBindings, AppEnv } from '@server/env'; diff --git a/src/server/routes/api/models.ts b/src/server/routes/api/models.ts index c17a8db5..69d4aa00 100644 --- a/src/server/routes/api/models.ts +++ b/src/server/routes/api/models.ts @@ -18,7 +18,7 @@ import { import { jsonError } from '@server/core/http'; import { getGlobalConfig, updateGlobalConfig } from '@server/core/config'; import { encryptLlmApiKey, decryptLlmApiKey } from '@server/core/llm-crypto'; -import { llmApiFormats } from '@shared/schema'; +import { llmApiFormats } from '@codra/schema'; import { reviewWithCloudflare } from '@server/models/cloudflare'; import { reviewWithGoogle } from '@server/models/google'; import { reviewWithVertex } from '@server/models/vertex'; diff --git a/src/server/routes/api/repos.ts b/src/server/routes/api/repos.ts index 1617929d..c71bfd2b 100644 --- a/src/server/routes/api/repos.ts +++ b/src/server/routes/api/repos.ts @@ -5,7 +5,7 @@ import { getRepoConfigRecord, listRepoConfigs, upsertRepoConfig, syncRepoConfig, import { jsonError } from '@server/core/http'; import { GitHubClient, type GitHubRepository } from '@server/core/github'; import { invalidateRepoConfigCache } from '@server/core/config'; -import { repoConfigSchema } from '@shared/schema'; +import { repoConfigSchema } from '@codra/schema'; const repoConfigPatchSchema = z .strictObject({ diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index 136107a0..2339586e 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import type { AppEnv } from '@server/env'; import { getReviewSettings, updateReviewSettings } from '@server/db/app-settings'; import { jsonError } from '@server/core/http'; -import { reviewConcurrencyLevels, reviewMaxCommentsOptions, reviewMaxFilesRange, reviewSettingsSchema } from '@shared/schema'; +import { reviewConcurrencyLevels, reviewMaxCommentsOptions, reviewMaxFilesRange, reviewSettingsSchema } from '@codra/schema'; const reviewSettingsPatchSchema = z.strictObject({ concurrencyLevel: z.enum(reviewConcurrencyLevels).optional(), diff --git a/src/server/routes/webhook.ts b/src/server/routes/webhook.ts index 3bc9dd7c..eaedefa8 100644 --- a/src/server/routes/webhook.ts +++ b/src/server/routes/webhook.ts @@ -6,7 +6,7 @@ import { type FeedbackWebhookPayload, type GitHubReviewCommentPayload, type GitHubWebhookPayload, -} from '@shared/github'; +} from '@codra/schema/github'; import type { AppBindings, AppEnv } from '@server/env'; import { loadRepoConfig } from '@server/core/config'; import { extractReviewRequest } from '@server/core/review'; diff --git a/src/server/services/formatter.ts b/src/server/services/formatter.ts index bfaca366..bbaa88fd 100644 --- a/src/server/services/formatter.ts +++ b/src/server/services/formatter.ts @@ -1,4 +1,4 @@ -import type { ParsedReviewComment } from '@shared/schema'; +import type { ParsedReviewComment } from '@codra/schema'; // The third field is OPTIONAL so every comment already on GitHub still parses; requiring it would silently stop recording deletions of historical comments. const FINDING_MARKER_PATTERN = //; diff --git a/src/server/services/model-chain-runner.ts b/src/server/services/model-chain-runner.ts index e16d69a5..e009ba3c 100644 --- a/src/server/services/model-chain-runner.ts +++ b/src/server/services/model-chain-runner.ts @@ -3,7 +3,7 @@ import { buildVerifyPrompt, VERIFY_RESPONSE_SCHEMA, VERIFY_SYSTEM_PROMPT, type V import { adaptiveModelTimeoutMs, clampTimeoutToChainBudget, MODEL_FALLBACK_CHAIN_BUDGET_MS } from '../models/limits'; import { isCloudflareAllocationError, isTransientModelFailure, RetryableModelError } from './model-support'; import { logger } from '../core/logger'; -import type { RepoConfig } from '@shared/schema'; +import type { RepoConfig } from '@codra/schema'; import type { TokenTracker } from '../core/token-tracker'; import type { ModelInput, ModelResponse } from '../models/types'; import type { ResolvedModelConfig } from '@server/db/model-configs'; diff --git a/src/server/services/model-review-batch.ts b/src/server/services/model-review-batch.ts index 5287ef7b..25f2f22d 100644 --- a/src/server/services/model-review-batch.ts +++ b/src/server/services/model-review-batch.ts @@ -3,7 +3,7 @@ import { buildFileReviewPrompts, buildReviewResponseSchema } from '../prompts/fi import { parseFileReviewResponse } from '../core/model-output'; import { truncateFileDiff } from '../core/diff'; import { logger } from '../core/logger'; -import type { RepoConfig } from '@shared/schema'; +import type { RepoConfig } from '@codra/schema'; import type { ModelResponse } from '../models/types'; import type { ResolvedModelConfig } from '@server/db/model-configs'; import { COMPACT_REVIEW_PROMPT_LINE_CAP, type ModelReviewContext } from './model-review-file'; diff --git a/src/server/services/model-review-chain.ts b/src/server/services/model-review-chain.ts index b0305d07..d28dfa47 100644 --- a/src/server/services/model-review-chain.ts +++ b/src/server/services/model-review-chain.ts @@ -1,6 +1,6 @@ import { logger } from '../core/logger'; -import { isSubrequestBudgetMessage, isTimeoutMessage } from '@shared/transient-errors'; -import type { RepoConfig } from '@shared/schema'; +import { isSubrequestBudgetMessage, isTimeoutMessage } from '@codra/schema/transient-errors'; +import type { RepoConfig } from '@codra/schema'; import type { AppBindings } from '../env'; import type { ModelResponseSchema } from '../models/types'; import { clampTimeoutToChainBudget, MODEL_FALLBACK_CHAIN_BUDGET_MS, SUBREQUEST_HEADROOM_FOR_MODEL_CALL } from '../models/limits'; diff --git a/src/server/services/model-review-file.ts b/src/server/services/model-review-file.ts index 9726029a..c95bfc75 100644 --- a/src/server/services/model-review-file.ts +++ b/src/server/services/model-review-file.ts @@ -13,7 +13,7 @@ import { generatorFindingCap } from '../prompts/file-review'; import { mergeCounts } from './model-support'; import { type ModelReviewContext, runModelChain } from './model-review-chain'; import { logger } from '../core/logger'; -import type { RepoConfig } from '@shared/schema'; +import type { RepoConfig } from '@codra/schema'; import type { ModelResponse } from '../models/types'; // Import from the services/model barrel, not here (four specs vi.mock it). diff --git a/src/server/services/model-support.ts b/src/server/services/model-support.ts index 59b2f476..0e930f8c 100644 --- a/src/server/services/model-support.ts +++ b/src/server/services/model-support.ts @@ -1,5 +1,5 @@ -import { normalizeModelId } from '@shared/schema'; -import { isTimeoutMessage, matchesAnyTransientSubstring } from '@shared/transient-errors'; +import { normalizeModelId } from '@codra/schema'; +import { isTimeoutMessage, matchesAnyTransientSubstring } from '@codra/schema/transient-errors'; import { UnparseableModelResponseError } from '../models/types'; // Pure helpers for the model service: alias resolution, prompt-size estimation, rate-limit parsing, error classification. diff --git a/src/server/services/model.ts b/src/server/services/model.ts index dde1f434..963b566d 100644 --- a/src/server/services/model.ts +++ b/src/server/services/model.ts @@ -5,7 +5,7 @@ import { reviewWithCloudflare } from '../models/cloudflare'; import { reviewWithOpenAI } from '../models/openai'; import { reviewWithAnthropic } from '../models/anthropic'; import type { VerifyCandidate } from '../prompts/verify'; -import type { RepoConfig } from '@shared/schema'; +import type { RepoConfig } from '@codra/schema'; import type { TokenTracker } from '../core/token-tracker'; import type { ModelInput, ModelResponse } from '../models/types'; import { logger } from '../core/logger'; diff --git a/src/server/workflows/review.ts b/src/server/workflows/review.ts index 7f5b1907..c059a066 100644 --- a/src/server/workflows/review.ts +++ b/src/server/workflows/review.ts @@ -1,7 +1,7 @@ import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers'; import type { AppBindings } from '@server/env'; import { runReviewJob, FRESH_INVOCATION_YIELD_SECONDS } from '@server/core/review'; -import { type ReviewJobMessage } from '@shared/schema'; +import { type ReviewJobMessage } from '@codra/schema'; import { setJobWorkflowInstance } from '@server/db/jobs'; import { logger } from '@server/core/logger'; import { runBestEffortJobMaintenance } from '@server/core/job-recovery'; diff --git a/src/shared/config.ts b/src/shared/config.ts deleted file mode 100644 index 89b25fd8..00000000 --- a/src/shared/config.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Bump whenever a default changes or a key is added OR REMOVED: loadRepoConfig returns the cached entry -// WITHOUT re-parsing it, so without a bump the stale value is served for up to 10 minutes after -// deploy. Parse-on-read supplies defaults for stored DB rows, so this needs no data migration. -// -// v3: `review.min_severity` default 'nit' -> 'P3'. -// v4: `review.min_confidence` default 0.6 -> 0, `review.deny_claim_types` added (migration 005, now folded into 003_grounding.sql). -// v5: a generator-restraint key, added then removed. Listed because versions are never reused. -// v6: `review.rules` added. -// v7: `review.batch_small_files` added. -export const REPO_CONFIG_CACHE_VERSION = 'v7'; diff --git a/src/shared/schema-claims.ts b/src/shared/schema-claims.ts deleted file mode 100644 index b14c8c7c..00000000 --- a/src/shared/schema-claims.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { reviewCategories } from './schema-enums'; - -// Enforced, not just labelled: DEFAULT_DENIED_CLAIM_TYPES drops whole types and filters rule -// candidates. Makes per-type precision measurable. -export const claimTypes = [ - 'react_hook_missing_deps', - 'react_missing_cleanup', - 'missing_await', - 'unhandled_promise_rejection', - 'resource_leak', - 'null_or_undefined_deref', - 'sql_injection', - 'unsafe_dom_sink', - 'unsafe_dynamic_code', - 'insecure_randomness', - 'hardcoded_secret', - 'redos_regex', - 'swallowed_error', - 'mutable_default_arg', - 'destructive_migration', - // About the outside world, so unverifiable by cutoff or diff grounding. Worst family measured: - // 21 generated, 4 posted, all wrong, at confidence 0.964. - 'external_version_claim', - 'other', -] as const; - -export type ClaimType = typeof claimTypes[number]; - -// DERIVED, never asked for: asking produced 'quality' on every row and one meaningless bar. -export const CLAIM_TYPE_CATEGORY: Record = { - sql_injection: 'security', - unsafe_dom_sink: 'security', - unsafe_dynamic_code: 'security', - insecure_randomness: 'security', - hardcoded_secret: 'security', - missing_await: 'bugs', - unhandled_promise_rejection: 'bugs', - null_or_undefined_deref: 'bugs', - react_hook_missing_deps: 'bugs', - swallowed_error: 'bugs', - mutable_default_arg: 'bugs', - resource_leak: 'performance', - redos_regex: 'performance', - destructive_migration: 'correctness', - react_missing_cleanup: 'correctness', - external_version_claim: 'correctness', - other: 'quality', -}; - -export function toClaimType(value: unknown): ClaimType { - return (claimTypes as readonly string[]).includes(value as string) ? (value as ClaimType) : 'other'; -} - -// Decidable from a diff hunk ALONE? Wider context is unaffordable (16k input tokens/min, one -// subrequest per file body against a budget of 25). needs_whole_file wants a signature, nullability -// or reachability, where general models measure near a coin flip. A Record, so a new type is a -// COMPILE ERROR until classified. -export const CLAIM_TYPE_DECIDABILITY: Record = { - sql_injection: 'diff_local', - unsafe_dom_sink: 'diff_local', - unsafe_dynamic_code: 'diff_local', - insecure_randomness: 'diff_local', - hardcoded_secret: 'diff_local', - mutable_default_arg: 'diff_local', - destructive_migration: 'diff_local', - swallowed_error: 'diff_local', - unhandled_promise_rejection: 'diff_local', - // Interprocedural but allowed: a known-true un-awaited call looks identical and the label is - // unpredictable. The largest deliberate soundness hole here. - missing_await: 'diff_local', - // Escape hatch, never deniable: where real defects the taxonomy cannot name land. A jump in its - // claimTypeCounts share means relabelling. - other: 'diff_local', - - react_hook_missing_deps: 'needs_whole_file', // needs the enclosing component and what's in scope - react_missing_cleanup: 'needs_whole_file', // needs to know whether cleanup exists outside the hunk - resource_leak: 'needs_whole_file', // interprocedural lifetime reasoning - redos_regex: 'needs_whole_file', // regex complexity AND reachability from untrusted input - - // Held out: 3 generated, 0 valid. Needs off-diff nullability plus path feasibility, the LLIFT class - // (~50% precision). - null_or_undefined_deref: 'needs_whole_file', - - // Undecidable from any source: the fact lives in a registry postdating training, and the only sound - // answer is a network lookup we will not do mid-review. - external_version_claim: 'needs_external_facts', -}; - -// Not reportable by default: anything undecidable from the diff. Derived from the table, so -// classifying a new type is the only step needed. -export const DEFAULT_DENIED_CLAIM_TYPES: ClaimType[] = claimTypes.filter( - (type) => CLAIM_TYPE_DECIDABILITY[type] !== 'diff_local', -); - -// Generate candidates, never post. Every rule starts here; promote by removing its id. -export const DEFAULT_SHADOW_RULE_IDS = [ - 'empty-catch', - 'debugger-statement', - 'focused-test', - 'dynamic-code-exec', - 'dynamic-html-sink', - 'mutable-default-arg', - 'destructive-migration', - // Tier 2, `enabled: false`. Listed so flipping one on starts SHADOW scoring, not posting P0s. - 'hardcoded-secret', - 'insecure-random', -] as const; - -// How a finding ended its life; splits what `posted = false` conflated. READ-VALIDATING, not just -// descriptive: getJobDetail runs jobDetailSchema.parse() over raw Postgres rows, so deleting a value -// a historical row still carries makes the dashboard throw. Retire by marking historical, never by -// deleting. -export const findingDispositions = [ - 'posted', - 'severity', - 'confidence', - 'suppression', - 'dedupe', - 'verify', - // Distinct from 'verify': that is the model's judgement, this is it not answering, which is our bug. - 'verify_unanswered', - // HISTORICAL, no producer: a rule candidate verification could not confirm, from when rules failed - // CLOSED. Removed with verify-findings.ts; kept because older rows still hold it. - 'rule_unverified', - 'cap', - // HISTORICAL, same removal: was for candidates unrenderable for verification, now simply kept. - // comment-card.tsx still labels it so old findings read correctly. - 'unverifiable_passthrough', -] as const; - -export type FindingDisposition = typeof findingDispositions[number]; diff --git a/src/shared/transient-errors.ts b/src/shared/transient-errors.ts deleted file mode 100644 index 8cd54bcf..00000000 --- a/src/shared/transient-errors.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Common core shared by isRetryableFileReviewErrorMessage (review.ts) and isTransientModelFailure (model-support.ts) so their lists can't silently drift apart; each still appends its own layer-specific extras. -export const SHARED_TRANSIENT_ERROR_SUBSTRINGS = [ - 'unavailable', - 'high demand', - 'returned no review content', - 'empty response', - '[redacted]', -] as const; - -// Timeouts are deliberately NOT transient here -- both classifiers fail fast on them. -export function isTimeoutMessage(lowerMessage: string): boolean { - return lowerMessage.includes('timed out') || lowerMessage.includes('timeout'); -} - -// The runtime refused the call because the invocation is out of subrequests. Nothing about the -// model: every remaining model in a chain will fail identically, so the only useful response is to -// stop and let a fresh invocation retry. Lives here, not in core/review/retry-policy.ts, because the -// model chain in services/ needs the same answer and cannot import across that boundary. -// Any message that merely MENTIONS "subrequest" counts -- retry-policy.ts has always matched that -// loosely, and the callers that must not trip it already avoid the word deliberately. -export function isSubrequestBudgetMessage(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error ?? ''); - return message.toLowerCase().includes('subrequest'); -} - -export function matchesAnyTransientSubstring( - lowerMessage: string, - substrings: readonly string[] = SHARED_TRANSIENT_ERROR_SUBSTRINGS, -): boolean { - return substrings.some((substring) => lowerMessage.includes(substring)); -} diff --git a/test/api/auth.spec.ts b/test/api/auth.spec.ts index 1a595390..f8e0c845 100644 --- a/test/api/auth.spec.ts +++ b/test/api/auth.spec.ts @@ -7,14 +7,14 @@ // than being divided; the account/session half could move out, but the settings half cannot. import { getReviewSettings, updateReviewSettings } from '@server/db/app-settings'; -import { reviewMaxFilesRange } from '@shared/schema'; +import { reviewMaxFilesRange } from '@codra/schema'; import { createApp } from '@server/app'; import { queryRows, runWithDb } from '@server/db/client'; import { syncUpdatesEmail } from '@server/core/updates-email'; -import type { AccountResponse, AuthSessionResponse, JobsResponse, UpdatesEmailResponse } from '@shared/api'; +import type { AccountResponse, AuthSessionResponse, JobsResponse, UpdatesEmailResponse } from '@codra/schema/api'; import { createTestEnv, dbDescribe } from '../helpers'; import { vi } from 'vitest'; diff --git a/test/api/jobs.spec.ts b/test/api/jobs.spec.ts index 7a5f41c7..83ee2b67 100644 --- a/test/api/jobs.spec.ts +++ b/test/api/jobs.spec.ts @@ -2,8 +2,8 @@ import { createApp } from '@server/app'; import { getJobForProcessing, insertJob } from '@server/db/jobs'; import { upsertFileReview } from '@server/db/file-reviews'; -import { defaultRepoConfig, reviewJobMessageSchema } from '@shared/schema'; -import type { JobDetailResponse, StatsResponse } from '@shared/api'; +import { defaultRepoConfig, reviewJobMessageSchema } from '@codra/schema'; +import type { JobDetailResponse, StatsResponse } from '@codra/schema/api'; import { createTestEnv, uniqueName, uniqueRepo } from '../helpers'; import { vi } from 'vitest'; diff --git a/test/api/models.spec.ts b/test/api/models.spec.ts index 8df289fd..aa76cb07 100644 --- a/test/api/models.spec.ts +++ b/test/api/models.spec.ts @@ -1,6 +1,6 @@ import { createApp } from '@server/app'; -import type { ModelConfigsResponse } from '@shared/api'; +import type { ModelConfigsResponse } from '@codra/schema/api'; import { createTestEnv, saveTestProviderApiKey, uniqueName } from '../helpers'; import { vi } from 'vitest'; diff --git a/test/api/repos.spec.ts b/test/api/repos.spec.ts index f3f67c8a..6bac2bdc 100644 --- a/test/api/repos.spec.ts +++ b/test/api/repos.spec.ts @@ -5,8 +5,8 @@ import { getRepoConfigRecord } from '@server/db/repo-configs'; import { loadRepoConfig, updateGlobalConfig } from '@server/core/config'; import { GitHubClient } from '@server/core/github'; -import { defaultRepoConfig } from '@shared/schema'; -import type { RepoConfigsResponse } from '@shared/api'; +import { defaultRepoConfig } from '@codra/schema'; +import type { RepoConfigsResponse } from '@codra/schema/api'; import { createTestEnv, uniqueName } from '../helpers'; import { vi } from 'vitest'; diff --git a/test/comment-feedback.spec.ts b/test/comment-feedback.spec.ts index 1b4d9fce..4679c5e3 100644 --- a/test/comment-feedback.spec.ts +++ b/test/comment-feedback.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { createApp } from '@server/app'; import { createTestEnv } from './helpers'; import { FormatterService, formatFindingMarker, parseFindingMarker } from '@server/services/formatter'; -import type { ParsedReviewComment } from '@shared/schema'; +import type { ParsedReviewComment } from '@codra/schema'; import { signPayload } from './mocks/fixtures'; diff --git a/test/db/bulk-upsert.spec.ts b/test/db/bulk-upsert.spec.ts index 9a2ef48d..a5e3434d 100644 --- a/test/db/bulk-upsert.spec.ts +++ b/test/db/bulk-upsert.spec.ts @@ -7,7 +7,7 @@ import { getFileReviewsForJobs, } from '@server/db/file-reviews'; import { getJobDetail, insertJob } from '@server/db/jobs'; -import type { ParsedReviewComment } from '@shared/schema'; +import type { ParsedReviewComment } from '@codra/schema'; import { createTestEnv, dbDescribe, sha, uniqueName } from '../helpers'; const env = createTestEnv(); diff --git a/test/diff.spec.ts b/test/diff.spec.ts index 3092dcbf..211997db 100644 --- a/test/diff.spec.ts +++ b/test/diff.spec.ts @@ -7,7 +7,7 @@ import { parseUnifiedDiff, truncateFileDiff, } from '@server/core/diff'; -import { defaultRepoConfig } from '@shared/schema'; +import { defaultRepoConfig } from '@codra/schema'; describe('Diff Engine Deep Dive', () => { const sampleDiff = `diff --git a/src/example.ts b/src/example.ts diff --git a/test/e2e/batch-grouping.spec.ts b/test/e2e/batch-grouping.spec.ts index e130f31b..9338f04f 100644 --- a/test/e2e/batch-grouping.spec.ts +++ b/test/e2e/batch-grouping.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { groupBatches } from '@client/lib/batch-groups'; -import type { FileReviewRecord } from '@shared/schema'; +import type { FileReviewRecord } from '@codra/schema'; // Which files shared a model call is NOT stored -- pack.ts derives bins and never persists them. // The logs view reconstructs them from the shared response body, so these pin that reconstruction. diff --git a/test/findings/claim-types.spec.ts b/test/findings/claim-types.spec.ts index e851d731..6ae6ef09 100644 --- a/test/findings/claim-types.spec.ts +++ b/test/findings/claim-types.spec.ts @@ -7,7 +7,7 @@ import { DEFAULT_DENIED_CLAIM_TYPES, claimTypes, toClaimType, -} from '@shared/schema'; +} from '@codra/schema'; import { buildReviewResponseSchema, fileReviewSystemPromptBase } from '@server/prompts/file-review'; import type { FileDiff } from '@server/core/diff'; diff --git a/test/findings/gold-set.spec.ts b/test/findings/gold-set.spec.ts index d662c1c4..4169a8c7 100644 --- a/test/findings/gold-set.spec.ts +++ b/test/findings/gold-set.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { parseFileReviewResponse } from '@server/core/model-output'; import { verifyFindings } from '@server/core/review'; -import { DEFAULT_DENIED_CLAIM_TYPES, defaultRepoConfig } from '@shared/schema'; +import { DEFAULT_DENIED_CLAIM_TYPES, defaultRepoConfig } from '@codra/schema'; import type { FileDiff } from '@server/core/diff'; // The regression wall. diff --git a/test/findings/prompts-batch-review.spec.ts b/test/findings/prompts-batch-review.spec.ts index f0baa6f5..be9572dd 100644 --- a/test/findings/prompts-batch-review.spec.ts +++ b/test/findings/prompts-batch-review.spec.ts @@ -7,7 +7,7 @@ import { } from '@server/prompts/file-review'; import { BIN_DIFF_CHAR_BUDGET, BIN_MAX_FILES } from '@server/core/review'; import { PROMPT_FIT_SAFETY_FACTOR, estimatePromptTokens } from '@server/services/model'; -import { defaultRepoConfig } from '@shared/schema'; +import { defaultRepoConfig } from '@codra/schema'; import type { FileDiff } from '@server/core/diff'; function file(path: string, lines: string[]): FileDiff { diff --git a/test/findings/prompts-file-review.spec.ts b/test/findings/prompts-file-review.spec.ts index 526356a4..f8fc19ef 100644 --- a/test/findings/prompts-file-review.spec.ts +++ b/test/findings/prompts-file-review.spec.ts @@ -7,7 +7,7 @@ import { buildReviewResponseSchema, generatorFindingCap, } from '@server/prompts/file-review'; -import { defaultRepoConfig } from '@shared/schema'; +import { defaultRepoConfig } from '@codra/schema'; import type { FileDiff } from '@server/core/diff'; function fileAt(path: string): FileDiff { diff --git a/test/findings/review-verify.spec.ts b/test/findings/review-verify.spec.ts index b8452e16..42ab80e5 100644 --- a/test/findings/review-verify.spec.ts +++ b/test/findings/review-verify.spec.ts @@ -1,5 +1,5 @@ import { verifyFindings } from '@server/core/review'; -import { defaultRepoConfig, type ParsedReviewComment } from '@shared/schema'; +import { defaultRepoConfig, type ParsedReviewComment } from '@codra/schema'; import type { FileDiff } from '@server/core/diff'; const files: FileDiff[] = [ diff --git a/test/findings/rules-detect.spec.ts b/test/findings/rules-detect.spec.ts index 992e03c2..a3c2ed7b 100644 --- a/test/findings/rules-detect.spec.ts +++ b/test/findings/rules-detect.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { scanFileForRuleHits, ruleHitsToComments } from '@server/core/rules/detect'; import { RULES } from '@server/core/rules/table'; -import { CLAIM_TYPE_DECIDABILITY, DEFAULT_SHADOW_RULE_IDS } from '@shared/schema'; +import { CLAIM_TYPE_DECIDABILITY, DEFAULT_SHADOW_RULE_IDS } from '@codra/schema'; import { addedLinesFile } from '../mocks/fixtures'; diff --git a/test/findings/rules-pipeline.spec.ts b/test/findings/rules-pipeline.spec.ts index b12b2829..51ee1ca9 100644 --- a/test/findings/rules-pipeline.spec.ts +++ b/test/findings/rules-pipeline.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { dedupeFindings } from '@server/core/model-output'; import { ruleHitsToComments, scanFileForRuleHits } from '@server/core/rules/detect'; -import { defaultRepoConfig, type ParsedReviewComment } from '@shared/schema'; +import { defaultRepoConfig, type ParsedReviewComment } from '@codra/schema'; import type { FileDiff } from '@server/core/diff'; import { addedLinesFile } from '../mocks/fixtures'; diff --git a/test/findings/suppression.spec.ts b/test/findings/suppression.spec.ts index 5481d9e0..59c3a667 100644 --- a/test/findings/suppression.spec.ts +++ b/test/findings/suppression.spec.ts @@ -4,7 +4,7 @@ import { clearDashboardFeedback, upsertDashboardFeedback } from '@server/db/comm import { runWithDb, queryRows } from '@server/db/client'; import { insertJob } from '@server/db/jobs'; import { getSuppressedFindings, markCommentsPosted, upsertFileReview } from '@server/db/file-reviews'; -import type { ParsedReviewComment } from '@shared/schema'; +import type { ParsedReviewComment } from '@codra/schema'; diff --git a/test/jsonb-encoding.spec.ts b/test/jsonb-encoding.spec.ts index 455a7e9d..6cefd28a 100644 --- a/test/jsonb-encoding.spec.ts +++ b/test/jsonb-encoding.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { defaultRepoConfig } from '@shared/schema'; +import { defaultRepoConfig } from '@codra/schema'; import { queryRows } from '@server/db/client'; import { insertJob } from '@server/db/jobs'; import { upsertFileReview } from '@server/db/file-reviews'; diff --git a/test/model/chain-resume.spec.ts b/test/model/chain-resume.spec.ts index 9eebc83f..e7981afe 100644 --- a/test/model/chain-resume.spec.ts +++ b/test/model/chain-resume.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ModelService, nextChainIndexOf } from '@server/services/model'; -import { defaultRepoConfig } from '@shared/schema'; +import { defaultRepoConfig } from '@codra/schema'; import { TokenTracker } from '@server/core/token-tracker'; import { createTestEnv, saveTestProviderApiKey } from '../helpers'; diff --git a/test/model/output.spec.ts b/test/model/output.spec.ts index d2cd2ceb..8ad1ed3b 100644 --- a/test/model/output.spec.ts +++ b/test/model/output.spec.ts @@ -1,6 +1,6 @@ import { parseFileReviewResponse, dedupeFindings } from '@server/core/model-output'; import type { FileDiff } from '@server/core/diff'; -import type { ParsedReviewComment } from '@shared/schema'; +import type { ParsedReviewComment } from '@codra/schema'; describe('Model Output Parsing Deep Dive', () => { const mockFile: FileDiff = { diff --git a/test/model/service-chunking.spec.ts b/test/model/service-chunking.spec.ts index 686dd564..5442a31c 100644 --- a/test/model/service-chunking.spec.ts +++ b/test/model/service-chunking.spec.ts @@ -5,7 +5,7 @@ import { ModelService } from '@server/services/model'; import { createTestEnv, saveTestProviderApiKey } from '../helpers'; -import { defaultRepoConfig } from '@shared/schema'; +import { defaultRepoConfig } from '@codra/schema'; import { TokenTracker } from '@server/core/token-tracker'; import { geminiThinkingBudgetTokens, reviewOutputBudgetTokens } from '@server/models/limits'; import { generatorFindingCap } from '@server/prompts/file-review'; diff --git a/test/model/service-fallbacks.spec.ts b/test/model/service-fallbacks.spec.ts index 6f6d2436..667b3e0a 100644 --- a/test/model/service-fallbacks.spec.ts +++ b/test/model/service-fallbacks.spec.ts @@ -3,7 +3,7 @@ import { isRetryableModelError, ModelService } from '@server/services/model'; import { createTestEnv, saveTestProviderApiKey } from '../helpers'; -import { defaultRepoConfig } from '@shared/schema'; +import { defaultRepoConfig } from '@codra/schema'; import { TokenTracker } from '@server/core/token-tracker'; // Walking the model chain: fallback, the two subrequest-budget breakers, and marking a provider diff --git a/test/model/service-grammar-rejection.spec.ts b/test/model/service-grammar-rejection.spec.ts index eb2e6b81..f6c88399 100644 --- a/test/model/service-grammar-rejection.spec.ts +++ b/test/model/service-grammar-rejection.spec.ts @@ -3,7 +3,7 @@ import { ModelService } from '@server/services/model'; import { reviewWithGoogle } from '@server/models/google'; import { buildReviewResponseSchema } from '@server/prompts/file-review'; import { createTestEnv, saveTestProviderApiKey } from '../helpers'; -import { defaultRepoConfig } from '@shared/schema'; +import { defaultRepoConfig } from '@codra/schema'; // Split out of service-retries.spec.ts: a 400 matches no transient pattern, so grammar rejection is // its own ladder rung -- drop responseJsonSchema, retry once, latch it off -- not part of the diff --git a/test/model/service-requests.spec.ts b/test/model/service-requests.spec.ts index f56e6884..e8aab636 100644 --- a/test/model/service-requests.spec.ts +++ b/test/model/service-requests.spec.ts @@ -6,7 +6,7 @@ import { reviewWithGoogle } from '@server/models/google'; import { buildBatchReviewResponseSchema, buildReviewResponseSchema } from '@server/prompts/file-review'; import { VERIFY_RESPONSE_SCHEMA } from '@server/prompts/verify'; import { createTestEnv, saveTestProviderApiKey } from '../helpers'; -import { defaultRepoConfig } from '@shared/schema'; +import { defaultRepoConfig } from '@codra/schema'; describe('ModelService: request shape and response handling', () => { diff --git a/test/model/service-retries.spec.ts b/test/model/service-retries.spec.ts index bff75b77..be52c755 100644 --- a/test/model/service-retries.spec.ts +++ b/test/model/service-retries.spec.ts @@ -4,7 +4,7 @@ import { reviewWithCloudflare } from '@server/models/cloudflare'; import { reviewWithGoogle } from '@server/models/google'; import { MODEL_TIMEOUT_MAX_MS } from '@server/models/limits'; import { createTestEnv, saveTestProviderApiKey } from '../helpers'; -import { defaultRepoConfig } from '@shared/schema'; +import { defaultRepoConfig } from '@codra/schema'; // The retry ladder: inline retries, Retry-After, and which exhausted runs report as retryable. describe('ModelService: transient failures and the retry ladder', () => { diff --git a/test/review/async-batch.spec.ts b/test/review/async-batch.spec.ts index ce5d2e34..ef5cabbb 100644 --- a/test/review/async-batch.spec.ts +++ b/test/review/async-batch.spec.ts @@ -21,7 +21,7 @@ const { getReviewSettingsMock } = vi.hoisted(() => ({ getReviewSettingsMock: vi. vi.mock('@server/db/app-settings', async (importOriginal) => { const mod = await importOriginal>(); - const { reviewSettingsSchema } = await import('@shared/schema'); + const { reviewSettingsSchema } = await import('@codra/schema'); getReviewSettingsMock.mockResolvedValue(reviewSettingsSchema.parse({})); return { ...mod, getReviewSettings: getReviewSettingsMock }; }); diff --git a/test/review/batch-flow.spec.ts b/test/review/batch-flow.spec.ts index 5155ce42..d5f64dc6 100644 --- a/test/review/batch-flow.spec.ts +++ b/test/review/batch-flow.spec.ts @@ -3,7 +3,7 @@ import { createTestEnv, dbDescribe, generateMockDiff, sha, uniqueRepo } from '.. import { afterEach, expect, it, vi } from 'vitest'; import { insertJob, updateJobFileCount, updateJobStep } from '@server/db/jobs'; import { getFileReviewsForJobs } from '@server/db/file-reviews'; -import { REVIEW_CONCURRENCY_LIMITS, defaultRepoConfig } from '@shared/schema'; +import { REVIEW_CONCURRENCY_LIMITS, defaultRepoConfig } from '@codra/schema'; import { runWithDb } from '@server/db/client'; import { REVIEW_FLOW_TIMEOUT_MS } from '../mocks/review-harness'; @@ -14,7 +14,7 @@ vi.mock('@server/db/jobs', async (importOriginal) => { vi.mock('@server/db/app-settings', async (importOriginal) => { const mod = await importOriginal>(); - const { reviewSettingsSchema } = await import('@shared/schema'); + const { reviewSettingsSchema } = await import('@codra/schema'); return { ...mod, getReviewSettings: vi.fn().mockResolvedValue(reviewSettingsSchema.parse({})) }; }); diff --git a/test/review/chunk-concurrency.spec.ts b/test/review/chunk-concurrency.spec.ts index f5dab173..66b196e1 100644 --- a/test/review/chunk-concurrency.spec.ts +++ b/test/review/chunk-concurrency.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { budgetAwareFileLimit, estimatedSubrequestsPerFile } from '@server/core/review'; import { TokenTracker } from '@server/core/token-tracker'; -import { REVIEW_CONCURRENCY_LIMITS, reviewConcurrencyLevels } from '@shared/schema'; +import { REVIEW_CONCURRENCY_LIMITS, reviewConcurrencyLevels } from '@codra/schema'; // Regression guard for "concurrency slider is dead above medium": the per-chunk budget cap must // NOT silently override the configured concurrency at a healthy budget. Exercises the REAL diff --git a/test/review/flow-chunking.spec.ts b/test/review/flow-chunking.spec.ts index c0424864..d704a455 100644 --- a/test/review/flow-chunking.spec.ts +++ b/test/review/flow-chunking.spec.ts @@ -3,7 +3,7 @@ import { createTestEnv, dbDescribe, generateMockDiff, sha, uniqueRepo } from '.. import { afterAll, vi } from 'vitest'; import { getJobForProcessing, insertJob, updateJobFileCount, updateJobStep } from '@server/db/jobs'; import { getFileReviewsForJobs, upsertFileReview } from '@server/db/file-reviews'; -import { defaultRepoConfig } from '@shared/schema'; +import { defaultRepoConfig } from '@codra/schema'; import { runWithDb } from '@server/db/client'; import { REVIEW_FLOW_TIMEOUT_MS } from '../mocks/review-harness'; @@ -22,7 +22,7 @@ const { getReviewSettingsMock } = vi.hoisted(() => ({ getReviewSettingsMock: vi. vi.mock('@server/db/app-settings', async (importOriginal) => { const mod = await importOriginal>(); - const { reviewSettingsSchema } = await import('@shared/schema'); + const { reviewSettingsSchema } = await import('@codra/schema'); getReviewSettingsMock.mockResolvedValue(reviewSettingsSchema.parse({})); return { ...mod, getReviewSettings: getReviewSettingsMock }; }); diff --git a/test/review/flow-lifecycle.spec.ts b/test/review/flow-lifecycle.spec.ts index 867dd5ec..d7f71279 100644 --- a/test/review/flow-lifecycle.spec.ts +++ b/test/review/flow-lifecycle.spec.ts @@ -3,7 +3,7 @@ import { createTestEnv, dbDescribe, generateMockDiff, sha, uniqueRepo } from '.. import { afterAll, vi } from 'vitest'; import { findExistingJobForHead, getJobForProcessing, insertJob, updateJobStep } from '@server/db/jobs'; import { getFileReviewsForJobs, upsertFileReview } from '@server/db/file-reviews'; -import { defaultRepoConfig } from '@shared/schema'; +import { defaultRepoConfig } from '@codra/schema'; import { runWithDb, queryRows } from '@server/db/client'; import { makeRunAndDrain, REVIEW_FLOW_TIMEOUT_MS } from '../mocks/review-harness'; @@ -23,7 +23,7 @@ const { getReviewSettingsMock } = vi.hoisted(() => ({ getReviewSettingsMock: vi. vi.mock('@server/db/app-settings', async (importOriginal) => { const mod = await importOriginal>(); - const { reviewSettingsSchema } = await import('@shared/schema'); + const { reviewSettingsSchema } = await import('@codra/schema'); getReviewSettingsMock.mockResolvedValue(reviewSettingsSchema.parse({})); return { ...mod, getReviewSettings: getReviewSettingsMock }; }); diff --git a/test/review/flow-retry.spec.ts b/test/review/flow-retry.spec.ts index 78875737..ef063f0a 100644 --- a/test/review/flow-retry.spec.ts +++ b/test/review/flow-retry.spec.ts @@ -3,7 +3,7 @@ import { createTestEnv, dbDescribe, sha, uniqueRepo } from '../helpers'; import { afterAll, vi } from 'vitest'; import { getJobForProcessing, insertJob, updateJobFileCount, updateJobStep } from '@server/db/jobs'; import { getFileReviewsForJobs, upsertFileReview } from '@server/db/file-reviews'; -import { defaultRepoConfig, type ParsedReviewComment } from '@shared/schema'; +import { defaultRepoConfig, type ParsedReviewComment } from '@codra/schema'; import { runWithDb } from '@server/db/client'; import { makeRunAndDrain, REVIEW_FLOW_TIMEOUT_MS } from '../mocks/review-harness'; @@ -22,7 +22,7 @@ const { getReviewSettingsMock } = vi.hoisted(() => ({ getReviewSettingsMock: vi. vi.mock('@server/db/app-settings', async (importOriginal) => { const mod = await importOriginal>(); - const { reviewSettingsSchema } = await import('@shared/schema'); + const { reviewSettingsSchema } = await import('@codra/schema'); getReviewSettingsMock.mockResolvedValue(reviewSettingsSchema.parse({})); return { ...mod, getReviewSettings: getReviewSettingsMock }; }); diff --git a/test/review/max-files.spec.ts b/test/review/max-files.spec.ts index ef7d0fac..03dd95e3 100644 --- a/test/review/max-files.spec.ts +++ b/test/review/max-files.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { defaultRepoConfig, reviewMaxFilesRange, reviewSettingsSchema } from '@shared/schema'; +import { defaultRepoConfig, reviewMaxFilesRange, reviewSettingsSchema } from '@codra/schema'; describe('review max files settings', () => { it('defaults to 200', () => { diff --git a/test/review/pipeline-regression.spec.ts b/test/review/pipeline-regression.spec.ts index cba6a512..f9db0958 100644 --- a/test/review/pipeline-regression.spec.ts +++ b/test/review/pipeline-regression.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { parseFileReviewResponse } from '@server/core/model-output'; -import { DEFAULT_DENIED_CLAIM_TYPES } from '@shared/schema'; +import { DEFAULT_DENIED_CLAIM_TYPES } from '@codra/schema'; import type { FileDiff } from '@server/core/diff'; // End-to-end regression over the parse-time chain: JSON extraction, evidence grounding, the diff --git a/test/review/quota-deferral.spec.ts b/test/review/quota-deferral.spec.ts index 6031a7f3..b3b5021e 100644 --- a/test/review/quota-deferral.spec.ts +++ b/test/review/quota-deferral.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { isRetryableModelError, ModelService } from '@server/services/model'; import { reviewWithGoogle } from '@server/models/google'; import { createTestEnv, saveTestProviderApiKey } from '../helpers'; -import { defaultRepoConfig } from '@shared/schema'; +import { defaultRepoConfig } from '@codra/schema'; const file = { path: 'src/app.ts', diff --git a/test/review/resilience.spec.ts b/test/review/resilience.spec.ts index 720b6110..5067d406 100644 --- a/test/review/resilience.spec.ts +++ b/test/review/resilience.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { getDiffFiles, failJobAndCheckRun } from '@server/core/review'; import { createTestEnv, generateMockDiff } from '../helpers'; -import { defaultRepoConfig } from '@shared/schema'; +import { defaultRepoConfig } from '@codra/schema'; // Regression coverage for the subrequest-exhaustion incident (job bb9cf692...): a large PR's // review workflow re-fetched the PR diff from GitHub on every phase/chunk and, once the diff --git a/tsconfig.json b/tsconfig.json index bf19fb0e..4397d7b2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,6 @@ "paths": { "@client/*": ["./src/client/*"], "@server/*": ["./src/server/*"], - "@shared/*": ["./src/shared/*"], "@/*": ["./src/client/*"] }, "jsx": "react-jsx", diff --git a/vite.config.ts b/vite.config.ts index afef5e23..bcc67f50 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,7 +14,6 @@ export default defineConfig(({ mode }) => ({ alias: { '@client': path.resolve(rootDir, 'src/client'), '@server': path.resolve(rootDir, 'src/server'), - '@shared': path.resolve(rootDir, 'src/shared'), '@': path.resolve(rootDir, 'src/client'), }, }, diff --git a/vitest.config.ts b/vitest.config.ts index b2248bcb..98bdedce 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,7 +8,6 @@ export default defineConfig({ alias: { '@server': resolve(__dirname, './src/server'), '@client': resolve(__dirname, './src/client'), - '@shared': resolve(__dirname, './src/shared'), '@': resolve(__dirname, './src/client'), 'cloudflare:workers': resolve(__dirname, './test/mocks/cloudflare-workers.ts'), }, From d3adbb5f4233e2568f7059b4f7a21ebef606635d Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Thu, 13 Aug 2026 04:34:20 +0530 Subject: [PATCH 3/6] refactor: extract the review engine into @codra/core behind ports --- .github/workflows/ci.yml | 23 +- eslint.config.js | 38 +- package-lock.json | 11 +- package.json | 4 +- packages/core/package.json | 32 +- packages/core/src/claim-checks.ts | 340 ++++++++++++++ packages/core/src/diff/index.ts | 295 ++++++++++++ .../core/src}/diff/position.ts | 0 .../core/src}/finding-gates.ts | 8 +- packages/core/src/fingerprint.ts | 55 +++ packages/core/src/index.ts | 42 +- packages/core/src/logger.ts | 125 +++++ .../core/src}/model-output/batch.ts | 2 +- .../core/src}/model-output/dedupe.ts | 0 .../core/src}/model-output/evidence.ts | 0 packages/core/src/model-output/index.ts | 436 +++++++++++++++++ .../core/src}/model-output/json-batch.ts | 0 .../core/src}/model-output/json.ts | 0 .../core/src}/model-output/non-answer.ts | 0 packages/core/src/ports/file-reviews.ts | 148 ++++++ packages/core/src/ports/formatter.ts | 21 + packages/core/src/ports/github.ts | 83 ++++ packages/core/src/ports/index.ts | 16 + packages/core/src/ports/jobs.ts | 130 ++++++ packages/core/src/ports/model.ts | 120 +++++ packages/core/src/ports/platform.ts | 48 ++ packages/core/src/ports/runtime.ts | 56 +++ packages/core/src/ports/settings.ts | 72 +++ packages/core/src/ports/telemetry.ts | 36 ++ packages/core/src/prompts/file-review.ts | 428 +++++++++++++++++ packages/core/src/prompts/languages.ts | 91 ++++ packages/core/src/prompts/summary.ts | 57 +++ packages/core/src/prompts/verify.ts | 168 +++++++ .../core/src}/review/bin-runner.ts | 39 +- .../core/src}/review/budget.ts | 0 .../core/src}/review/diff-cache.ts | 19 +- .../core/src}/review/file-runner.ts | 39 +- .../core/src}/review/finalize.ts | 49 +- .../core/src}/review/gate-pipeline.ts | 7 +- packages/core/src/review/index.ts | 371 +++++++++++++++ .../core => packages/core/src}/review/pack.ts | 2 +- .../core/src}/review/phase-control.ts | 35 +- .../core/src}/review/phase.ts | 43 +- .../core/src}/review/prepare.ts | 38 +- .../core/src}/review/request.ts | 0 .../core/src}/review/retry-policy.ts | 7 +- .../core/src}/review/telemetry.ts | 14 +- packages/core/src/rules/detect.ts | 159 +++++++ packages/core/src/rules/table.ts | 149 ++++++ packages/core/src/timeout.ts | 22 + packages/core/src/token-tracker.ts | 131 ++++++ packages/core/src/verify.ts | 20 + packages/core/test/in-memory.ts | 420 +++++++++++++++++ packages/core/test/logger.spec.ts | 112 +++++ packages/core/test/review-in-memory.spec.ts | 160 +++++++ packages/core/tsconfig.json | 25 +- packages/core/vitest.config.ts | 14 + packages/schema/tsconfig.json | 8 +- scripts/check-core-boundary.mjs | 107 +++++ src/server/adapters/file-review-store.ts | 31 ++ src/server/adapters/index.ts | 56 +++ src/server/adapters/jobs-store.ts | 78 ++++ src/server/adapters/platform.ts | 23 + src/server/adapters/services.ts | 34 ++ src/server/adapters/settings-store.ts | 32 ++ src/server/core/claim-checks.ts | 342 +------------- src/server/core/diff/index.ts | 297 +----------- src/server/core/fingerprint.ts | 59 +-- src/server/core/github/types.ts | 23 +- src/server/core/logger.ts | 73 +-- src/server/core/model-output/index.ts | 438 +----------------- src/server/core/review/index.ts | 419 ++--------------- src/server/core/rules/detect.ts | 161 +------ src/server/core/rules/table.ts | 151 +----- src/server/core/timeout.ts | 25 +- src/server/core/token-tracker.ts | 133 +----- src/server/core/verify.ts | 22 +- src/server/db/file-reviews-bulk.ts | 25 +- src/server/db/file-reviews-findings.ts | 12 +- src/server/models/types.ts | 20 +- src/server/prompts/file-review.ts | 430 +---------------- src/server/prompts/languages.ts | 93 +--- src/server/prompts/summary.ts | 59 +-- src/server/prompts/verify.ts | 170 +------ src/server/routes/api/jobs.ts | 7 +- test/review/resilience.spec.ts | 19 +- tsconfig.json | 9 +- vitest.workspace.ts | 7 - 88 files changed, 5056 insertions(+), 3067 deletions(-) create mode 100644 packages/core/src/claim-checks.ts create mode 100644 packages/core/src/diff/index.ts rename {src/server/core => packages/core/src}/diff/position.ts (100%) rename {src/server/core => packages/core/src}/finding-gates.ts (96%) create mode 100644 packages/core/src/fingerprint.ts create mode 100644 packages/core/src/logger.ts rename {src/server/core => packages/core/src}/model-output/batch.ts (99%) rename {src/server/core => packages/core/src}/model-output/dedupe.ts (100%) rename {src/server/core => packages/core/src}/model-output/evidence.ts (100%) create mode 100644 packages/core/src/model-output/index.ts rename {src/server/core => packages/core/src}/model-output/json-batch.ts (100%) rename {src/server/core => packages/core/src}/model-output/json.ts (100%) rename {src/server/core => packages/core/src}/model-output/non-answer.ts (100%) create mode 100644 packages/core/src/ports/file-reviews.ts create mode 100644 packages/core/src/ports/formatter.ts create mode 100644 packages/core/src/ports/github.ts create mode 100644 packages/core/src/ports/index.ts create mode 100644 packages/core/src/ports/jobs.ts create mode 100644 packages/core/src/ports/model.ts create mode 100644 packages/core/src/ports/platform.ts create mode 100644 packages/core/src/ports/runtime.ts create mode 100644 packages/core/src/ports/settings.ts create mode 100644 packages/core/src/ports/telemetry.ts create mode 100644 packages/core/src/prompts/file-review.ts create mode 100644 packages/core/src/prompts/languages.ts create mode 100644 packages/core/src/prompts/summary.ts create mode 100644 packages/core/src/prompts/verify.ts rename {src/server/core => packages/core/src}/review/bin-runner.ts (88%) rename {src/server/core => packages/core/src}/review/budget.ts (100%) rename {src/server/core => packages/core/src}/review/diff-cache.ts (80%) rename {src/server/core => packages/core/src}/review/file-runner.ts (90%) rename {src/server/core => packages/core/src}/review/finalize.ts (88%) rename {src/server/core => packages/core/src}/review/gate-pipeline.ts (97%) create mode 100644 packages/core/src/review/index.ts rename {src/server/core => packages/core/src}/review/pack.ts (98%) rename {src/server/core => packages/core/src}/review/phase-control.ts (80%) rename {src/server/core => packages/core/src}/review/phase.ts (90%) rename {src/server/core => packages/core/src}/review/prepare.ts (69%) rename {src/server/core => packages/core/src}/review/request.ts (100%) rename {src/server/core => packages/core/src}/review/retry-policy.ts (93%) rename {src/server/core => packages/core/src}/review/telemetry.ts (88%) create mode 100644 packages/core/src/rules/detect.ts create mode 100644 packages/core/src/rules/table.ts create mode 100644 packages/core/src/timeout.ts create mode 100644 packages/core/src/token-tracker.ts create mode 100644 packages/core/src/verify.ts create mode 100644 packages/core/test/in-memory.ts create mode 100644 packages/core/test/logger.spec.ts create mode 100644 packages/core/test/review-in-memory.spec.ts create mode 100644 packages/core/vitest.config.ts create mode 100644 scripts/check-core-boundary.mjs create mode 100644 src/server/adapters/file-review-store.ts create mode 100644 src/server/adapters/index.ts create mode 100644 src/server/adapters/jobs-store.ts create mode 100644 src/server/adapters/platform.ts create mode 100644 src/server/adapters/services.ts create mode 100644 src/server/adapters/settings-store.ts delete mode 100644 vitest.workspace.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d547392..a4ffa78f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,8 +60,17 @@ jobs: - name: Install dependencies run: npm ci + # Both halves matter. The root program has worker-configuration.d.ts in scope, so it would + # happily accept a KVNamespace inside packages/core; each package's own `tsc -p` is the + # narrower program (no DOM-wide Worker types, no vitest globals) that actually enforces that. - name: Static Analysis (Typecheck) - run: npm run typecheck + run: npm run typecheck && npm run typecheck:all + + # The @codra/core purity criterion as an assertion rather than a convention: no hono/postgres/ + # wrangler/git-provider dependency in the manifest, and no type-only import sneaking the + # platform types back in. See the header of scripts/check-core-boundary.mjs. + - name: Boundary Check (@codra/core purity) + run: npm run check:boundaries # Lint is not cosmetic here: eslint.config.js carries the barrel guards that stop a module from # importing a mocked barrel's sibling (which would silently void a vi.mock), plus max-lines and @@ -72,8 +81,20 @@ jobs: - name: Automated Tests run: npm test + # The package suites, separate from `npm test` on purpose: that one shells through + # scripts/test.mjs, which requires TEST_DATABASE_URL and runs migrations. @codra/core's suite + # must pass with no database at all -- that is the acceptance criterion for the extraction. + - name: Automated Tests (packages) + run: npm run test:all + # Catches bundler-level breakage typecheck cannot see -- notably a client file pulling zod into # the browser bundle through @shared/schema. `vite build` rather than `npm run build` so CI does # not depend on the `wrangler types` step, which only regenerates a local .d.ts. - name: Build (client bundle) run: npx vite build + + # `vite build` above only builds index.html; the Worker entry is bundled by wrangler's esbuild, + # so nothing in CI previously proved src/server/index.ts still bundles. That is exactly where a + # bad @codra/core exports map fails -- silently, until deploy. + - name: Build (worker bundle, dry run) + run: npx wrangler deploy --dry-run --outdir=.wrangler/dry diff --git a/eslint.config.js b/eslint.config.js index 874f53ff..0772c7ee 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,8 +7,10 @@ import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescrip export default tseslint.config( { ignores: [ - 'dist/**', - 'node_modules/**', + // `**/` matters: a bare `dist/**` only covers the root build output, so emitted .d.ts under + // packages/*/dist was being linted as source. + '**/dist/**', + '**/node_modules/**', // Generated by `wrangler types`. 'src/server/worker-env.d.ts', 'worker-configuration.d.ts', @@ -70,6 +72,18 @@ export default tseslint.config( files: ['src/client/**/*.{ts,tsx}'], rules: { 'react-hooks/rules-of-hooks': 'error', + + // The zone block at the bottom of this file cannot express this direction: its `files` is + // packages/** + apps/**, so a violation living in src/client is never linted by it. + '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. (@codra/schema/review-limits is the sanctioned client-side import.)' + } + ] + }], }, }, @@ -96,9 +110,9 @@ export default tseslint.config( { 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.)' }, // Covers every sibling in the family, including the three the barrel re-exports publicly // (budget, diff-cache, request) which were previously unprotected. - { group: ['**/core/review/*', '@server/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/*'], message: 'Import from @server/core/model-output, not a sibling.' }, - { group: ['**/core/diff/position', '@server/core/diff/position'], message: 'Import from @server/core/diff, not a sibling.' }, + { group: ['**/core/review/*', '@server/core/review/*', '@codra/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/*', '@codra/core/model-output/*'], message: 'Import from @codra/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', '@codra/core/diff/position'], message: 'Import from @codra/core/diff, not a sibling.' }, { group: ['**/schema-claims', '**/schema-repo-config', '**/schema-enums', '@codra/schema/schema-claims', '@codra/schema/schema-repo-config', '@codra/schema/schema-enums'], message: 'Import from @codra/schema, not a sibling. (@codra/schema/review-limits is exempt: the client imports it directly to keep zod out of the browser bundle.)' }, ], }], @@ -124,9 +138,9 @@ export default tseslint.config( 'src/server/db/file-reviews.ts', 'src/server/services/model.ts', 'src/server/core/github/index.ts', - 'src/server/core/review/index.ts', - 'src/server/core/diff/index.ts', - 'src/server/core/model-output/index.ts', + // core/review, core/diff and core/model-output are gone from here: they moved to @codra/core and + // what is left at those paths is a re-export shim with no sibling imports to exempt. ESLint does + // not warn about `files` patterns that match nothing, so a stale entry would just rot quietly. 'packages/schema/src/schema.ts', ], rules: { @@ -157,12 +171,16 @@ export default tseslint.config( 'import-x/no-restricted-paths': ['error', { zones: [ { + // `src/**` in `from` is what actually holds the extraction in place. The zones below + // only ever described packages -> packages traffic, so nothing stopped a moved file from + // keeping its old `@server/db/jobs` import and quietly re-coupling the package to the + // Worker tree. Traffic goes src -> packages, through src/server/adapters, never back. target: 'packages/schema/**/*', - from: ['packages/core/**/*', 'packages/provider-github/**/*', 'packages/db/**/*', 'packages/models/**/*', 'packages/api/**/*', 'packages/ui/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] + 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: ['packages/provider-github/**/*', 'packages/db/**/*', 'packages/models/**/*', 'packages/api/**/*', 'packages/ui/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] + from: ['src/**/*', 'test/**/*', 'scripts/**/*', 'packages/provider-github/**/*', 'packages/db/**/*', 'packages/models/**/*', 'packages/api/**/*', 'packages/ui/**/*', 'apps/worker/**/*', 'apps/dashboard/**/*'] }, { target: 'packages/db/**/*', diff --git a/package-lock.json b/package-lock.json index e8fce7cf..fc14b7ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ ], "dependencies": { "@base-ui/react": "^1.6.0", + "@codra/core": "*", "@codra/schema": "*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -22,7 +23,6 @@ "lenis": "^1.3.26", "lucide-react": "^1.8.0", "motion": "^12.42.2", - "picomatch": "^4.0.5", "postgres": "^3.4.9", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -43,7 +43,6 @@ "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@types/node": "^25.6.0", - "@types/picomatch": "^4.0.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.5", @@ -8633,7 +8632,13 @@ "name": "@codra/core", "version": "0.9.4", "dependencies": { - "@codra/schema": "*" + "@codra/schema": "*", + "jsonrepair": "^3.15.0", + "picomatch": "^4.0.5", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/picomatch": "^4.0.3" } }, "packages/schema": { diff --git a/package.json b/package.json index b3d72189..65aa8795 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dev:worker": "wrangler dev --local", "lint": "eslint src test scripts packages apps", "lint:all": "npm run lint --workspaces --if-present", + "check:boundaries": "node scripts/check-core-boundary.mjs", "density": "node scripts/comment-density.mjs --top", "start": "npm run dev", "setup:cloudflare": "node scripts/setup-cloudflare.js", @@ -43,7 +44,6 @@ "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@types/node": "^25.6.0", - "@types/picomatch": "^4.0.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.5", @@ -64,6 +64,7 @@ "wrangler": "^4.114.0" }, "dependencies": { + "@codra/core": "*", "@codra/schema": "*", "@base-ui/react": "^1.6.0", "class-variance-authority": "^0.7.1", @@ -73,7 +74,6 @@ "lenis": "^1.3.26", "lucide-react": "^1.8.0", "motion": "^12.42.2", - "picomatch": "^4.0.5", "postgres": "^3.4.9", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/packages/core/package.json b/packages/core/package.json index 2d34ea36..13cbd73f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,8 +2,36 @@ "name": "@codra/core", "version": "0.9.4", "private": true, - "main": "src/index.ts", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./ports": "./src/ports/index.ts", + "./logger": "./src/logger.ts", + "./diff": "./src/diff/index.ts", + "./model-output": "./src/model-output/index.ts", + "./rules/detect": "./src/rules/detect.ts", + "./rules/table": "./src/rules/table.ts", + "./claim-checks": "./src/claim-checks.ts", + "./verify": "./src/verify.ts", + "./fingerprint": "./src/fingerprint.ts", + "./timeout": "./src/timeout.ts", + "./token-tracker": "./src/token-tracker.ts", + "./prompts/file-review": "./src/prompts/file-review.ts", + "./prompts/languages": "./src/prompts/languages.ts", + "./prompts/summary": "./src/prompts/summary.ts", + "./prompts/verify": "./src/prompts/verify.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run" + }, "dependencies": { - "@codra/schema": "*" + "@codra/schema": "*", + "jsonrepair": "^3.15.0", + "picomatch": "^4.0.5", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/picomatch": "^4.0.3" } } diff --git a/packages/core/src/claim-checks.ts b/packages/core/src/claim-checks.ts new file mode 100644 index 00000000..c3c623f5 --- /dev/null +++ b/packages/core/src/claim-checks.ts @@ -0,0 +1,340 @@ +// 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 { DiffLine, FileDiff } from './diff'; +import { normalizeDiffText } from './fingerprint'; + +// Refute only when the identifier turns up in the same hunk, or this close in the new file. +const PROXIMITY_WINDOW_LINES = 25; + +// Shorter than this and an identifier is too generic to carry a refutation. +const MIN_IDENTIFIER_LENGTH = 3; + +// Anchored on verbs, not bare "missing": that also matches undecidable claims like "missing error handling". +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, +]; + +// Never refute on these: finding `await` elsewhere does not refute "`await` is missing". +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', +]); + +// Wording that marks a claim as about an external version or config key, not the code shown. +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, + // A claim about what an installed library's API offers is the same kind of claim as one about a + // version: it is settled by node_modules, not by the diff. Added after a P0 on codra's own PR #86 + // asserted that `z.uuid()` "does not expose" a top-level validator and would throw at runtime -- + // Zod 4 has had it since the 4.0 release, and the suggested fix reverted to the deprecated form. + // "does not exist" was already covered; the miss was purely the verb. + /\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, +]; + +// ---- Undecidable-claim refutations --------------------------------------------------------------- +// CLAIM_TYPE_DECIDABILITY answers "can this be settled from a diff hunk?" per claim TYPE, which leaves +// `other` -- the deliberate escape hatch, marked diff_local -- carrying whatever a model wants to +// assert. These answer the same question per CLAIM, for the two families that recur: +// +// cross-file the claim's consequence lands in a file that is not in the diff +// environment the claim is conditional on a runtime, framework or engine version not shown +// +// Both are already forbidden by the review prompt in prose; on codra's own PR #86 the models ignored +// that instruction four times in one review, and the verification pass confirmed every one of them +// (generator and verifier share a knowledge gap, so verification cannot close it). +// +// Same soundness rule as the absence checker above: a refutation asserts only that the claim cannot be +// settled HERE, never that the code is fine. Losing one is free; a wrong one silences a real defect. + +// The claim reaches for consumers it cannot see: "other modules", "downstream callers". +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; + +// Hedged, and hedged specifically about where the code runs rather than about what it does. +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; + +// "if `loadCooldowns()` fails, the rejection is unhandled" -- a claim about how a function HANDLES ITS +// OWN ERRORS, where that function's body is not in the diff. Posted as a P1 on codra's own PR: the +// callee already wrapped its only failure path in try/catch, in another file, with a comment saying so. +// Requires a call-shaped subject (`name(` or `name()`), a failure condition, and an unhandled-outcome +// word, so an ordinary claim about visible code -- "this catch swallows the error" -- does not match. +// `(?!\.\s)` skips a sentence break but keeps dotted member expressions, so the condition still matches +// "if the `this.persistence.loadCooldowns()` call fails" without spanning two sentences. +const CALLEE_FAILURE_CONDITION = /\b(?:if|when|should|were)\b(?:(?!\.\s)[^;!?]){0,100}\b(?:fails?|failing|rejects?|rejecting|throws?|throwing|errors? out)\b/i; +const CALLEE_CALL_SHAPE = /[\w.$]+\s*\(\s*\)|`[\w.$]+\(/; +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, returning the family it belongs to or null. + * + * Deliberately requires TWO independent signals per family -- a subject and a consequence -- because + * either alone is ordinary review language. "This breaks the build" is a normal thing to say about + * code in the diff; "other modules import this" is a normal aside. Only together do they describe a + * consequence in a file nobody showed the model. + */ +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; +} + +// A full git object id: `uses: owner/action@<40 hex>` pins, and any version beside it is a comment. +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)); +} + +// A step pinned to a full SHA resolves by SHA, and the trailing `# v7.0.0` is never read, so "v7.0.0 does not exist" is not a defect there. +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); +} + +type PresenceEntry = { line: DiffLine; hunkIndex: number; code: string }; + +export type PresenceIndex = { + byToken: Map; + entries: PresenceEntry[]; + // new-file line number -> hunk index, so "same hunk" is answerable for the anchor line. + 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: DiffLine }; + +type CommentSyntax = { line: readonly string[]; block: boolean }; + +// By extension: `//` is floor division in Python, `#` a private field in JS. Guessing truncates code. +export function commentSyntaxFor(path: string): CommentSyntax { + const ext = path.toLowerCase().split('.').pop() ?? ''; + if (ext === 'py' || ext === 'rb' || ext === 'sh' || ext === 'yaml' || ext === 'yml' || ext === 'toml') { + return { line: ['#'], block: false }; + } + if (ext === 'sql') return { line: ['--'], block: true }; + return { line: ['//'], block: true }; +} + +// Returns `null` when unscannable, biasing to `unknown`. Do NOT add cross-line state without a desync test: dropping real code silently is worse than giving up. +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; +} + +// Keeps `${...}` interiors and discards the literal text around them. +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; +} + +const TOKEN_PATTERN = /[A-Za-z_$][\w$]*/g; + +export function buildPresenceIndex(file: FileDiff): PresenceIndex { + const syntax = commentSyntaxFor(file.path); + const byToken = new Map(); + const entries: PresenceEntry[] = []; + const hunkByLine = new Map(); + + file.hunks.forEach((hunk, hunkIndex) => { + for (const line of hunk.lines) { + if (line.newLineNumber !== undefined) hunkByLine.set(line.newLineNumber, hunkIndex); + + // A removed line cannot prove presence: deletion is consistent with the claim. + if (line.kind === 'del') continue; + + const code = stripCommentsAndStrings(normalizeDiffText(line.content), syntax); + if (code === null) continue; + + const entry: PresenceEntry = { line, hunkIndex, code }; + entries.push(entry); + + for (const match of code.matchAll(TOKEN_PATTERN)) { + const token = match[0]; + const existing = byToken.get(token); + if (existing) existing.push(entry); + else byToken.set(token, [entry]); + } + } + }); + + return { byToken, entries, hunkByLine }; +} + +const SIMPLE_IDENTIFIER = /^[A-Za-z_$][\w$]*$/; +const DOTTED_IDENTIFIER = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/; + +// Delimited code spans only: prose would yield `days` and refute against any unrelated use. +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'; + // Two plausible identifiers means we cannot tell which one the claim is about, and refuting the wrong one is unsound. + 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 { + // Bounded so a long body cannot turn this into a CPU problem inside a 10ms-budget Worker. + 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' }; + + // Tried per sentence: TITLE usually gives the shape, BODY the identifier; ambiguity short-circuits rather than hunting for a tidier sentence. + 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' }; + + // Proximity: without it "X is not passed to f()" is refuted by an unrelated X hundreds of lines away. + const anchorHunk = input.anchorLine !== undefined ? input.index.hunkByLine.get(input.anchorLine) : undefined; + const nearby = occurrences.find((entry) => { + if (anchorHunk !== undefined && entry.hunkIndex === anchorHunk) return true; + if (input.anchorLine === undefined || entry.line.newLineNumber === undefined) return false; + return Math.abs(entry.line.newLineNumber - input.anchorLine) <= PROXIMITY_WINDOW_LINES; + }); + + if (!nearby) return { status: 'unknown', reason: 'out_of_window' }; + return { status: 'refuted', identifier, line: nearby.line }; +} diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts new file mode 100644 index 00000000..bb99f459 --- /dev/null +++ b/packages/core/src/diff/index.ts @@ -0,0 +1,295 @@ +import picomatch from 'picomatch'; +import type { RepoConfig } from '@codra/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; +} + +// The b-side path from `diff --git a/ b/`. Splitting on the LAST space breaks on `a/my file.ts b/my file.ts` (space in filename), which wedged jobs in a review -> finalize loop. +// A symmetric `a/X b/X` split handles spaces correctly since both sides match unless renamed; only a rename falls back to the first ` b/`. +export function parseDiffHeaderPath(line: string) { + const rest = line.slice('diff --git '.length); + + if (rest.startsWith('a/')) { + // len(X) for a symmetric "a/X b/X": total = 2 + n + 1 + 2 + n. + 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); +} + +// One entry of GitHub's `/pulls/{n}/files` response, narrowed to what we use. +export type GitHubDiffFileEntry = { + filename: string; + previous_filename?: string | null; + status?: string; + // Absent for binary files and ones GitHub considers too large to patch. + patch?: string | null; +}; + +// Rebuilds unified-diff text from GitHub's per-file JSON, because the diff media type returns 406 `too_large` past 20,000 lines with nothing to retry. Emitting text keeps `parseUnifiedDiff` the one format reader everywhere. +// Headers match real git output, including the mode lines that set `isNew`/`isDeleted` (`/dev/null` alone would not). +export function buildUnifiedDiffFromFiles(files: GitHubDiffFileEntry[]): 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}`); + } + + // No patch means binary or declined. Say so in the form the parser knows, or the file silently disappears and reads as reviewed-and-clean. + 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` : ''; +} + +// `maxFiles` is passed in, not read from repo config, because the subrequest ceiling and provider rate limit it protects are instance-wide, shared across repositories. +// Returns `skipped` so callers can say "100 of 106" instead of reporting a partial review as complete. +export function filterReviewableFiles( + files: FileDiff[], + config: RepoConfig['review'], + maxFiles: number, +): { 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)); + + return { + files: reviewable.slice(0, maxFiles), + skipped: Math.max(0, reviewable.length - maxFiles), + }; +} diff --git a/src/server/core/diff/position.ts b/packages/core/src/diff/position.ts similarity index 100% rename from src/server/core/diff/position.ts rename to packages/core/src/diff/position.ts diff --git a/src/server/core/finding-gates.ts b/packages/core/src/finding-gates.ts similarity index 96% rename from src/server/core/finding-gates.ts rename to packages/core/src/finding-gates.ts index 2cd4057e..dae66a9c 100644 --- a/src/server/core/finding-gates.ts +++ b/packages/core/src/finding-gates.ts @@ -1,11 +1,9 @@ import type { FindingDisposition, ParsedReviewComment, RepoConfig } from '@codra/schema'; import type { FileDiff } from './diff'; -import type { ModelService } from '../services/model'; -import { renderDiffSnippet, parseVerifyResponse, type VerifyCandidate } from '../prompts/verify'; +import type { ReviewModel } from './ports'; +import { renderDiffSnippet, parseVerifyResponse, type VerifyCandidate } from './prompts/verify'; import { logger } from './logger'; -// Keep the ModelService import type-only, or it closes a cycle through core/model-output. - type VerifiableJob = { id: string }; // Scores candidate filters WITHOUT applying them. Score anywhere else and you measure sort order: "P3 never posted" (0 of 173) was really `max_comments` slicing a severity sort from the end. @@ -57,7 +55,7 @@ export async function verifyFindings(params: { config: RepoConfig; files: FileDiff[]; comments: ParsedReviewComment[]; - model: Pick; + model: Pick; maxCandidates?: number; }): Promise { const { comments, files, model, config, job } = params; diff --git a/packages/core/src/fingerprint.ts b/packages/core/src/fingerprint.ts new file mode 100644 index 00000000..bf809eb5 --- /dev/null +++ b/packages/core/src/fingerprint.ts @@ -0,0 +1,55 @@ +// Stable identifiers for a finding: `fingerprint` answers "is this the same finding?" (path + normalized title), `anchorHash` answers "has the code under it changed?" (content of the anchored line). Kept separate: a combined hash answers neither. + +// FNV-1a 32-bit hex. A dedupe key, not a security boundary; synchronous, so not `crypto.subtle`. +export function fnv1a32Hex(input: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + // hash *= 16777619, via shifts to stay in 32-bit integer math. + hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0; + } + return hash.toString(16).padStart(8, '0'); +} + +// The gutter strip is load-bearing: models quoting "verbatim" copy part of the ` 12 14 +` prefix. Whitespace is collapsed, not removed, so `a + b` and `a+b` stay distinct. +export function normalizeDiffText(input: string): string { + return input + .replace(/^\s*\d*\s+\d*\s*[+\- ]?/, '') + .replace(/\s+/g, ' ') + .trim(); +} + +// Typographic folding for matching a model's evidence quote: models retype rather than copy, and an unmatched curly quote is fatal. +// NEVER fold this into `normalizeDiffText` -- `buildAnchorHash` builds on that, so widening it re-hashes every affected anchor and re-raises findings suppression had already retired. +export function foldEvidenceText(input: string): string { + return normalizeDiffText(input) + .replace(/[‘’‚‛′]/g, "'") + .replace(/[“”„‟″]/g, '"') + .replace(/[‐-―−]/g, '-') + .replace(/…/g, '...'); +} + +// Normalized finding title, shared with the in-memory dedupe so both agree on identity. +export function normalizeFindingTitle(title: string): string { + return title.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); +} + +// Keep the NUL as an ESCAPE, never a literal control character: as a raw byte it made git and the GitHub API classify this file as *binary*. +// Changing this input resets every cross-run suppression and unmatches stored comment_feedback dismissals, re-posting findings a human deleted. Pinned by test/claim-types.spec.ts; must not move. +export function buildFindingFingerprint(path: string, title: string): string { + return fnv1a32Hex(`${path}\u0000${normalizeFindingTitle(title)}`); +} + +// A second identity, OR-matched with v1, because models reword titles and v1 missed most repeats. Inputs are machine-derived, so they don't move when the prose does; hashing the flagged line's CONTENT means editing that line re-raises the finding. Additive on purpose -- folding it into v1 would carry the reset cost described above. +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 cb0ff5c3..f83035ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,41 @@ -export {}; +// @codra/core -- the review engine. +// +// Depends on @codra/schema and its own ports, and on nothing else: no HTTP framework, no database +// driver, no platform bindings, no git-provider SDK. Everything environment-specific arrives through +// the ReviewRuntime a host assembles (see ./ports). +// +// The entrypoint is `runReview(runtime, message)`, which runs one phase of one review job. See its +// doc comment for the driver contract. +export { + runReview, + type ReviewJobRunResult, + // Phase plumbing the driver needs: the inter-phase sleep floor and the transition signal. + FRESH_INVOCATION_YIELD_SECONDS, + NextPhaseError, + failJobAndCheckRun, + // Trigger detection, for a host that receives webhooks before it has a job. + extractReviewRequest, + type ReviewRequest, + // Diff access, shared with hosts that surface a finished job's diff. + getDiffFiles, + getOrFetchRawDiffForCompletedJob, + // Budget and packing, exposed because they are the engine's documented capacity model. + 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, + // The verification gate, used directly by finding-quality suites. + verifyFindings, + type VerifyDrop, + type VerifyOutcome, +} from './review'; + +export type * from './ports'; diff --git a/packages/core/src/logger.ts b/packages/core/src/logger.ts new file mode 100644 index 00000000..5c3061aa --- /dev/null +++ b/packages/core/src/logger.ts @@ -0,0 +1,125 @@ +// The transport-agnostic half of the logger: secret scrubbing, redaction and record shaping. +// +// The request-context half lives in src/server/core/logger.ts, because it needs +// node:async_hooks AsyncLocalStorage, which is a platform assumption this package must not make. +// That module wires itself in here via setLoggerSink at import scope. + +/** + * 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', +]; + +// A JWT: three base64url segments, the first being base64 of `{"...` so it always starts `eyJ`. +// Anchoring on that is what keeps this from matching ordinary prose. +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; + +// Scrubs secrets OUT OF a string rather than discarding the whole string. The previous test, "contains exactly two periods", deleted messages with two dots (e.g. file paths) while missing real JWTs, protecting nothing. +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); + // Error instances don't expose name/message/stack as own enumerable properties, so Object.entries() would serialize them to {}. + 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; +} + +// Shapes one log line. `contexts` are spread in order, so a later one wins -- callers pass the +// ambient request context first and the logger's own bound context second, matching what +// src/server/core/logger.ts did inline before the split. +// `message` and every context object go through redaction too: scrubbing only `data` left unscrubbed paths to the same log line. +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) } : {}), + }; +} + +// The fallback sink, used until a host installs its own. Mirrors the server logger's console +// routing so output is identical whether or not the wiring ran. +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; +} + +// Indirects through `sink` on every call rather than capturing it, so installing a sink after this +// module has already been imported still takes effect. +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/src/server/core/model-output/batch.ts b/packages/core/src/model-output/batch.ts similarity index 99% rename from src/server/core/model-output/batch.ts rename to packages/core/src/model-output/batch.ts index 5022b62b..786b23b2 100644 --- a/src/server/core/model-output/batch.ts +++ b/packages/core/src/model-output/batch.ts @@ -1,7 +1,7 @@ // Splits one batched response into per-file reviews, then grounds each through the same groundParsedFindings the single-file path uses. import type { ClaimType } from '@codra/schema'; import type { FileDiff } from '../diff'; -import { generatorFindingCap } from '@server/prompts/file-review'; +import { generatorFindingCap } from '../prompts/file-review'; import { logger } from '../logger'; import { buildBinAmbiguityIndex } from './evidence'; import { type GroundedFileReview, groundParsedFindings, samePath } from './index'; diff --git a/src/server/core/model-output/dedupe.ts b/packages/core/src/model-output/dedupe.ts similarity index 100% rename from src/server/core/model-output/dedupe.ts rename to packages/core/src/model-output/dedupe.ts diff --git a/src/server/core/model-output/evidence.ts b/packages/core/src/model-output/evidence.ts similarity index 100% rename from src/server/core/model-output/evidence.ts rename to packages/core/src/model-output/evidence.ts diff --git a/packages/core/src/model-output/index.ts b/packages/core/src/model-output/index.ts new file mode 100644 index 00000000..4ae5a1a3 --- /dev/null +++ b/packages/core/src/model-output/index.ts @@ -0,0 +1,436 @@ +import { + fileReviewModelOutputSchema, + parsedReviewCommentSchema, + toClaimType, + CLAIM_TYPE_CATEGORY, + type ClaimType, + type ParsedReviewComment, + reviewSeverities, +} from '@codra/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'; + +// Tolerates the prefix noise models add to paths (`./src/a.ts`, `b/src/a.ts`, `/src/a.ts`). +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; + // Path of the entry enclosing the finding being grounded. + 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\`\`\``; +} + +// Relabels an `other` finding when its vocabulary is unmistakable, so the denylist can see it. Deliberately excludes react_missing_cleanup/resource_leak/null_or_undefined_deref: that vocabulary also appears in legitimate `other` findings. +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}`; + + // Version claims arrive labelled `other`, and every one in the corpus has been false. + 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]; + +// Dropped finding for the off-diff list. Only the position-validation drop omits `tag`. +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}`; +} + +// Stage 2: resolve the evidence quote against the diff; only a match passes, on every provider. +// unmatched = discriminating but absent, weak = under 8 normalized chars, absent = no quote. +function groundFindingInEvidence( + finding: RawFinding, + evidenceIndex: EvidenceIndex, + evidenceStats: { total: number; matched: number; unmatched: number; weak: number; absent: 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}` } }; + } + + // Batch path only: a quote shared across packed files PLUS a mismatched claimed path means a misfiled finding. Either signal alone is ordinary. + 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', + }, + }; + } + } + + // Anchor comes from the matched quote; `code_location.line` only disambiguates repeated lines. + return { diffLine: evidence.line }; +} + +// Stage 3: anchors a grounded evidence line to a concrete, postable diff position. +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 }; +} + +// Stage 4: normalize raw priority/title/body, independent of evidence and claim-type decisions. +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', + }; + // Missing priority falls back to P3 rather than dropping a possible P0. + 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 }; +} + +// Stage 5: resolve the claim type, then enforce the denylist and pinned-SHA refutation. Counts update BEFORE the deny check, or a working denylist would tally identically to an idle one. +function applyClaimGate( + finding: RawFinding, + title: string, + body: string, + anchorContent: string, + deniedClaimTypes: Set, + claimTypeCounts: Record, + deniedClaimCounts: Record, +): { claimType: ClaimType } | { withheld: Withheld } { + // Coerce to 'other' rather than throw: a Zod rejection discards the whole file over one bad label. + 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}` } }; + } + + // A full commit SHA pin refutes a version claim outright. + 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' } }; + } + + // Claims whose consequence lives in a file, framework or engine version the model was never shown. + // Counted under its own key and tagged distinctly, so every suppression stays auditable in the + // off-diff list rather than vanishing -- a wrong refutation must be findable. + 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 }; +} + +// Stage 6: assemble the persisted comment. Absence-check stats are SHADOW: counted, never acted on. Promote to a drop only once `refuted` is non-zero on real claims and the gold set passes. +function buildParsedComment(params: { + file: FileDiff; + line: number; + position: number; + severity: typeof reviewSeverities[number]; + title: string; + body: string; + claimType: ClaimType; + anchorContent: string; + finding: RawFinding; + presenceIndex: ReturnType; + absenceCheckStats: { absenceShaped: number; identifierExtracted: number; refuted: number }; +}): ParsedReviewComment { + const { file, line, position, severity, title, body, claimType, anchorContent, finding, presenceIndex, absenceCheckStats } = params; + + const absence = checkAbsenceClaim({ title, body, anchorLine: line, index: presenceIndex }); + if (absence.status === 'refuted') { + absenceCheckStats.absenceShaped += 1; + absenceCheckStats.identifierExtracted += 1; + absenceCheckStats.refuted += 1; + } else if (absence.reason !== 'not_absence_shaped') { + absenceCheckStats.absenceShaped += 1; + if (absence.reason !== 'no_identifier' && absence.reason !== 'ambiguous_identifier') { + absenceCheckStats.identifierExtracted += 1; + } + } + + // Never `undefined`: the gate fires on typeof==='number', so an omission would sail past it. + const confidenceScore = typeof finding.confidence_score === 'number' + ? finding.confidence_score + : 0; + + // An empty or whitespace-only suggestion means "no suggestion", not "discard this finding" -- but + // `codeSuggestion` is `z.string().min(1)`, so passing `""` straight through threw a ZodError and the + // catch below binned the whole comment as `unverified:unassemblable`. Measured across an 800-review + // sweep: 256 findings destroyed this way, including real ones (a hardcoded-secret P1 among them). + // `evidence` on the next line has always had this guard; this field simply never got it. + const codeSuggestion = typeof finding.code_suggestion === 'string' && finding.code_suggestion.trim() + ? finding.code_suggestion + : undefined; + + return parsedReviewCommentSchema.parse({ + path: file.path, + line, + position, + severity, + // Derived, never model-emitted: asking produced 'quality' on all 705 rows. + category: CLAIM_TYPE_CATEGORY[claimType], + claimType, + // Unrecoverable later: 003 nulls diff_input and the KV diff cache expires after 6h. + 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, + // Title-independent identity, OR-matched with the first so a reworded repeat is still recognised. + fingerprintV2: buildFindingFingerprintV2( + file.path, + claimType, + anchorContent ? buildAnchorHash(anchorContent) : undefined, + ) ?? undefined, + }); +} + +// One file's worth of extracted output, so the batch path can hand-build it per file instead of going through the single-file `parseRawPayload`. +export type FileReviewPayload = z.infer; + +export type GroundingOptions = { + // Rejected outright. Enforced here, not in the grammar: only Workers AI and Google AI Studio honor the schema. + deniedClaimTypes?: readonly ClaimType[]; + // Batch path only. + ambiguity?: BinAmbiguity; +}; + +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 }; + claimTypeCounts: Record; + // Denied per type; these also appear in `claimTypeCounts`. + deniedClaimCounts: Record; + // Absence-check funnel, shadow-only; three counters keep refuted:0 distinct from "never fired". + absenceCheckStats: { absenceShaped: number; identifierExtracted: number; refuted: number }; +}; + +// Grounding is per file, never per response: the indexes come from one `FileDiff`. Split out of `parseFileReviewResponse` so batches can reuse it per file. +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 }; + const claimTypeCounts: Record = {}; + const deniedClaimCounts: Record = {}; + const deniedClaimTypes = new Set(options?.deniedClaimTypes ?? []); + const presenceIndex = buildPresenceIndex(file); + 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); + + // Anchor on content, not line number: an edit above shifts it, an edit TO the line must re-raise. + 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; + } + + // Contained per finding: under batching, propagating would discard the rest of the bin. + try { + return buildParsedComment({ + file, + line: anchored.line, + position: anchored.position, + severity, + title, + body, + claimType: gated.claimType, + anchorContent, + finding, + presenceIndex, + absenceCheckStats, + }); + } catch (error) { + // ZodError only: a wider catch would swallow systemic failures. + 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, + }; +} + +// Provider-independent by design: gating these on a Cloudflare-only flag once disabled the evidence gate and min_confidence on the Google chain. +export function parseFileReviewResponse( + raw: string, + file: FileDiff, + options?: GroundingOptions, +): GroundedFileReview { + return groundParsedFindings(parseRawPayload(raw), file, options); +} + + +export { dedupeFindings } from './dedupe'; +export { + isNonAnswerReview, + NON_ANSWER_MAX_RESPONSE_CHARS, + NON_ANSWER_MIN_DIFF_LINES, +} from './non-answer'; +export { parseRawBatchPayload, type RawBatchPayload } from './json-batch'; +export { parseBatchReviewResponse, type BatchParseStats, type BatchReviewResult } from './batch'; diff --git a/src/server/core/model-output/json-batch.ts b/packages/core/src/model-output/json-batch.ts similarity index 100% rename from src/server/core/model-output/json-batch.ts rename to packages/core/src/model-output/json-batch.ts diff --git a/src/server/core/model-output/json.ts b/packages/core/src/model-output/json.ts similarity index 100% rename from src/server/core/model-output/json.ts rename to packages/core/src/model-output/json.ts diff --git a/src/server/core/model-output/non-answer.ts b/packages/core/src/model-output/non-answer.ts similarity index 100% rename from src/server/core/model-output/non-answer.ts rename to packages/core/src/model-output/non-answer.ts diff --git a/packages/core/src/ports/file-reviews.ts b/packages/core/src/ports/file-reviews.ts new file mode 100644 index 00000000..4e51bb0b --- /dev/null +++ b/packages/core/src/ports/file-reviews.ts @@ -0,0 +1,148 @@ +import type { ParsedReviewComment } from '@codra/schema'; + +// Per-file review persistence. Mirrors src/server/db/file-reviews{,-bulk,-findings}.ts minus `env`. + +/** + * A file_reviews row as returned by `getFileReviewsForJobs`, with the two JSON columns already + * decoded. Defined here rather than imported because it is a raw-column shape with no schema + * counterpart, and the review phase copies nearly every field through `upsertFileReview` when it + * inherits a parent job's reviews. + */ +export type FileReviewRow = { + 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[]; + 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 }; + // NULL pre-batching; 1 reviewed alone, N for a packed bin. + batch_size: number | null; +}; + +export type SuppressedFinding = { + fingerprint: string | null; + // Null for repo-wide rejections, which suppress regardless of what the code now says. + anchor_hash: string | null; + // Title-independent identity; already includes the anchor, so it needs no separate anchor check. + fingerprint_v2: string | null; + // True when this came from an earlier posted comment rather than from human rejection. + anchored: boolean; +}; + +export type BulkFileReviewInput = { + filePath: string; + fileStatus: 'pending' | 'done' | 'skipped' | 'failed'; + modelUsed: string; + modelProvider?: string | null; + diffLineCount: number; + 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; + withheldCounts?: { evidence: number; claimDenied: number } | null; + // 1 for a file reviewed alone, N for a file that shared a model call with N-1 others. + batchSize: number; +}; + +/** + * Per-file review rows, the findings attached to them, and their posted/rejected bookkeeping. + * + * A correct implementation must guarantee: + * - every write is IDEMPOTENT on (jobId, filePath). A phase that dies after reviewing a file + * re-reviews it on the next invocation, so a second upsert for the same path must replace the row + * rather than adding one. This is the property that makes the whole phase re-runnable. + * - `recordRetryableFileReviewFailure` and `bulkRecordRetryableFileReviewFailures` return the + * transient failure count AFTER this attempt, and must only increment it when + * `countsAsAttempt` is not false. That flag distinguishes "the provider is down again" from "we + * advanced one step down the model chain"; conflating them burns the retry budget on progress. + * The count must never reset on its own -- MAX_RETRYABLE_FILE_REVIEW_FAILURES depends on it. + * - `getFileReviewsForJobs` returns rows in stable creation order across calls, for every jobId + * given, with `parsed_comments` and `withheld_counts` already decoded (never raw JSON strings). + * Finalize reads it to assemble the review, so an unstable order reorders posted comments. + * - `bulkInheritFileReviews` returns only the paths it actually inserted, skipping any that already + * exist. The caller treats the returned list as "these are now done" and re-reviews the rest. + * - `markCommentsPosted` and `markCommentDispositions` are additive and idempotent: re-marking an + * already-marked fingerprint is a no-op, never an error. Cross-run suppression reads these, so a + * lost write re-posts a finding a human already dismissed. + * - an empty input array is a no-op that must not touch the database or throw. + */ +export interface FileReviewStore { + upsertFileReview(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 } | null; + // Async batch bookkeeping: set on submit to the Workers AI queue, cleared once the batch completes. + asyncRequestId?: string | null; + asyncModel?: string | null; + }): Promise; + + recordRetryableFileReviewFailure(jobId: string, input: { + filePath: string; + modelUsed: string; + modelProvider?: string | null; + diffLineCount: number; + diffInput: string | null; + durationMs: number | null; + errorMessage: string; + countsAsAttempt?: boolean; + }): Promise; + + getFileReviewsForJobs(jobIds: string[]): Promise; + + bulkInheritFileReviews(input: { jobId: string; parentJobId: string; filePaths: string[] }): Promise; + bulkUpsertFileReviews(jobId: string, inputs: BulkFileReviewInput[]): Promise; + bulkRecordRetryableFileReviewFailures( + jobId: string, + inputs: Array<{ filePath: string; modelUsed: string; diffLineCount: number; errorMessage: string }>, + opts?: { countsAsAttempt?: boolean }, + ): Promise>; + bulkMarkFilesFailed( + jobId: string, + files: Array<{ filePath: string; diffLineCount: number }>, + opts: { modelUsed: string; errorMessage: string }, + ): Promise; + + getSuppressedFindings(jobId: string): Promise; + markCommentsPosted(jobId: string, fingerprints: string[]): Promise; + markCommentDispositions( + jobId: string, + byFingerprint: Map, + ): Promise; +} diff --git a/packages/core/src/ports/formatter.ts b/packages/core/src/ports/formatter.ts new file mode 100644 index 00000000..8c336696 --- /dev/null +++ b/packages/core/src/ports/formatter.ts @@ -0,0 +1,21 @@ +import type { ParsedReviewComment } from '@codra/schema'; + +/** + * Renders findings into the markdown the provider will show. + * + * Four of `FormatterService`'s methods, which are the four finalize calls. The implementation is + * already pure apart from a base URL, and it stays outside the engine because the URL is deployment + * configuration. + * + * A correct implementation must be PURE and DETERMINISTIC: same finding in, same string out, no I/O, + * no clock, no randomness. Finalize is re-runnable, and a formatter whose output varied between + * invocations would make a retried finalize post text that no longer matches what was recorded -- + * and, because posted findings are tracked by fingerprint rather than by body, would do so silently. + * `summarizeVerdict` must treat `hasFailures` as decisive: a job with failed files cannot approve. + */ +export interface ReviewFormatter { + toReviewEvent(verdict: 'approve' | 'comment'): 'APPROVE' | 'COMMENT'; + summarizeVerdict(comments: ParsedReviewComment[], hasFailures: boolean): { verdict: 'approve' | 'comment'; errors: number; warnings: number }; + formatInlineComment(comment: ParsedReviewComment): string; + formatReviewOverview(commitSha: string, botUsername: string): string; +} diff --git a/packages/core/src/ports/github.ts b/packages/core/src/ports/github.ts new file mode 100644 index 00000000..5f8d86d2 --- /dev/null +++ b/packages/core/src/ports/github.ts @@ -0,0 +1,83 @@ +// The git-provider port. Named for what the engine needs, not for GitHub's API: the ten methods here +// are the entire surface the review engine touches, out of a much larger service. A second provider +// implements these ten and nothing else. +// +// The two record types are owned here and re-exported by src/server/core/github/types.ts, so there is +// exactly one definition of each. + +export type PullRequestRecord = { + number: number; + title: string | null; + body: string | null; + draft: boolean; + head: { sha: string; ref: string }; + base: { sha: string; ref: string }; + user: { login: string }; +}; + +export type GitHubReviewComment = { + path: string; + // File line to attach the comment to, paired with `side`. The model reports file lines, never diff offsets. + line?: number; + // 'RIGHT' = the head (post-change) file, which is where findings live. + side?: 'LEFT' | 'RIGHT'; + // Legacy diff-offset addressing. Kept for callers that already compute it. + position?: number; + body: string; +}; + +/** + * Reads a pull request's contents and writes the review back. + * + * Retry-safety is NOT uniform here, and callers depend on knowing which is which: + * - `getPullRequest`, `getPullRequestDiff`, `getCompareDiff` are pure reads and freely retryable. + * `getCompareDiff` must resolve the diff for the two commits GIVEN, not the current head, because + * its caller reconstructs a finished job's diff after the pull request has moved on. + * - `createCheckRun` is not idempotent; the caller stores the returned id and passes it to + * `updateCheckRun` thereafter. `updateCheckRun` IS idempotent and may be called repeatedly, + * including to re-complete an already-completed run. + * - `createReview` is NOT retry-safe: it posts. A caller that may have already posted must first ask + * `findBotReviewForCommit` and reuse what it finds. It must return `postedIndices` naming which of + * the submitted comments were actually accepted -- when the provider rejects inline comments and + * the review falls back to a body-only post, that list is empty, and reporting all of them as + * posted would suppress those findings forever. + * - `findBotReviewForCommit` must scope to the given commit sha AND bot login, and return null + * rather than throwing when there is none. + * - `ensureLabel`, `addIssueLabels`, `removeIssueLabelsIfPresent` are idempotent. Removing a label + * that is absent must succeed, not 404. + * Every method may throw; the engine classifies transient failures and reschedules. + */ +export interface ReviewGitHub { + getPullRequest(owner: string, repo: string, prNumber: number): Promise; + getPullRequestDiff(owner: string, repo: string, prNumber: number): Promise; + getCompareDiff(owner: string, repo: string, base: string, head: string): Promise; + createCheckRun(owner: string, repo: string, params: { headSha: string; title: string; summary: string }): Promise<{ id: number }>; + updateCheckRun(owner: string, repo: string, checkRunId: number, params: { + title: string; + summary: string; + status?: 'in_progress' | 'completed'; + conclusion?: 'success' | 'neutral' | 'failure' | 'cancelled'; + }): Promise; + createReview(owner: string, repo: string, prNumber: number, params: { + commitSha: string; + event: 'APPROVE' | 'COMMENT'; + body: string; + comments: GitHubReviewComment[]; + }): Promise<{ id: number; postedIndices?: number[] }>; + findBotReviewForCommit(owner: string, repo: string, prNumber: number, commitSha: string, botLogin: string): Promise<{ id: number } | null>; + ensureLabel(owner: string, repo: string, name: string, color: string): Promise; + addIssueLabels(owner: string, repo: string, prNumber: number, labels: string[]): Promise; + removeIssueLabelsIfPresent(owner: string, repo: string, prNumber: number, labels: string[]): Promise; +} + +/** + * Builds a provider client for an installation whose job row does not exist yet. + * + * Needed because webhook resolution -- label cleanup on a closed pull request, and looking up the + * pull request behind an issue comment -- happens before any job is inserted, so there is no job to + * take the installation id from. A correct implementation must be cheap enough to call per webhook + * and must not perform I/O until one of the returned methods is called. + */ +export interface GitHubClientFactory { + forInstallation(installationId: string): ReviewGitHub; +} diff --git a/packages/core/src/ports/index.ts b/packages/core/src/ports/index.ts new file mode 100644 index 00000000..c2f3cb3e --- /dev/null +++ b/packages/core/src/ports/index.ts @@ -0,0 +1,16 @@ +// The engine's ports: interfaces and data contracts only, never implementations. Every port carries a +// doc comment stating what a correct implementation must guarantee -- idempotency, ordering, +// retry-safety -- because those are the properties the engine relies on and cannot check. +// +// The dependency rule is one-way: this package may import @codra/schema and nothing else. Ports are +// implemented by hosts (src/server/adapters today, packages/{db,models,provider-github} later). + +export type { Clock, IdGenerator, KvStore, Logger } from './platform'; +export type { JobLeaseClaim, JobRow, JobStore, PersistedReviewJob } from './jobs'; +export type { BulkFileReviewInput, FileReviewRow, FileReviewStore, SuppressedFinding } from './file-reviews'; +export type { LearningStore, ModelConfigReader, RepoConfigLoader, ReviewSettingsReader, WebhookDeliveryReader } from './settings'; +export type { GitHubClientFactory, GitHubReviewComment, PullRequestRecord, ReviewGitHub } from './github'; +export type { FileReviewOutcome, ModelErrorClassifier, ModelResponse, ModelResponseSchema, ReviewModel } from './model'; +export type { ReviewFormatter } from './formatter'; +export type { ReviewTelemetryEvent, TelemetrySink } from './telemetry'; +export type { ReviewRuntime } from './runtime'; diff --git a/packages/core/src/ports/jobs.ts b/packages/core/src/ports/jobs.ts new file mode 100644 index 00000000..39bf6dec --- /dev/null +++ b/packages/core/src/ports/jobs.ts @@ -0,0 +1,130 @@ +import type { JobSummary, RepoConfig } from '@codra/schema'; + +// Job persistence. Method signatures mirror src/server/db/{jobs,jobs-leases,jobs-lifecycle}.ts +// exactly, minus the leading `env` parameter, which the adapter closes over. + +/** + * A job as the engine sees it. + * + * This is `JobSummary` rather than a hand-copied shape, and that is not a convenience: `mapJob` ends + * in `jobSummarySchema.parse(...)`, and `.parse` strips unknown keys, so `ReturnType` + * IS this type. A field added to the mapper cannot widen it, and a field added to the schema widens + * both sides together -- there is no channel for the two to drift. src/server/adapters/jobs-store.ts + * carries a compile-time assertion pinning the equality. + */ +export type PersistedReviewJob = JobSummary; + +/** + * The raw jobs row, before `mapJob` decodes it. + * + * The engine reads exactly two columns off it -- `status`, to detect a job superseded mid-flight, and + * `check_run_id`, to reconcile a check run on the failure path -- and otherwise only hands the row + * straight back to `mapJob`. The index signature is what lets the db layer's own row type satisfy + * this without core knowing the other forty columns exist. + */ +export type JobRow = { + status: 'queued' | 'running' | 'done' | 'failed' | 'superseded' | 'cancelled' | 'stopped'; + check_run_id: number | null; + [column: string]: unknown; +}; + +export type JobLeaseClaim = + | { status: 'claimed'; row: JobRow } + | { status: 'busy'; row: JobRow; retryAfterSeconds: number } + | { status: 'terminal'; row: JobRow } + | { status: 'missing' }; + +/** + * Job rows and the lease that makes a phase safe to re-run. + * + * A correct implementation must guarantee: + * - `claimJobLease` is ATOMIC. Two concurrent callers for the same jobId must not both receive + * 'claimed'; the loser gets 'busy'. Everything else here assumes the caller holds the lease, and + * a lease two workers can hold simultaneously means two workers reviewing and posting the same + * pull request. Claiming must also flip a 'queued' job to 'running' in the same operation. + * - `releaseJobLease` and `heartbeatJobLease` are no-ops when `leaseOwner` does not match the + * current holder. A phase that lost its lease to expiry-recovery must not be able to release the + * successor's claim. + * - `markJobContinuationQueued` returns the count AFTER incrementing, and never decreases for a + * given job except via `resetJobContinuationCount`. The two continuation ceilings are the only + * thing standing between a wedged job and an infinite reschedule loop, so an implementation that + * lost increments would loop forever. + * - every write is idempotent under retry. Each of these may be called twice for one logical step, + * because a phase that dies after the write is re-run from the top. + * - `insertJob` and `findExistingJobForHead` agree on identity: what insert stores under + * (owner, repo, prNumber, commitSha, trigger) is what find must return. + * - `mapJob` is pure and total for any row this store returned. + * Ordering between calls is the caller's business; no method may reorder or batch across calls. + */ +export interface JobStore { + mapJob(row: JobRow): PersistedReviewJob; + + getJobForProcessing(jobId: string): Promise; + claimJobLease(jobId: string, leaseOwner: string, leaseSeconds: number): Promise; + heartbeatJobLease(jobId: string, leaseOwner: string, leaseSeconds: number): Promise; + releaseJobLease(jobId: string, leaseOwner: string): Promise; + markJobContinuationQueued(jobId: string, delaySeconds?: number): Promise; + resetJobContinuationCount(jobId: string): Promise; + getOtherRunningJobsCount(excludeJobId: string): Promise; + + setJobWorkflowInstance(jobId: string, workflowInstanceId: string): Promise; + setJobPullRequestMeta(jobId: string, meta: { prTitle: string | null; prAuthor: string | null }): Promise; + insertJob(input: { + installationId: string; + owner: string; + repo: string; + prNumber: number; + prTitle: string | null; + prAuthor: string | null; + commitSha: string; + baseSha: string; + trigger: 'auto' | 'mention' | 'retry'; + headRef: string | null; + baseRef: string | null; + configSnapshot?: RepoConfig | null; + retryOfJobId?: string | null; + }): Promise; + findExistingJobForHead(input: { + owner: string; + repo: string; + prNumber: number; + commitSha: string; + trigger: 'auto' | 'mention'; + }): Promise; + + updateJobCheckRun(jobId: string, checkRunId: number): Promise; + markJobCheckRunCompleted(jobId: string): Promise; + completePreparationStep(jobId: string, fileCount: number): Promise; + updateJobStep(jobId: string, stepName: string, update: { + status: 'pending' | 'running' | 'done' | 'failed'; + startedAt?: string | null; + finishedAt?: string | null; + error?: string | null; + }): Promise; + completeJob(jobId: string, input: { + verdict: 'approve' | 'comment'; + fileCount: number; + commentCount: number; + totalInputTokens: number; + totalOutputTokens: number; + summaryMarkdown: string; + reviewId: number | null; + summaryModel: string | null; + overallConfidenceScore?: number | null; + errorMessage?: string | null; + }): Promise; + /** + * Marks the job terminal. This is a MUST-NOT-LOSE write: it is what stops the queue redelivering + * the job forever, and what makes it eligible for check-run reconciliation afterwards. An + * implementation that can fail must fail loudly rather than silently no-op. + */ + failJob(jobId: string, errorMessage: string): Promise; + /** Returns how many older jobs were superseded. Must not supersede `newJobId` itself. */ + supersedeOlderJobs(input: { + installationId: string; + owner: string; + repo: string; + prNumber: number; + newJobId: string; + }): Promise; +} diff --git a/packages/core/src/ports/model.ts b/packages/core/src/ports/model.ts new file mode 100644 index 00000000..ad53e2b0 --- /dev/null +++ b/packages/core/src/ports/model.ts @@ -0,0 +1,120 @@ +// The model port. Implementations live in src/server/services/model.ts (and, later, +// packages/models) -- nothing here may reach for a provider SDK or an API key. +import type { RepoConfig } from '@codra/schema'; +import type { FileDiff } from '../diff'; +import type { BatchReviewResult, parseFileReviewResponse } from '../model-output'; +import type { RejectedExemplar } from '../prompts/file-review'; +import type { VerifyCandidate } from '../prompts/verify'; + +type ParsedFileReview = ReturnType; + +/** + * One model call's result. `degraded: 'schema-dropped'` means the provider rejected the structured + * output grammar and the call ran unconstrained but succeeded, so the caller must be prepared to + * parse free-form text. + */ +export type ModelResponse = { + rawText: string; + inputTokens: number; + outputTokens: number; + modelUsed: string; + provider: string; + // Grammar rejected, so the call ran unconstrained but succeeded. Read by services/model.ts and `/models/:id/test`. + degraded?: 'schema-dropped'; +}; + +// Honored only by Workers AI and Google AI Studio -- not by `vertex`, despite it serving the same Gemini models. +export type ModelResponseSchema = { + name: string; + schema: Record; +}; + +/** One file's review, as the runner receives it: the raw call plus the grounded parse. */ +export type FileReviewOutcome = ModelResponse & { + parsed: ParsedFileReview; + reviewedLineCount: number; + wasPromptTruncated: boolean; + userPrompt: string; +}; + +/** + * Runs review prompts against whatever model chain the host has configured. + * + * The engine deliberately knows nothing about model selection, fallback order, rate limits or + * provider quirks -- all of that is the implementation's business. What it does depend on: + * - every method may throw, and the implementation must make transient failures DISTINGUISHABLE + * from permanent ones via `ModelErrorClassifier` below. Misclassifying a permanent failure as + * transient wedges the job until its continuation ceiling; the reverse fails a job that would + * have succeeded on retry. + * - `reviewFile` and `reviewFiles` must be safe to call again after a failure. They are pure + * request/response as far as the engine is concerned: no state carries between calls except + * whatever chain-resume bookkeeping the implementation keeps. + * - `reviewFiles` returns a result whose `batch.missing` names files the model did not answer for. + * Those must NOT be reported as reviewed; the caller re-runs them individually. + * - `submitReviewBatch` returns null when async batching is unusable for this model, and the caller + * falls back to `reviewFile`. Returning a requestId commits to `pollReviewBatch` being able to + * resolve it in a LATER Worker invocation -- the id is persisted, so it must not be tied to + * in-memory state. + * - `pollReviewBatch` must be safe to call repeatedly for the same requestId, returning 'pending' + * until the batch resolves. It must never block. + * - token counts on the response must reflect what the call actually consumed; the budget and the + * per-job totals are computed from them. + */ +export interface ReviewModel { + reviewFile(params: { + file: FileDiff; + prTitle: string | null; + prDescription: string | null; + config: RepoConfig; + totalLineCount: number; + compactPrompt?: boolean; + rejectedExemplars?: readonly RejectedExemplar[]; + }): Promise; + + reviewFiles(params: { + files: readonly FileDiff[]; + prTitle: string | null; + prDescription: string | null; + config: RepoConfig; + totalLineCount: number; + rejectedExemplars?: readonly RejectedExemplar[]; + }): Promise; + + submitReviewBatch(params: { + file: FileDiff; + prTitle: string | null; + prDescription: string | null; + config: RepoConfig; + totalLineCount: number; + compactPrompt?: boolean; + }): Promise<{ requestId: string; model: string } | null>; + + pollReviewBatch(params: { model: string; requestId: string; file: FileDiff; config: RepoConfig }): Promise< + | { status: 'pending' } + | { status: 'done'; response: FileReviewOutcome } + | { status: 'failed'; error: unknown } + >; + + verifyFindings(params: { candidates: VerifyCandidate[]; config: RepoConfig }): Promise; +} + +/** + * Classifies a thrown model/provider error. + * + * Kept as a port rather than moved into the engine even though both functions are pure predicates: + * five specs substitute them by mocking the '@server/services/model' specifier, and pulling them in + * here would void those mocks silently while the tests kept passing. + * + * A correct implementation must be: + * - total: any value may be passed, including non-Errors, and neither method may throw. + * - deterministic for a given error. The retry-delay ladder and the chain-advance memo are both + * derived from these answers across separate invocations, so an answer that changed between calls + * would produce an inconsistent retry plan. + * - conservative about `isRetryableModelError`: only return true when a later attempt has a real + * chance of succeeding. `nextChainIndexOf` returns the index to resume the fallback chain at, or + * null when the failure says nothing about chain position. + */ +export interface ModelErrorClassifier { + isRetryableModelError(error: unknown): boolean; + nextChainIndexOf(error: unknown): number | null; +} diff --git a/packages/core/src/ports/platform.ts b/packages/core/src/ports/platform.ts new file mode 100644 index 00000000..de563efd --- /dev/null +++ b/packages/core/src/ports/platform.ts @@ -0,0 +1,48 @@ +// Platform primitives the engine refuses to reach for as globals, so a caller can make a review +// deterministic (fixed clock, fixed ids) or run it with no key-value store at all. + +/** + * A best-effort string cache, satisfied structurally by Cloudflare's KVNamespace. + * + * A correct implementation must: + * - treat every entry as expendable. `get` returning null is always legal, for any key, at any + * time, including immediately after a successful `put` -- the engine re-derives the value. + * - never throw from `get`. A read failure must surface as null, not an exception, because the + * only caller (diff-cache) treats a miss as normal and a throw as a job failure. + * - honour `expirationTtl` in seconds if it can, and ignore it if it cannot. `put` MAY throw; the + * engine catches and continues, so a full or read-only store degrades to re-fetching. + * Reads need not be strongly consistent, and writes need not be visible to a concurrent reader. + */ +export interface KvStore { + get(key: string): Promise; + put(key: string, value: string, options?: { expirationTtl?: number }): Promise; +} + +/** + * Wall-clock time in epoch milliseconds, satisfied by `{ now: () => Date.now() }`. + * + * A correct implementation must be non-decreasing within one phase: the file runner and the phase + * loop both compute elapsed time by subtracting two `now()` readings, and a clock that went + * backwards would produce a negative duration and, worse, hide a breach of the 12-minute + * REVIEW_CHUNK_WALL_CLOCK_MS budget that exists to keep the phase inside its invocation limit. + * It need not be monotonic ACROSS phases -- each phase re-reads it from scratch. + */ +export interface Clock { + now(): number; +} + +/** + * Opaque unique identifiers, satisfied structurally by `globalThis.crypto`. + * + * A correct implementation must never return the same value twice for the lifetime of the + * deployment. The engine's one caller mints a job lease owner with it, and two workers agreeing on + * a lease owner string would let both believe they hold the same job's lease -- the one failure this + * whole locking scheme exists to prevent. Values need not be UUID-shaped, sortable, or unguessable. + */ +export interface IdGenerator { + randomUUID(): string; +} + +// Re-exported so `@codra/core/ports` is the single place a host looks for the contracts it must +// implement, even though the interface itself has to live next to the scrubbing it constrains. +export type { Logger } from '../logger'; diff --git a/packages/core/src/ports/runtime.ts b/packages/core/src/ports/runtime.ts new file mode 100644 index 00000000..1061ca8e --- /dev/null +++ b/packages/core/src/ports/runtime.ts @@ -0,0 +1,56 @@ +import type { TokenTracker } from '../token-tracker'; +import type { Clock, IdGenerator, KvStore } from './platform'; +import type { FileReviewStore } from './file-reviews'; +import type { GitHubClientFactory, ReviewGitHub } from './github'; +import type { JobStore } from './jobs'; +import type { ModelErrorClassifier, ReviewModel } from './model'; +import type { ReviewFormatter } from './formatter'; +import type { LearningStore, ModelConfigReader, RepoConfigLoader, ReviewSettingsReader, WebhookDeliveryReader } from './settings'; +import type { TelemetrySink } from './telemetry'; + +/** + * Everything the review engine needs from the outside world. This single object is what replaced + * `env: AppBindings`, and assembling one is the whole job of a host: see + * src/server/adapters/index.ts for the Cloudflare/Postgres/GitHub implementation, and + * packages/core/test/in-memory.ts for a complete in-memory one. + * + * A correct runtime must be CHEAP TO CONSTRUCT and hold no per-job state: one is built per Worker + * invocation, before the job is known, and a phase that dies is re-run against a fresh one. + * Everything job-scoped is created through the factories below, after the lease is claimed. + */ +export interface ReviewRuntime { + kv: KvStore; + clock: Clock; + ids: IdGenerator; + + /** + * The bot's own login, used to find its previous review on a retried finalize and to attribute the + * review overview. Must match the account the `github` port posts as, or finalize will fail to + * recognise its own earlier comment and post a duplicate. + */ + botUsername: string; + + jobs: JobStore; + fileReviews: FileReviewStore; + settings: ReviewSettingsReader; + webhooks: WebhookDeliveryReader; + learning: LearningStore; + modelConfigs: ModelConfigReader; + repoConfig: RepoConfigLoader; + telemetry: TelemetrySink; + + /** + * Job-scoped collaborators. Factories rather than instances because all four are built per phase, + * after the job row is claimed, and because the github and model ports must share ONE + * TokenTracker -- the subrequest budget that decides how many files a phase attempts counts both + * provider calls and model calls, so two trackers would let a phase overrun its invocation limit. + */ + createTokenTracker(): TokenTracker; + createGitHub(installationId: string, tracker: TokenTracker): ReviewGitHub; + createModel(jobId: string, tracker: TokenTracker): ReviewModel; + createFormatter(): ReviewFormatter; + + /** For webhook resolution, which runs before any job row exists. */ + githubClients: GitHubClientFactory; + modelErrors: ModelErrorClassifier; +} diff --git a/packages/core/src/ports/settings.ts b/packages/core/src/ports/settings.ts new file mode 100644 index 00000000..928e8f7a --- /dev/null +++ b/packages/core/src/ports/settings.ts @@ -0,0 +1,72 @@ +import type { ClaimType, RepoConfig, ReviewSettings } from '@codra/schema'; + +/** + * Instance-wide review settings (concurrency level, file caps). + * + * A correct implementation must always return a complete, valid `ReviewSettings` -- defaults when + * nothing is stored, never a partial object and never a throw. The engine reads this on the admission + * path, so a failure here rejects a job that should have run. It MAY cache: callers already assume + * one lookup serves a whole phase, and a setting changed mid-review taking effect on the next phase + * is the intended behaviour. + */ +export interface ReviewSettingsReader { + getReviewSettings(): Promise; +} + +/** + * Per-repository configuration (the committed `.codra.json`, merged over defaults). + * + * A correct implementation must guarantee: + * - the return is always a fully-populated `RepoConfig`, defaults included. Callers index into + * `parsedJson.review` without checking, and a partial config silently disables gates. + * - `enabled: false` means "this repo has opted out"; absence of any record means enabled. + * - it is safe to call repeatedly for the same repo within a phase. Caching is expected; the cache + * need not be invalidated mid-review. + */ +export interface RepoConfigLoader { + loadRepoConfig(input: { installationId: string; owner: string; repo: string }): Promise<{ parsedJson: RepoConfig; enabled: boolean }>; +} + +/** + * The model catalogue, narrowed to the one field the engine needs. + * + * Deliberately NOT the full `ResolvedModelConfig`: that carries `encryptedApiKey`, and a credential + * has no business crossing into the engine. The real implementation is still structurally assignable, + * so this is a narrowing rather than an API change. + * + * A correct implementation returns null for an unknown or disabled model id rather than throwing -- + * the sole caller is labelling a failure for telemetry and must not fail because of it. + */ +export interface ModelConfigReader { + getResolvedModelConfig(modelId: string): Promise<{ providerName: string } | null>; +} + +/** + * Webhook deliveries, replayed to recover a job whose queue message arrived without one. + * + * `payload` stays `unknown` on purpose: the caller narrows it to a `GitHubWebhookPayload` itself, and + * a port that pre-narrowed it would be asserting a git-provider shape the engine is meant not to + * assume. A correct implementation must return the payload already decoded from whatever column + * encoding it uses -- never a JSON string -- and null for an unknown delivery id. + */ +export interface WebhookDeliveryReader { + getWebhookDelivery(deliveryId: string): Promise<{ delivery_id: string; event_name: string; payload: unknown } | null>; +} + +/** + * Findings a human previously rejected, injected as negative few-shot exemplars. + * + * A correct implementation must guarantee: + * - results are drawn only from findings a human actually labelled. Absence of a label is not a + * rejection, and treating it as one would teach the model from silence. + * - `limit` is an upper bound and may be clamped down; returning fewer (including none) is always + * legal. Every caller treats exemplars as optional enrichment and must still work with zero. + * - it never throws for a repository with no history -- a new repo returns an empty array. + * - the field names stay snake_case: they are read straight through into the prompt builder. + */ +export interface LearningStore { + getRepositoryIdForJob(jobId: string): Promise; + getRejectedExemplars(input: { repositoryId: number; claimTypes?: readonly ClaimType[]; limit?: number }): Promise< + Array<{ title: string; body: string; claim_type: ClaimType | null; context_snippet: string | null; path: string }> + >; +} diff --git a/packages/core/src/ports/telemetry.ts b/packages/core/src/ports/telemetry.ts new file mode 100644 index 00000000..3b4cdb50 --- /dev/null +++ b/packages/core/src/ports/telemetry.ts @@ -0,0 +1,36 @@ +/** + * One completed review's anonymous metrics. Shaped entirely by the engine; the version and instance + * id are the sink's business, since neither is knowable from inside a review. + */ +export type ReviewTelemetryEvent = { + linesReviewed: number; + findingsReported: number; + inputTokens: number; + outputTokens: number; + modelsUsed: string[]; + fileExtensions: string[]; + triggerType: string; + reviewDurationMs: number; + filesReviewed: number; + verdict?: string; + severityDistribution: Record; + concurrencyLevel: string; + prTotalLinesChanged: number; + retryCount: number; +}; + +/** + * Where finished-review metrics go. + * + * A correct implementation MUST NOT THROW, for any input or any transport failure -- it is called on + * the last step of a successful review, and an exception there would fail a job that has already + * posted its review. It must also not block: a slow or unreachable endpoint has to degrade to + * dropping the event, not to holding the phase open until the invocation times out. + * + * It may drop, batch, sample or refuse events entirely (a host with telemetry disabled implements + * this as a no-op), so the engine treats a resolved promise as no evidence that anything was sent. + * Delivery is at-most-once and unordered. + */ +export interface TelemetrySink { + send(event: ReviewTelemetryEvent): Promise; +} diff --git a/packages/core/src/prompts/file-review.ts b/packages/core/src/prompts/file-review.ts new file mode 100644 index 00000000..693b9c61 --- /dev/null +++ b/packages/core/src/prompts/file-review.ts @@ -0,0 +1,428 @@ +import { claimTypes, type RepoConfig } from '@codra/schema'; +import type { FileDiff } from '../diff'; +import type { ModelResponseSchema } from '../ports/model'; +import { getLanguageForFile } from './languages'; + +// Generator cap, NOT the posted cap: per CHUNK, upstream of four remove-only filters, where `max_comments` is once per job. +// +// Deliberately NOT divided by the size of a batched bin. That was tried, on the theory that a six-file +// bin asking 20 findings per file requested more than one response could hold: measured on a 221-file +// job, all 71 bin responses ended cleanly at 967-1,845 chars and the whole job spent 17,158 output +// tokens -- about 3% of the ceiling that was supposedly binding. The cap has never been what limits +// findings, so lowering it only removes room a genuinely defective file might need. +export function generatorFindingCap(maxComments: number): number { + return Math.max(1, maxComments * 2); +} + +// Shared by the single-file and batched grammars, so the field-order invariant is stated once. +function findingItemSchema() { + return { + type: 'object', + additionalProperties: false, + // Field order is load-bearing under constrained decoding: `evidence` first forces a real quote before any prose. + required: ['evidence', 'code_location', 'claim_type', 'title', 'body', 'priority'], + // `properties` order must match `required`: generation follows declaration order, so gemini-schema.ts must never sort or rebuild this. + 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 }, + }, + }, + }, + // Branch order matters: gemini-schema.ts collapses this to the first branch. + 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' }, + }, + }; +} + +// Response grammar for constrained decoding; same contract as the system and user prompts, all three must agree. +export function buildReviewResponseSchema(maxComments: 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: generatorFindingCap(maxComments), + 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 }, + }, + }, + }; +} + +// Batched grammar: `absolute_file_path` is required here even though its per-finding twin is optional; no `minItems` on `files` (uneven provider support) so the count is checked at parse time. +export function buildBatchReviewResponseSchema(maxComments: 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, + // Path first, like `evidence` in a finding: commit to the file before describing it. + required: ['absolute_file_path', 'findings', 'overall_explanation', 'overall_correctness'], + properties: { + absolute_file_path: { type: 'string' }, + // Deliberately unbounded, unlike the single-file grammar: `maxItems` on an array nested + // inside another bounded array made Gemini reject the whole schema with "produces a + // constraint that has too many states for serving", losing constrained decoding for the + // bin. The cap is stated in prose ("per file") and enforced at parse time by the + // over-cap truncation, so nothing but the FSM size changes. + 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) +}`; + +// A finding belongs to whichever entry encloses it; the per-finding `absolute_file_path` is only a cross-check. +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) +}`; + +// No restraint language: behind four remove-only filters, asking for empty findings arrays measured 0.039 findings/file and no true positives. Wording is snapshot-locked. +export function buildFileReviewSystemPromptBase(opts?: { multiFile?: boolean }): string { + const multi = opts?.multiFile === true; + + 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.` + : '- You can see ONLY the diff below, not the whole file or the rest of the repository.'; + + 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.'; + + // The multi-file wording must demand one entry per file (the parser reports a missing file as + // unreviewed and re-queues it) WITHOUT handing out an empty array as the easy way to satisfy that. + // The previous phrasing -- "even for files with no defect, give those an empty findings array" -- + // presupposed clean files in every bin and reintroduced exactly the restraint language the note above + // says measured 0.039 findings/file. Review each diff on its own merits is the whole instruction. + 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.`; +} + +// Named export because several tests assert against the prompt text directly. +export const fileReviewSystemPromptBase = buildFileReviewSystemPromptBase(); + +export function buildFileReviewSystemPrompt( + config: RepoConfig['review'], + languagePersona?: string, + opts?: { multiFile?: boolean }, +) { + const persona = languagePersona ? ` as ${languagePersona}` : ''; + // Prose cap must be the generator cap: otherwise the grammar allows 2N while the text asks for N, and the model obeys the text. + const prompt = buildFileReviewSystemPromptBase(opts) + .replace('{{MAX_COMMENTS}}', generatorFindingCap(config.max_comments).toString()); + return `You are a world-class professional senior code reviewer${persona}. ${prompt}`; +} + +// Human-rejected findings as NEGATIVE few-shot exemplars. Rejections only, since `marked_right` is rare and an absent label means nothing. +export type RejectedExemplar = { title: string; claimType?: string | null }; + +// Hard cap: every character competes with the diff for a 16k-input-tokens/minute bucket. +const EXEMPLAR_BLOCK_CHARS = 700; + +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'); +} + +const PR_DESCRIPTION_CHARS = 2_000; + +// Highest-value context by a wide margin (ContextCRBench: diff-only F1 36.08, +description 62.12). +function renderPrContext(prDescription: string | null): string | null { + const trimmed = prDescription?.trim(); + if (!trimmed) return null; + return `PR description (author intent - use to judge whether a change is deliberate):\n${trimmed.slice(0, PR_DESCRIPTION_CHARS)}${trimmed.length > PR_DESCRIPTION_CHARS ? '…' : ''}`; +} + +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; + prTitle: string | null; + prDescription: 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 systemPrompt = buildFileReviewSystemPrompt(input.config, languageInfo?.persona); + const languageGuidelines = renderLanguageGuidelines(input.file.path); + + const prContext = renderPrContext(input.prDescription); + + const exemplars = renderExemplars(input.rejectedExemplars); + + const userPrompt = [ + `PR title: ${input.prTitle ?? 'Untitled PR'}`, + ...(prContext ? [prContext] : []), + ...(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` is posted to GitHub as the anchor, so it must be a NEW-file number present in the diff. + '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 is matched verbatim before posting, so it must be code only -- no gutter or marker. + 'Evidence: every finding must carry an `evidence` string containing the exact code of the line it is about, copied character-for-character from the 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.', + '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), + ].join('\n'); + + return { systemPrompt, userPrompt }; +} + +// Distinct enough not to be confused for diff content. +function packFileHeader(file: FileDiff, index: number, total: number): string { + return `===== FILE ${index + 1} of ${total}: ${file.path} =====`; +} + +// Several small files share one call so the ~2,800-token preamble amortises. Not a generalisation of buildFileReviewPrompts, which is snapshot-locked. +export function buildBatchReviewPrompts(input: { + files: readonly FileDiff[]; + prTitle: string | null; + prDescription: string | null; + config: RepoConfig['review']; + rejectedExemplars?: readonly RejectedExemplar[]; +}) { + const files = input.files; + + // Object identity is enough: getLanguageForFile returns the same entry for every matching file. + const languages = new Set(files.map((file) => getLanguageForFile(file.path))); + const uniformLanguage = languages.size === 1 ? [...languages][0] : undefined; + + // A persona claims something about the whole response, so only uniform bins get one. + const systemPrompt = buildFileReviewSystemPrompt(input.config, uniformLanguage?.persona, { multiFile: true }); + + const prContext = renderPrContext(input.prDescription); + 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), + // Uniform bins state the language once, above; only a mixed bin repeats it per file. + ...(uniformLanguage ? [] : [renderLanguageGuidelines(file.path)]), + 'Unified diff:', + renderFileDiff(file), + ]); + + const userPrompt = [ + `PR title: ${input.prTitle ?? 'Untitled PR'}`, + ...(prContext ? [prContext] : []), + ...(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.', + // The key batch-only rule: a misfiled finding can fuzzy-match a common line in the wrong file. + '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` is posted to GitHub as the anchor, so it must be a NEW-file number present in the diff. + '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 is matched verbatim before posting, so it must be code only -- no gutter or marker. + '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.', + '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)`, + // Same constant the system prompt renders. + MULTI_FILE_SCHEMA_FORMAT, + ...fileBlocks, + ].join('\n'); + + return { systemPrompt, userPrompt }; +} + +// Exported so the packer measures bins with the exact renderer the prompt uses. +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 new file mode 100644 index 00000000..ad22509f --- /dev/null +++ b/packages/core/src/prompts/languages.ts @@ -0,0 +1,91 @@ +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).', + ], + }, + // A React entry with a hook-dependency guideline used to live here, but its extensions overlapped the TypeScript entry above. + // Effect was measurable: hook-dependency findings ran 10x concentrated in .tsx with 0 of 28 posted -- the checklist dictated what the model "found" rather than helping it find more. Removed rather than reworded. + { + 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; + + // On an overlap, take the single most specific entry rather than merging: merging is how .tsx ended up being told to hunt for hook-dependency bugs. Narrower extension list == more specific. + if (matches.length > 1) { + return matches.reduce((best, candidate) => + candidate.extensions.length < best.extensions.length ? candidate : best, + ); + } + + return matches[0]; +} diff --git a/packages/core/src/prompts/summary.ts b/packages/core/src/prompts/summary.ts new file mode 100644 index 00000000..8468367c --- /dev/null +++ b/packages/core/src/prompts/summary.ts @@ -0,0 +1,57 @@ +export const SUMMARY_SYSTEM_PROMPT = `You are an automated code review bot. Summarize the findings of a PR review. +CRITICAL: Return ONLY a JSON object with a single "summary" key. + +Constraints: +1. NO intro text, NO reasoning, NO meta-commentary like "Task: Summarize...". +2. NO markdown code fences for the JSON itself. +3. DO NOT include any verdict headers like "✅ Approved" or "💬 Comments posted". +4. Format: [File name]: [Concise overview of issues] (lines X-Y). +5. DO NOT include any priority tags like "P0", "P1", etc., in the summary text. Mention the impact instead. +6. If failures occurred, mention: "⚠️ **[filename]** - automated review could not complete (parse error)." +7. Tone: Technical, impact-focused, brief. +8. Max 200 words. JSON only.`; + +export function buildSummaryPrompt(input: { + prTitle: string | null; + verdict: 'approve' | 'comment'; + fileSummaries: Array<{ path: string; summary: string; verdict: string }>; +}) { + const successFindings = input.fileSummaries.filter( + (f) => f.verdict !== 'approve' && !f.summary.startsWith('Review failed'), + ); + const approved = input.fileSummaries.filter((f) => f.verdict === 'approve'); + const failures = input.fileSummaries.filter((f) => f.summary.startsWith('Review failed')); + + const lines: string[] = [ + `PR: "${input.prTitle ?? 'Untitled PR'}"`, + `Verdict: ${input.verdict}`, + '', + ]; + + if (successFindings.length > 0) { + lines.push('Files with findings:'); + for (const f of successFindings) { + lines.push(`- \`${f.path}\` [${f.verdict}]: ${f.summary}`); + } + } + + if (approved.length > 0) { + lines.push(`Files approved with no issues: ${approved.map((f) => `\`${f.path}\``).join(', ')}`); + } + + if (failures.length > 0) { + lines.push('Files where automated review failed (mention as warnings):'); + for (const f of failures) { + const reason = f.summary.replace('Review failed: ', ''); + lines.push(`- \`${f.path}\`: ${reason}`); + } + } + + if (successFindings.length === 0 && failures.length === 0) { + lines.push('No significant findings. All files passed review.'); + } + + return lines.join('\n'); +} + + diff --git a/packages/core/src/prompts/verify.ts b/packages/core/src/prompts/verify.ts new file mode 100644 index 00000000..ace50f10 --- /dev/null +++ b/packages/core/src/prompts/verify.ts @@ -0,0 +1,168 @@ +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(), + // `.optional()` and NOT `.default()`: a default would materialize the key on every parsed result, changing the shape callers compare against. + reason: z.string().optional(), + // Optional so a model that ignores the field is treated as "did not say", never as "not + // 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]; + +// Field order matters for providers that decode against the schema: `reason` precedes `verdict` so the +// model commits to a justification BEFORE the decision token, and `decidable` precedes it for the same +// reason -- it must answer "could I check this at all?" before it is allowed to answer "is it true?". +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 }, + // Longer than the 15 words the verdict gets: naming the artifact you would need to check + // a claim is the whole point of the `decidable` field, and it does not fit in 15 words. + 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'); +} + +// Renders a window of the diff around a finding's line so the verifier can judge it in context without re-sending the whole file. +// Returns '' when the line can't be located, rather than falling back to `anchor = 0`: that used to make the verifier silently judge unrelated code, masquerading an infrastructure miss as a real verdict. +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 ''; + + // NEW-file numbers first, in a separate pass: a combined findIndex on `newLineNumber === line || oldLineNumber === line` can match an earlier OLD-numbered context line in a deletion-heavy file, landing the window N-deletions away from the real finding. Old-number pass is kept only as a fallback for removed code. + 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/src/server/core/review/bin-runner.ts b/packages/core/src/review/bin-runner.ts similarity index 88% rename from src/server/core/review/bin-runner.ts rename to packages/core/src/review/bin-runner.ts index a6971f26..0fc6ae4a 100644 --- a/src/server/core/review/bin-runner.ts +++ b/packages/core/src/review/bin-runner.ts @@ -1,15 +1,8 @@ import { logger } from '../logger'; import type { RepoConfig } from '@codra/schema'; -import type { AppBindings } from '@server/env'; -import { - type BulkFileReviewInput, - bulkRecordRetryableFileReviewFailures, - bulkUpsertFileReviews, -} from '@server/db/file-reviews'; import type { FileDiff } from '../diff'; -import { renderFileDiff, type RejectedExemplar } from '@server/prompts/file-review'; -import { GitHubService } from '../../services/github'; -import { isRetryableModelError, ModelService, nextChainIndexOf } from '../../services/model'; +import { renderFileDiff, type RejectedExemplar } from '../prompts/file-review'; +import type { BulkFileReviewInput, PullRequestRecord, ReviewModel, ReviewRuntime } from '../ports'; import { type PersistedReviewJob, FRESH_INVOCATION_YIELD_SECONDS, MAX_RETRYABLE_FILE_REVIEW_FAILURES } from './phase-control'; import { isSubrequestBudgetError, retryableModelFailureDelaySeconds } from './retry-policy'; import { scanRuleChannel } from './file-runner'; @@ -39,17 +32,17 @@ export function proportionalSplit(total: number, weights: number[]): number[] { // Reviews a packed bin in one model call, one row per file. Returns how many files reached a terminal state; re-queued files are excluded, or the wedge counter never advances. export async function reviewAndPersistBin( - env: AppBindings, + env: ReviewRuntime, job: PersistedReviewJob, files: FileDiff[], - pr: Awaited>, + pr: PullRequestRecord, config: RepoConfig, totalLineCount: number, - model: ModelService, + model: ReviewModel, resolveFailureModelProvider: () => Promise, rejectedExemplars: readonly RejectedExemplar[] = [], ): Promise { - const startedAt = Date.now(); + const startedAt = env.clock.now(); // Scanned before the model call, so a rule hit reaches finalize even when the chain fails. const ruleScans = new Map(files.map((file) => [file.path, scanRuleChannel(file, config)])); @@ -70,7 +63,7 @@ export async function reviewAndPersistBin( parsedComments: ruleScans.get(file.path)?.comments ?? [], inputTokens: null, outputTokens: null, - durationMs: Date.now() - startedAt, + durationMs: env.clock.now() - startedAt, verdict: null, fileSummary: null, errorMessage, @@ -91,7 +84,7 @@ export async function reviewAndPersistBin( const weights = reviewed.map((file) => renderFileDiff(file).length); const inputSplit = proportionalSplit(response.inputTokens, weights); const outputSplit = proportionalSplit(response.outputTokens, weights); - const durationMs = Date.now() - startedAt; + const durationMs = env.clock.now() - startedAt; const rows: BulkFileReviewInput[] = reviewed.map((file, index) => { const parsed = response.batch.reviews.get(file.path)!; @@ -126,14 +119,14 @@ export async function reviewAndPersistBin( }); if (rows.length > 0) { - await bulkUpsertFileReviews(env, job.id, rows); + await env.fileReviews.bulkUpsertFileReviews(job.id, rows); for (const row of rows) persisted.add(row.filePath); terminalCount += rows.length; } // Never done-and-clean (that approves unexamined code), and not terminal progress either. if (response.batch.missing.length > 0) { - const counts = await bulkRecordRetryableFileReviewFailures(env, job.id, response.batch.missing.map((path) => ({ + 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, @@ -144,7 +137,7 @@ export async function reviewAndPersistBin( // Otherwise a file omitted every time never terminates through this path. const exhausted = counts.filter((c) => c.transientErrorCount >= MAX_RETRYABLE_FILE_REVIEW_FAILURES); if (exhausted.length > 0) { - await bulkUpsertFileReviews(env, job.id, exhausted.map((c) => failedRow( + 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.`, ))); @@ -200,12 +193,12 @@ export async function reviewAndPersistBin( return terminalCount; } - if (isRetryableModelError(error)) { + if (env.modelErrors.isRetryableModelError(error)) { // Set when the chain still has untried models: the next invocation resumes at that index, so // this deferral is progress and must not spend one of the three allowed attempts. Keeping the // count also keeps the bin intact, which is what we want while only the model is changing. - const advancedTo = nextChainIndexOf(error); - const counts = await bulkRecordRetryableFileReviewFailures(env, job.id, outstanding.map((file) => ({ + 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, @@ -215,7 +208,7 @@ export async function reviewAndPersistBin( const exhausted = counts.filter((c) => c.transientErrorCount >= MAX_RETRYABLE_FILE_REVIEW_FAILURES); if (exhausted.length > 0) { // Terminal, but rule-channel findings survive the model's failure. - await bulkUpsertFileReviews(env, job.id, exhausted.map((c) => failedRow( + 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, @@ -245,7 +238,7 @@ export async function reviewAndPersistBin( logger.error('Batched review failed', { jobId: job.id, paths: outstanding.map((f) => f.path), error }); - await bulkUpsertFileReviews(env, job.id, outstanding.map((file) => failedRow(file, errorMessage, modelProvider))); + await env.fileReviews.bulkUpsertFileReviews(job.id, outstanding.map((file) => failedRow(file, errorMessage, modelProvider))); terminalCount += outstanding.length; } diff --git a/src/server/core/review/budget.ts b/packages/core/src/review/budget.ts similarity index 100% rename from src/server/core/review/budget.ts rename to packages/core/src/review/budget.ts diff --git a/src/server/core/review/diff-cache.ts b/packages/core/src/review/diff-cache.ts similarity index 80% rename from src/server/core/review/diff-cache.ts rename to packages/core/src/review/diff-cache.ts index 6e412cb4..f454a4e7 100644 --- a/src/server/core/review/diff-cache.ts +++ b/packages/core/src/review/diff-cache.ts @@ -1,7 +1,6 @@ -import type { AppBindings } from '@server/env'; import { reviewMaxFilesRange, type RepoConfig } from '@codra/schema'; import { filterReviewableFiles, parseUnifiedDiff, type FileDiff } from '../diff'; -import type { GitHubService } from '../../services/github'; +import type { ReviewGitHub, ReviewRuntime } from '../ports'; import { logger } from '../logger'; const DIFF_CACHE_TTL_SECONDS = 6 * 60 * 60; @@ -13,20 +12,20 @@ export function diffCacheKey(jobId: string) { // Fetches and parses the PR diff from GitHub only once per job (cached in KV) instead of once per phase invocation. export async function getDiffFiles( - env: AppBindings, + env: Pick, job: { id: string; owner: string; repo: string; prNumber: number }, - github: Pick, + github: Pick, config: RepoConfig, // Passed in rather than read here so a single settings lookup can serve both this and the concurrency level in the same phase. maxFiles: number = reviewMaxFilesRange.default, ): Promise<{ files: FileDiff[]; skipped: number }> { const cacheKey = diffCacheKey(job.id); - let rawDiff = await env.APP_KV.get(cacheKey); + let rawDiff = await env.kv.get(cacheKey); if (!rawDiff) { rawDiff = await github.getPullRequestDiff(job.owner, job.repo, job.prNumber); try { - await env.APP_KV.put(cacheKey, rawDiff, { expirationTtl: DIFF_CACHE_TTL_SECONDS }); + 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))); } @@ -37,17 +36,17 @@ export async function getDiffFiles( // Reconstructs the raw PR diff for a finished job (diff_input isn't stored in Postgres; see /api/jobs/:id/diffs). Reuses getDiffFiles' KV cache while warm; once the 6h TTL lapses, re-derives from GitHub via the job's own base/head commits (not the live PR diff, which may have moved on) and rewrites the cache. export async function getOrFetchRawDiffForCompletedJob( - env: AppBindings, + env: Pick, job: { id: string; owner: string; repo: string; baseSha: string; commitSha: string }, - github: Pick, + github: Pick, ): Promise { const cacheKey = diffCacheKey(job.id); - const cached = await env.APP_KV.get(cacheKey); + 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.APP_KV.put(cacheKey, rawDiff, { expirationTtl: DIFF_CACHE_TTL_SECONDS }); + 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))); } diff --git a/src/server/core/review/file-runner.ts b/packages/core/src/review/file-runner.ts similarity index 90% rename from src/server/core/review/file-runner.ts rename to packages/core/src/review/file-runner.ts index e6bbae92..de453dba 100644 --- a/src/server/core/review/file-runner.ts +++ b/packages/core/src/review/file-runner.ts @@ -1,19 +1,16 @@ import { logger } from '../logger'; import { type ParsedReviewComment, type RepoConfig } from '@codra/schema'; -import type { AppBindings } from '@server/env'; -import { recordRetryableFileReviewFailure, upsertFileReview } from '@server/db/file-reviews'; import { parseUnifiedDiff, type FileDiff } from '../diff'; import { ruleHitsToComments, scanFileForRuleHits, type RuleScanStats } from '../rules/detect'; -import type { RejectedExemplar } from '@server/prompts/file-review'; -import { GitHubService } from '../../services/github'; -import { isRetryableModelError, ModelService, nextChainIndexOf } from '../../services/model'; +import type { RejectedExemplar } from '../prompts/file-review'; +import type { PullRequestRecord, ReviewModel, ReviewRuntime } from '../ports'; import { type PersistedReviewJob, FRESH_INVOCATION_YIELD_SECONDS, MAX_RETRYABLE_FILE_REVIEW_FAILURES } from './phase-control'; import { isSubrequestBudgetError, retryableModelFailureDelaySeconds } from './retry-policy'; // Sibling of the core/review barrel; import from there, not here. One file end to end: rule scan, model review, persist. // Persists an async-batch poll result, clearing the bookkeeping columns. export async function persistCompletedReview( - env: AppBindings, + env: Pick, job: PersistedReviewJob, file: ReturnType[number], response: { @@ -32,7 +29,7 @@ export async function persistCompletedReview( }; }, ) { - await upsertFileReview(env, job.id, { + await env.fileReviews.upsertFileReview(job.id, { filePath: file.path, fileStatus: 'done', modelUsed: response.modelUsed, @@ -57,7 +54,7 @@ export async function persistCompletedReview( // Terminal 'failed' upsert, one place for several near-identical ones. `clearAsync` wipes batch bookkeeping on queued rows. export async function persistFailedFileReview( - env: AppBindings, + env: Pick, jobId: string, input: { filePath: string; @@ -71,7 +68,7 @@ export async function persistFailedFileReview( parsedComments?: ParsedReviewComment[]; }, ) { - await upsertFileReview(env, jobId, { + await env.fileReviews.upsertFileReview(jobId, { filePath: input.filePath, fileStatus: 'failed', modelUsed: input.modelUsed, @@ -115,18 +112,18 @@ export function scanRuleChannel( } export async function reviewAndPersistFile( - env: AppBindings, + env: ReviewRuntime, job: PersistedReviewJob, file: ReturnType[number], - pr: Awaited>, + pr: PullRequestRecord, config: RepoConfig, totalLineCount: number, - model: ModelService, + model: ReviewModel, resolveFailureModelProvider: () => Promise, previousReview?: { transient_error_count: number }, rejectedExemplars: readonly RejectedExemplar[] = [], ) { - const startedAt = Date.now(); + const startedAt = env.clock.now(); const compactPrompt = (previousReview?.transient_error_count ?? 0) > 0; // Scanned BEFORE the model call, so a hit reaches finalize even when the whole chain fails. @@ -143,7 +140,7 @@ export async function reviewAndPersistFile( rejectedExemplars, }); - await upsertFileReview(env, job.id, { + await env.fileReviews.upsertFileReview(job.id, { filePath: file.path, fileStatus: 'done', modelUsed: response.modelUsed, @@ -154,7 +151,7 @@ export async function reviewAndPersistFile( parsedComments: [...response.parsed.comments, ...ruleScan.comments], inputTokens: response.inputTokens, outputTokens: response.outputTokens, - durationMs: Date.now() - startedAt, + durationMs: env.clock.now() - startedAt, verdict: response.parsed.verdict, fileSummary: response.parsed.fileSummary, overallCorrectness: response.parsed.overallCorrectness, @@ -210,17 +207,17 @@ export async function reviewAndPersistFile( } // Transient outages count against the file, so one unrecoverable file becomes a partial review instead of blocking the job forever. - if (isRetryableModelError(error)) { - const failureCount = await recordRetryableFileReviewFailure(env, job.id, { + 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: Date.now() - startedAt, + durationMs: env.clock.now() - startedAt, errorMessage, // Progress down the chain, not a repeated outage: the retry resumes at the next model. - countsAsAttempt: nextChainIndexOf(error) === null, + countsAsAttempt: env.modelErrors.nextChainIndexOf(error) === null, }); if (failureCount >= MAX_RETRYABLE_FILE_REVIEW_FAILURES) { @@ -230,7 +227,7 @@ export async function reviewAndPersistFile( modelUsed: modelId, modelProvider, diffLineCount: file.lineCount, - durationMs: Date.now() - startedAt, + durationMs: env.clock.now() - startedAt, errorMessage: finalError, parsedComments: ruleScan.comments, }); @@ -269,7 +266,7 @@ export async function reviewAndPersistFile( modelUsed: modelId, modelProvider, diffLineCount: file.lineCount, - durationMs: Date.now() - startedAt, + durationMs: env.clock.now() - startedAt, errorMessage, parsedComments: ruleScan.comments, }); diff --git a/src/server/core/review/finalize.ts b/packages/core/src/review/finalize.ts similarity index 88% rename from src/server/core/review/finalize.ts rename to packages/core/src/review/finalize.ts index cacb88d0..298ed427 100644 --- a/src/server/core/review/finalize.ts +++ b/packages/core/src/review/finalize.ts @@ -1,14 +1,8 @@ import { logger } from '../logger'; import { defaultRepoConfig, type ParsedReviewComment, type RepoConfig } from '@codra/schema'; -import type { AppBindings } from '@server/env'; -import { bulkMarkFilesFailed, getFileReviewsForJobs, markCommentDispositions, markCommentsPosted } from '@server/db/file-reviews'; -import { completeJob, markJobCheckRunCompleted, updateJobStep } from '@server/db/jobs'; import { shadowEvaluate } from '../finding-gates'; import { getDiffFiles } from './diff-cache'; -import { GitHubService } from '../../services/github'; -import { ModelService } from '../../services/model'; -import { FormatterService } from '../../services/formatter'; -import { getReviewSettings } from '@server/db/app-settings'; +import type { ReviewFormatter, ReviewGitHub, ReviewModel, ReviewRuntime } from '../ports'; import { type PersistedReviewJob, FRESH_INVOCATION_YIELD_SECONDS, @@ -20,23 +14,23 @@ import { applyFindingGates } from './gate-pipeline'; // Reconciles reviews, gates findings, then composes and posts the review. Import from the core/review barrel, not here. export async function runFinalizePhase( - env: AppBindings, + env: ReviewRuntime, job: PersistedReviewJob, leaseOwner: string, - github: GitHubService, - formatter: FormatterService, - model: ModelService, + github: ReviewGitHub, + formatter: ReviewFormatter, + model: ReviewModel, ) { - await updateJobStep(env, job.id, 'Generating Summary', { status: 'running' }); + 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; // One lookup supplies both the file ceiling and the gating comment cap. - const reviewSettings = await getReviewSettings(env); + const reviewSettings = await env.settings.getReviewSettings(); // The diff (KV/GitHub) and the file reviews (Postgres) share no state; two in flight cannot breach the subrequest cap. const [{ files, skipped: filesOverCap }, initialReviews] = await Promise.all([ getDiffFiles(env, job, github, config, reviewSettings.maxFiles), - getFileReviewsForJobs(env, [job.id]), + env.fileReviews.getFileReviewsForJobs([job.id]), ]); let reviews = initialReviews; @@ -48,24 +42,23 @@ export async function runFinalizePhase( if (missingFiles.length > 0) { logger.warn(`Job ${job.id} reached finalize phase with ${missingFiles.length} missing file reviews. Forcing them to failed state.`); // One INSERT: per-file writes would exhaust the subrequest budget right before posting. - await bulkMarkFilesFailed( - env, + 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 getFileReviewsForJobs(env, [job.id]); + reviews = await env.fileReviews.getFileReviewsForJobs([job.id]); } else if (reviews.length < files.length) { // Every path covered but fewer rows than files: review isn't done, so bounce back. Must stay an `else if`, or the healthy path loops finalize forever. - await updateJobStep(env, job.id, 'Reviewing Files', { status: 'running' }); + await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'running' }); await enqueueJobPhase(env, job.id, 'review', FRESH_INVOCATION_YIELD_SECONDS); return; } } // The continuation-ceiling degrade reaches finalize unmarked, stranding the step "In progress". - await updateJobStep(env, job.id, 'Reviewing Files', { status: 'done' }); + 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) => ({ @@ -82,7 +75,7 @@ export async function runFinalizePhase( const retryCount = job.retryOfJobId ? 1 : 0; if (fileSummaries.length > 0 && fileSummaries.every((file) => file.verdict === 'failed')) { - await updateJobStep(env, job.id, 'Generating Summary', { status: 'failed', error: 'All files failed to review' }); + await env.jobs.updateJobStep(job.id, 'Generating Summary', { status: 'failed', error: 'All files failed to review' }); await sendReviewTelemetry( env, @@ -159,10 +152,10 @@ export async function runFinalizePhase( const verdictSummary = everythingWithheld && rawVerdict.verdict === 'approve' ? { ...rawVerdict, verdict: 'comment' as const } : rawVerdict; - await updateJobStep(env, job.id, 'Generating Summary', { status: 'done' }); + await env.jobs.updateJobStep(job.id, 'Generating Summary', { status: 'done' }); await heartbeatAndCheckSuperseded(env, job.id, leaseOwner); - let formattedSummary = formatter.formatReviewOverview(pr.head.sha, env.BOT_USERNAME); + let formattedSummary = formatter.formatReviewOverview(pr.head.sha, env.botUsername); // Reviewing 100 of 106 files and calling it done looks identical to finding the other six clean. if (filesOverCap > 0) { @@ -177,9 +170,9 @@ export async function runFinalizePhase( const finalizeRetriedPastPost = job.steps.some( (step) => step.name === 'Completing' && (step.status === 'running' || step.status === 'done'), ); - await updateJobStep(env, job.id, 'Completing', { status: 'running' }); + 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.BOT_USERNAME) + ? 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, @@ -200,7 +193,7 @@ export async function runFinalizePhase( const postedFingerprints = review.postedIndices .map((index) => finalComments[index]?.fingerprint) .filter((fingerprint): fingerprint is string => Boolean(fingerprint)); - await markCommentsPosted(env, job.id, postedFingerprints); + await env.fileReviews.markCommentsPosted(job.id, postedFingerprints); } // Measurement only: a failure here must never fail a review already on GitHub. @@ -213,7 +206,7 @@ export async function runFinalizePhase( reason: verifyReasons.get(fingerprint) ?? null, }); } - await markCommentDispositions(env, job.id, withReasons); + await env.fileReviews.markCommentDispositions(job.id, withReasons); } catch (error) { logger.warn('Could not record finding dispositions', { jobId: job.id, @@ -234,7 +227,7 @@ export async function runFinalizePhase( ? `Partial review: ${failedFileCount} of ${files.length} file${files.length === 1 ? '' : 's'} could not be reviewed.` : null; // Done immediately after createReview: the review is on GitHub, so a budget-exhausted cosmetic call must not strand the job. - await completeJob(env, job.id, { + await env.jobs.completeJob(job.id, { verdict: verdictSummary.verdict, fileCount: files.length, commentCount: finalComments.length, @@ -258,7 +251,7 @@ export async function runFinalizePhase( summary: `${finalComments.length} inline comments across ${files.length} files.${hasFailures ? ` ${failedFileCount} file${failedFileCount === 1 ? '' : 's'} could not be reviewed.` : ''}`, }); // Record completion so the maintenance sweep skips it. - await markJobCheckRunCompleted(env, job.id); + await env.jobs.markJobCheckRunCompleted(job.id); } if (config.review.labels !== false) { diff --git a/src/server/core/review/gate-pipeline.ts b/packages/core/src/review/gate-pipeline.ts similarity index 97% rename from src/server/core/review/gate-pipeline.ts rename to packages/core/src/review/gate-pipeline.ts index 16d74ba1..1ffe24f5 100644 --- a/src/server/core/review/gate-pipeline.ts +++ b/packages/core/src/review/gate-pipeline.ts @@ -1,20 +1,19 @@ import { dedupeFindings } from '../model-output'; import { verifyFindings } from '../finding-gates'; import type { FindingDisposition, ParsedReviewComment, RepoConfig } from '@codra/schema'; -import type { AppBindings } from '@server/env'; import type { FileDiff } from '../diff'; import type { PersistedReviewJob } from './phase-control'; -import type { ModelService } from '../../services/model'; +import type { ReviewModel, ReviewRuntime } from '../ports'; import { loadSuppressedFingerprints } from './telemetry'; // The finding funnel. Order is load-bearing: severity/confidence gates, cross-run suppression (before dedupe/verification), dedupe, a severity sort, then verification and the max_comments cap. // Returns per-stage counts, since `posted = false` alone conflated six outcomes. Import from the core/review barrel, not here. export async function applyFindingGates(params: { - env: AppBindings; + env: Pick; job: PersistedReviewJob; config: RepoConfig; files: FileDiff[]; - model: Pick; + model: Pick; effectiveMaxComments: number; reviewedComments: ParsedReviewComment[]; reviews: Array<{ withheld_counts?: { evidence?: number; claimDenied?: number } | null }>; diff --git a/packages/core/src/review/index.ts b/packages/core/src/review/index.ts new file mode 100644 index 00000000..4eada7ff --- /dev/null +++ b/packages/core/src/review/index.ts @@ -0,0 +1,371 @@ +import { logger } from '../logger'; +import { isSupportedGitHubWebhookEvent, type GitHubWebhookPayload, type PullRequestWebhookPayload } from '@codra/schema/github'; +import { REVIEW_CONCURRENCY_LIMITS, type ReviewJobMessage } from '@codra/schema'; +import type { ReviewGitHub, ReviewRuntime } from '../ports'; +import { extractReviewRequest } from './request'; + +// Re-exports below are the engine's public surface, reached through @codra/core. +export { getDiffFiles, getOrFetchRawDiffForCompletedJob } from './diff-cache'; + +export { budgetAwareFileLimit, estimatedSubrequestsPerFile } from './budget'; + +export { + BIN_DIFF_CHAR_BUDGET, + BIN_MAX_FILES, + BIN_TARGET_DIFF_LINES, + PACKABLE_MAX_DIFF_LINES, + narrowUnit, + planReviewUnits, + unitFiles, + type LedgerEntry, + type ReviewUnit, +} from './pack'; + +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 +// importing phase-control directly. +export { FRESH_INVOCATION_YIELD_SECONDS } from './phase-control'; + +import { + type PersistedReviewJob, + BUSY_RETRY_SECONDS, + FRESH_INVOCATION_YIELD_SECONDS, + JOB_LEASE_SECONDS, + MAX_FINALIZE_CONTINUATIONS, + MAX_JOB_CONTINUATIONS, + NextPhaseError, + failJobAndCheckRun, +} from './phase-control'; +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 } + // jobId is resolved (mention-triggered jobs carry none). freshInstance starts a new Workflow instance: set on a subrequest deferral or the move into finalize. + | { 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' }; + } + + // Admission only: re-gating a job already 'running' would retry forever and stale its lease. + 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); + + // Bind the Workflow instance id so stop/delete/rerun hit the right one; webhook jobs key theirs on deliveryId, so the earlier bind step cannot. Idempotent. + 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); + // Finalize needs a fresh instance for a clean budget; other transitions hibernate instead. + 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'); + } + + // Not a job failure: every phase is idempotent enough to resume on a fresh budget. + if (isSubrequestBudgetError(error)) { + // Only a long-enough sleep hibernates the workflow into the fresh invocation this needs. + 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'); + } + + 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' }; + } +} + +// Records a same-phase continuation and enforces the ceiling. Completing any file resets the counter, so only a genuinely wedged job gets there. +async function continueOrFailWedgedJob( + env: ReviewRuntime, + job: PersistedReviewJob, + github: ReviewGitHub, + leaseOwner: string, + phase: 'prepare' | 'review' | 'finalize', + delaySeconds: number, + reason: string, +): Promise { + const continuationCount = await env.jobs.markJobContinuationQueued(job.id, delaySeconds); + + // Finalize fails fast instead of looping ~20 min; other phases make real per-file progress. + const ceiling = phase === 'finalize' ? MAX_FINALIZE_CONTINUATIONS : MAX_JOB_CONTINUATIONS; + + if (continuationCount > ceiling) { + if (phase === 'review') { + // Must RETURN the transition: enqueueJobPhase() throws, and this runs inside a catch. + logger.error(`Review job exceeded the continuation ceiling; degrading to a partial review: ${job.owner}/${job.repo} PR #${job.prNumber}`, { + phase, + continuationCount, + reason, + }); + // A file still awaiting an async batch would otherwise finalize as an empty 'successful'. + 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, + }); + } + // Finalize needs its own continuation budget: the counter is already past the ceiling. + 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); + // A subrequest-limit deferral saturated THIS instance; a transient model deferral did not. + 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 GitHubWebhookPayload | 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 GitHubWebhookPayload; + } + + if (!isSupportedGitHubWebhookEvent(eventName)) { + logger.info(`Queue message ignored: unsupported GitHub event ${eventName}`); + return null; + } + + const installationId = String(payload.installation?.id ?? ''); + 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.login, + repo: payload.repository.name, + }); + + if (repoConfig.enabled === false) { + logger.info(`Job ignored: repository ${payload.repository.owner.login}/${payload.repository.name} is disabled`); + return null; + } + + const extracted = extractReviewRequest({ + eventName, + payload, + botUsername: env.botUsername, + config: repoConfig.parsedJson, + }); + + if (!extracted) { + if (eventName === 'pull_request') { + const prPayload = payload as PullRequestWebhookPayload; + 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.login, + prPayload.repository.name, + prPayload.pull_request.number, + [labels.p1, labels.p2, labels.p3], + ); + } + } + return null; + } + + let resolved = extracted; + const githubClient = env.githubClients.forInstallation(installationId); + if (eventName === 'issue_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/src/server/core/review/pack.ts b/packages/core/src/review/pack.ts similarity index 98% rename from src/server/core/review/pack.ts rename to packages/core/src/review/pack.ts index 7b6455c9..cdd0848f 100644 --- a/src/server/core/review/pack.ts +++ b/packages/core/src/review/pack.ts @@ -1,5 +1,5 @@ // Groups small files into shared model calls, the inverse of chunkFileDiff, so a 4-line file does not pay the full ~2,800-token preamble. -import { renderFileDiff } from '@server/prompts/file-review'; +import { renderFileDiff } from '../prompts/file-review'; import type { FileDiff } from '../diff'; // Above this a file is reviewed alone -- it already amortises its own preamble. diff --git a/src/server/core/review/phase-control.ts b/packages/core/src/review/phase-control.ts similarity index 80% rename from src/server/core/review/phase-control.ts rename to packages/core/src/review/phase-control.ts index d1b0981c..3fa9ff00 100644 --- a/src/server/core/review/phase-control.ts +++ b/packages/core/src/review/phase-control.ts @@ -1,19 +1,12 @@ import { logger } from '../logger'; -import type { AppBindings } from '@server/env'; -import { - failJob, - getJobForProcessing, - heartbeatJobLease, - mapJob, - markJobCheckRunCompleted, - markJobContinuationQueued, -} from '@server/db/jobs'; -import type { GitHubService } from '../../services/github'; +import type { PersistedReviewJob, ReviewGitHub, ReviewRuntime } from '../ports'; // Sibling of core/review.ts -- import from that barrel, not from here. // THE LEAF OF THE REVIEW FAMILY: phase.ts and finalize.ts both need exports from here, so this module must import NOTHING from any other review-* sibling or import-x/no-cycle fires. -export type PersistedReviewJob = ReturnType; +// Re-exported so the review family keeps its single source for the job type. It resolves to +// JobSummary, which is exactly what mapJob returns; see the note on the port. +export type { PersistedReviewJob }; export const REVIEW_CHUNK_WALL_CLOCK_MS = 12 * 60 * 1000; export const JOB_LEASE_SECONDS = 15 * 60; @@ -35,9 +28,9 @@ export const MAX_JOB_CONTINUATIONS = 20; // Lower than review's: finalize either fits a fresh invocation's budget or it doesn't; the check-run reconciler recovers past that. export const MAX_FINALIZE_CONTINUATIONS = 3; -export async function heartbeatAndCheckSuperseded(env: AppBindings, jobId: string, leaseOwner: string) { - await heartbeatJobLease(env, jobId, leaseOwner, JOB_LEASE_SECONDS); - const currentJob = await getJobForProcessing(env, jobId); +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'); } @@ -50,12 +43,12 @@ export class NextPhaseError extends Error { } export async function enqueueJobPhase( - env: AppBindings, + env: ReviewRuntime, jobId: string, phase: 'prepare' | 'review' | 'finalize', delaySeconds = 0, ) { - await markJobContinuationQueued(env, jobId, delaySeconds); + await env.jobs.markJobContinuationQueued(jobId, delaySeconds); throw new NextPhaseError(phase, delaySeconds); } @@ -64,14 +57,14 @@ export function hasCompletedStep(job: PersistedReviewJob, stepName: string) { } export async function failJobAndCheckRun( - env: AppBindings, + env: ReviewRuntime, job: Pick, - github: Pick, + github: Pick, message: string, ) { // Must-not-lose write: marks the job terminal so it stops retrying, and eligible for completeTerminalCheckRuns if the GitHub call below fails. try { - await failJob(env, job.id, message); + 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; @@ -79,7 +72,7 @@ export async function failJobAndCheckRun( // Best-effort: the job is already durably marked failed above, and completeTerminalCheckRuns retries this later. try { - const latest = await getJobForProcessing(env, job.id); + 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, { @@ -88,7 +81,7 @@ export async function failJobAndCheckRun( title: 'Review failed', summary: message, }); - await markJobCheckRunCompleted(env, job.id); + 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/src/server/core/review/phase.ts b/packages/core/src/review/phase.ts similarity index 90% rename from src/server/core/review/phase.ts rename to packages/core/src/review/phase.ts index 5df56cfd..c84dc1a4 100644 --- a/src/server/core/review/phase.ts +++ b/packages/core/src/review/phase.ts @@ -1,16 +1,11 @@ import { logger } from '../logger'; import { defaultRepoConfig, REVIEW_CONCURRENCY_LIMITS, type ParsedReviewComment, type RepoConfig } from '@codra/schema'; -import type { AppBindings } from '@server/env'; -import { bulkInheritFileReviews, getFileReviewsForJobs, upsertFileReview } from '@server/db/file-reviews'; -import { markJobContinuationQueued, resetJobContinuationCount, updateJobStep } from '@server/db/jobs'; import { budgetAwareFileLimit } from './budget'; import { narrowUnit, planReviewUnits } from './pack'; import { reviewAndPersistBin } from './bin-runner'; import { getDiffFiles } from './diff-cache'; -import { GitHubService } from '../../services/github'; -import { isRetryableModelError, ModelService } from '../../services/model'; +import type { ReviewGitHub, ReviewModel, ReviewRuntime } from '../ports'; import { TokenTracker } from '../token-tracker'; -import { getReviewSettings } from '@server/db/app-settings'; import { type PersistedReviewJob, ASYNC_BATCH_POLL_DELAY_SECONDS, @@ -34,11 +29,11 @@ import { persistCompletedReview, persistFailedFileReview, reviewAndPersistFile } // Import via the core/review.ts barrel, not from here: several specs mock that specifier. export async function runReviewPhase( - env: AppBindings, + env: ReviewRuntime, job: PersistedReviewJob, leaseOwner: string, - github: GitHubService, - model: ModelService, + github: ReviewGitHub, + model: ReviewModel, tracker: TokenTracker, ) { if (!hasCompletedStep(job, 'Preparation')) { @@ -46,7 +41,7 @@ export async function runReviewPhase( return; } - await updateJobStep(env, job.id, 'Reviewing Files', { status: 'running' }); + await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'running' }); // One DB read and one GitHub read with nothing between them; two in flight cannot breach the subrequest cap. const [rejectedExemplars, pr] = await Promise.all([ @@ -60,7 +55,7 @@ export async function runReviewPhase( failureModelProviderPromise ??= resolveModelProviderName(env, failureModelId); return failureModelProviderPromise; }; - const { concurrencyLevel, maxFiles } = await getReviewSettings(env); + 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 configuredChunkFileLimit = REVIEW_CONCURRENCY_LIMITS[concurrencyLevel]; @@ -74,12 +69,12 @@ export async function runReviewPhase( if (reviewChunkFileLimit <= 0) { throw new Error('Subrequest budget for this invocation was exhausted before starting the next review chunk.'); } - const startedAt = Date.now(); + const startedAt = env.clock.now(); let processedThisChunk = 0; const jobIdsToQuery = [job.id]; if (job.retryOfJobId) jobIdsToQuery.push(job.retryOfJobId); - const allExistingReviews = await getFileReviewsForJobs(env, jobIdsToQuery); + const allExistingReviews = await env.fileReviews.getFileReviewsForJobs(jobIdsToQuery); type ExistingReview = (typeof allExistingReviews)[number]; const currentReviews = new Map(); const parentReviews = new Map(); @@ -102,7 +97,7 @@ export async function runReviewPhase( }); if (inheritablePaths.length > 0) { - const inheritedPaths = await bulkInheritFileReviews(env, { + const inheritedPaths = await env.fileReviews.bulkInheritFileReviews({ jobId: job.id, parentJobId: job.retryOfJobId, filePaths: inheritablePaths, @@ -140,7 +135,7 @@ export async function runReviewPhase( for (const unit of plannedBins) { // A bin is one unit (one model chain + one bulk write); counting its files would stop the chunk after a single bin. if (processedThisChunk >= reviewChunkFileLimit) break; - if (Date.now() - startedAt >= REVIEW_CHUNK_WALL_CLOCK_MS) 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)); @@ -218,7 +213,7 @@ export async function runReviewPhase( compactPrompt: (existingReview?.transient_error_count ?? 0) > 0, }); if (submitted) { - await upsertFileReview(env, job.id, { + await env.fileReviews.upsertFileReview(job.id, { filePath: file.path, fileStatus: 'pending', modelUsed: submitted.model, @@ -251,7 +246,7 @@ export async function runReviewPhase( await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview, rejectedExemplars); terminalProgress += 1; } else { - await upsertFileReview(env, job.id, { + await env.fileReviews.upsertFileReview(job.id, { filePath: file.path, fileStatus: 'done', modelUsed: inherited.model_used, @@ -278,7 +273,7 @@ export async function runReviewPhase( // A poll is one subrequest, not a review: charging it would strand every in-flight batch. if (!awaitingReview) processedThisChunk += 1; - if (Date.now() - startedAt >= REVIEW_CHUNK_WALL_CLOCK_MS) { + if (env.clock.now() - startedAt >= REVIEW_CHUNK_WALL_CLOCK_MS) { break; } } @@ -288,7 +283,7 @@ export async function runReviewPhase( // Only terminal rows count as progress; a submit/poll-only chunk must not reset the counter. if (terminalProgress > 0) { - await resetJobContinuationCount(env, job.id); + await env.jobs.resetJobContinuationCount(job.id); } // Before the throw paths on purpose: a chunk that defers is exactly when waste is highest. @@ -308,7 +303,7 @@ export async function runReviewPhase( }); // Surface as a single error so the orchestrator reschedules instead of failing on AggregateError. - const deferrableError = rejected.map(r => r.reason).find(r => isRetryableModelError(r) || isSubrequestBudgetError(r)); + const deferrableError = rejected.map(r => r.reason).find(r => env.modelErrors.isRetryableModelError(r) || isSubrequestBudgetError(r)); if (deferrableError) { throw deferrableError; } @@ -318,7 +313,7 @@ export async function runReviewPhase( : new AggregateError(rejected.map((result) => result.reason), `${rejected.length} review chunk tasks failed`); } - const latestReviews = await getFileReviewsForJobs(env, [job.id]); + const latestReviews = await env.fileReviews.getFileReviewsForJobs([job.id]); // Exclude files awaiting async results so the job doesn't finalize with pending reviews. const reviewedPaths = new Set( latestReviews.flatMap((review) => ( @@ -328,7 +323,7 @@ export async function runReviewPhase( const completedCount = files.filter((file) => reviewedPaths.has(file.path)).length; if (completedCount >= files.length) { - await updateJobStep(env, job.id, 'Reviewing Files', { status: 'done' }); + await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'done' }); // Finalize needs a fresh budget: TokenTracker under-reports usage, so a conditional yield let finalize die with "Too many subrequests". await enqueueJobPhase(env, job.id, 'finalize', FRESH_INVOCATION_YIELD_SECONDS); return; @@ -336,7 +331,7 @@ export async function runReviewPhase( // Only in-flight batches left: poll after a delay, degrading to a partial review if they never land. if (awaitingAsync > 0 && terminalProgress === 0) { - const pollCount = await markJobContinuationQueued(env, job.id, ASYNC_BATCH_POLL_DELAY_SECONDS); + 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)) { @@ -348,7 +343,7 @@ export async function runReviewPhase( clearAsync: true, }); } - await updateJobStep(env, job.id, 'Reviewing Files', { status: 'done' }); + 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); diff --git a/src/server/core/review/prepare.ts b/packages/core/src/review/prepare.ts similarity index 69% rename from src/server/core/review/prepare.ts rename to packages/core/src/review/prepare.ts index 585a9dc8..bf65e547 100644 --- a/src/server/core/review/prepare.ts +++ b/packages/core/src/review/prepare.ts @@ -1,34 +1,24 @@ import { logger } from '../logger'; import { defaultRepoConfig, type RepoConfig } from '@codra/schema'; -import type { AppBindings } from '@server/env'; -import { - completePreparationStep, - heartbeatJobLease, - setJobPullRequestMeta, - updateJobCheckRun, - updateJobStep, -} from '@server/db/jobs'; +import type { ReviewGitHub, ReviewRuntime } from '../ports'; import { getDiffFiles } from './diff-cache'; -import { getRejectedExemplars, getRepositoryIdForJob } from '@server/db/learning'; -import type { RejectedExemplar } from '@server/prompts/file-review'; -import { GitHubService } from '../../services/github'; -import { getReviewSettings } from '@server/db/app-settings'; +import type { RejectedExemplar } from '../prompts/file-review'; import { type PersistedReviewJob, JOB_LEASE_SECONDS, FRESH_INVOCATION_YIELD_SECONDS, enqueueJobPhase } from './phase-control'; // Sibling of core/review.ts -- import from that barrel, not from here. export async function runPreparePhase( - env: AppBindings, + env: ReviewRuntime, job: PersistedReviewJob, leaseOwner: string, - github: GitHubService, + github: ReviewGitHub, ) { - await updateJobStep(env, job.id, 'Preparation', { status: 'running' }); + 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; // Refresh cached PR title/author: these are snapshotted at job creation and copied onto retries, so a title edited on GitHub afterwards would otherwise stay stale. try { - await setJobPullRequestMeta(env, job.id, { + await env.jobs.setJobPullRequestMeta(job.id, { prTitle: pr.title ?? null, prAuthor: pr.user?.login ?? null, }); @@ -44,16 +34,16 @@ export async function runPreparePhase( summary: 'Codra has started reviewing this pull request.', }); checkRunId = checkRun.id; - await updateJobCheckRun(env, job.id, checkRun.id); + await env.jobs.updateJobCheckRun(job.id, checkRun.id); } - const { maxFiles } = await getReviewSettings(env); + const { maxFiles } = await env.settings.getReviewSettings(); const { files } = await getDiffFiles(env, job, github, config, maxFiles); - await completePreparationStep(env, job.id, files.length); - await heartbeatJobLease(env, job.id, leaseOwner, JOB_LEASE_SECONDS); + await env.jobs.completePreparationStep(job.id, files.length); + await env.jobs.heartbeatJobLease(job.id, leaseOwner, JOB_LEASE_SECONDS); if (files.length === 0) { - await updateJobStep(env, job.id, 'Reviewing Files', { status: 'done' }); + await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'done' }); await enqueueJobPhase(env, job.id, 'finalize', FRESH_INVOCATION_YIELD_SECONDS); return; } @@ -74,11 +64,11 @@ export async function runPreparePhase( } // Negative few-shot exemplars for this repository. Best-effort, and per chunk so it costs one query rather than one per file. -export async function loadRejectedExemplars(env: AppBindings, job: PersistedReviewJob): Promise { +export async function loadRejectedExemplars(env: Pick, job: PersistedReviewJob): Promise { try { - const repositoryId = await getRepositoryIdForJob(env, job.id); + const repositoryId = await env.learning.getRepositoryIdForJob(job.id); if (repositoryId === null) return []; - const rows = await getRejectedExemplars(env, { repositoryId, limit: 5 }); + 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', { diff --git a/src/server/core/review/request.ts b/packages/core/src/review/request.ts similarity index 100% rename from src/server/core/review/request.ts rename to packages/core/src/review/request.ts diff --git a/src/server/core/review/retry-policy.ts b/packages/core/src/review/retry-policy.ts similarity index 93% rename from src/server/core/review/retry-policy.ts rename to packages/core/src/review/retry-policy.ts index 20a1c549..62343ce9 100644 --- a/src/server/core/review/retry-policy.ts +++ b/packages/core/src/review/retry-policy.ts @@ -1,8 +1,7 @@ import { logger } from '../logger'; import { normalizeModelId, type RepoConfig } from '@codra/schema'; import { isSubrequestBudgetMessage, isTimeoutMessage, matchesAnyTransientSubstring } from '@codra/schema/transient-errors'; -import type { AppBindings } from '@server/env'; -import { getResolvedModelConfig } from '@server/db/model-configs'; +import type { ReviewRuntime } from '../ports'; import { RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS } from './phase-control'; // Sibling of core/review.ts -- import from that barrel, not from here. @@ -97,11 +96,11 @@ export function canInheritParentFileReview(config: RepoConfig, review: { model_u return configuredModelSet(config).has(bareModelId(review.model_used)); } -export async function resolveModelProviderName(env: Pick, modelId: string | null | undefined) { +export async function resolveModelProviderName(env: Pick, modelId: string | null | undefined) { if (!modelId || modelId === 'unconfigured') return null; try { - const resolved = await getResolvedModelConfig(env, normalizeModelId(modelId)); + const resolved = await env.modelConfigs.getResolvedModelConfig(normalizeModelId(modelId)); return resolved?.providerName ?? null; } catch (error) { logger.warn(`Failed to resolve provider for model ${modelId}`, { diff --git a/src/server/core/review/telemetry.ts b/packages/core/src/review/telemetry.ts similarity index 88% rename from src/server/core/review/telemetry.ts rename to packages/core/src/review/telemetry.ts index eb37bb79..deceec19 100644 --- a/src/server/core/review/telemetry.ts +++ b/packages/core/src/review/telemetry.ts @@ -1,14 +1,12 @@ import { logger } from '../logger'; -import type { AppBindings } from '@server/env'; -import { getSuppressedFindings } from '@server/db/file-reviews'; -import { sendTelemetryEvent } from '../telemetry'; +import type { ReviewRuntime } from '../ports'; import { type PersistedReviewJob } from './phase-control'; import { bareModelId } from './retry-policy'; // Sibling of core/review.ts -- import from that barrel, not from here. // Success/all-failed fields come in as `overrides`. Token/model data comes from `done` reviews only, so failed or inherited rows don't deflate totals. export async function sendReviewTelemetry( - env: AppBindings, + 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 }>, @@ -34,7 +32,7 @@ export async function sendReviewTelemetry( return name.slice(dotIndex + 1).toLowerCase(); }; - await sendTelemetryEvent(env, { + 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), @@ -44,7 +42,7 @@ export async function sendReviewTelemetry( return extension ? [extension] : []; }))), triggerType: job.trigger, - reviewDurationMs: Math.max(0, Date.now() - new Date(job.createdAt).getTime()), + 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), @@ -57,7 +55,7 @@ export async function sendReviewTelemetry( } // `posted` requires both fingerprint and anchor hash to match, so an edit to the flagged line re-raises it; `rejected` suppresses on fingerprint alone. -export async function loadSuppressedFingerprints(env: AppBindings, jobId: string) { +export async function loadSuppressedFingerprints(env: Pick, jobId: string) { const posted = new Map>(); const rejected = new Set(); // v2 already contains the anchor hash, so membership alone means "same file, same claim class, byte-identical line". @@ -65,7 +63,7 @@ export async function loadSuppressedFingerprints(env: AppBindings, jobId: string const rejectedV2 = new Set(); try { - for (const row of await getSuppressedFindings(env, jobId)) { + 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); diff --git a/packages/core/src/rules/detect.ts b/packages/core/src/rules/detect.ts new file mode 100644 index 00000000..8a3d4cac --- /dev/null +++ b/packages/core/src/rules/detect.ts @@ -0,0 +1,159 @@ +import type { ClaimType, ParsedReviewComment } from '@codra/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 '@codra/schema'; +import { RULES, type Rule } from './table'; + +// Cap on added lines scanned per file: the binding constraint is the 10ms CPU budget, not memory. Reported as `truncated` rather than silently applied. +const MAX_RULE_SCAN_ADDED_LINES = 600; + +export type RuleHit = { + rule: Rule; + line: DiffLine; + // Set when the rule is in shadow mode: counted and logged, never turned into a comment. + shadow: boolean; +}; + +export type RuleScanStats = { + addedLinesScanned: number; + // Lines that passed the cheap substring sieve and were actually stripped + regex-tested. + sievePassed: number; + hits: number; + shadowHits: number; + // Hits discarded because the identical line already existed as a `del` - the PR only moved it. + suppressedAsMoved: number; + // Lines the stripper refused to scan (unterminated quote / unclosed block comment). + 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); +} + +// Zero subrequests and no model call: this channel still produces findings when the LLM returns nothing or the file's review fails outright. +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 }; + + // One flat sieve over every active rule's triggers: cheap substring checks reject >95% of lines before regexes run. + const triggers = [...new Set(active.flatMap((rule) => rule.triggers))]; + const syntax = commentSyntaxFor(file.path); + + for (const hunk of file.hunks) { + // Same discipline as buildPresenceIndex: collected per hunk so reformat-move suppression can compare within the same window. + 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; + + // The "defect" pre-existed and the PR only moved or reindented the line. + 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; + // One hit per line: two rules firing on one line would post two comments at one anchor. + break; + } + } + if (stats.truncated) break; + } + + return { hits, stats }; +} + +// Turns rule hits into the same `ParsedReviewComment` shape the LLM channel produces, so downstream stages treat them uniformly. +// The fingerprint deliberately includes the anchor hash: a rule's title is a CONSTANT, so two hits of one rule in one file would otherwise collide on a single fingerprint identity. +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 new file mode 100644 index 00000000..af5234c5 --- /dev/null +++ b/packages/core/src/rules/table.ts @@ -0,0 +1,149 @@ +import type { ClaimType, reviewSeverities } from '@codra/schema'; + +type ReviewSeverity = typeof reviewSeverities[number]; + +// Deterministic rules, the second finding channel: models GENERATE at F1 0.07-0.37 but TRIAGE pre-grounded candidates at 0.88-0.96, so rules propose and the model judges. +export type Rule = { + id: string; + claimType: ClaimType; + severity: ReviewSeverity; + title: string; + body: string; + // Cheap substrings: absent from the raw line, the rule is never considered. + triggers: readonly string[]; + // Runs against the stripped line. Must not backtrack catastrophically. + pattern: RegExp; + // Veto against the RAW line, where stripping destroys the evidence that clears a hit: a block comment + // becomes a space, so an intentionally-empty catch looks genuinely empty. + rejectRaw?: RegExp; + // File extensions this applies to. Empty means all. + extensions?: readonly string[]; + // Tier-2 ships disabled: reviewable code, untrusted rule. + 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*\}/, + // A documented empty catch is deliberate. Checked on the RAW line: the stripper collapses the comment + // to a space and the block looks empty. + 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'], + // Non-literal right-hand side only: the stripper removes literals, so `= ''` cannot match, `= html` can. + 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'], + // DROP COLUMN/TABLE/TRUNCATE only. Not DROP INDEX/CONSTRAINT/DEFAULT/NOT NULL: they discard no rows + // and this repo's migrations use them routinely. + pattern: /\b(?:drop\s+(?:column|table)|truncate\s+table|truncate\s+\w)/i, + extensions: ['sql'], + enabled: true, + }, + + // ── Tier 2: shipped but disabled ──────────────────────────────────────────────────────────── + + { + 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/, + // Disabled: the stripper removes literals, where credentials live, so only unquoted tokens fire. Needs a different scanning mode, not a different regex. + 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, + // Disabled: the name heuristic is the whole rule, and a test fixture or React key is a false positive. + enabled: false, + }, +]; + +// NOT SHIPPED, `sql-string-concat`: the stripper deletes literals, so a safe tagged `sql` template is indistinguishable from real concatenation. Telling them apart needs a parse, not a regex. diff --git a/packages/core/src/timeout.ts b/packages/core/src/timeout.ts new file mode 100644 index 00000000..703f24a4 --- /dev/null +++ b/packages/core/src/timeout.ts @@ -0,0 +1,22 @@ +export class TimeoutError extends Error { + constructor(label: string, ms: number) { + super(`${label} timed out after ${ms}ms`); + this.name = 'TimeoutError'; + } +} + +export async function withTimeout(label: string, ms: number, fn: (signal: AbortSignal) => Promise): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ms); + + try { + return await fn(controller.signal); + } catch (err: any) { + if (controller.signal.aborted || err?.name === 'AbortError') { + throw new TimeoutError(label, ms); + } + throw err; + } finally { + clearTimeout(timer); + } +} diff --git a/packages/core/src/token-tracker.ts b/packages/core/src/token-tracker.ts new file mode 100644 index 00000000..3b61295e --- /dev/null +++ b/packages/core/src/token-tracker.ts @@ -0,0 +1,131 @@ +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'; + +// Prompts we paid to transmit but got nothing back for. Estimated, never billed: a failed call +// returns no usageMetadata, so this is `estimatePromptTokens` output and must not be compared to a +// provider's own promptTokenCount as an equal. +// +// `estimatedInput` is a token count but must NOT be named `...Tokens`: logger.ts redacts any key +// whose name contains "token", so the field would log as [REDACTED] and the metric would be useless. +export interface WastedUsage { + attempts: number; + estimatedInput: number; + skips: number; + byReason: Record; +} + +export class TokenTracker { + private usage: Map = new Map(); + // Kept out of `usage` so estimates can never leak into billed accounting or telemetry. + private wasted = { attempts: 0, estimatedInput: 0, skips: 0 }; + private wastedByReason: Map = new Map(); + private subrequests = 0; + private readonly MAX_SUBREQUESTS = 50; + // Covers untracked Hyperdrive queries per chunk (lease heartbeats, review reads/writes, etc.) that the tracker never sees. + 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; + } + + // Subrequests left before crossing into the reserved safety margin below Cloudflare's per-invocation cap; size variable concurrent work against this instead of a fixed constant. + 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 + }); + } + + // A full prompt went over the wire and produced no reviewable response. + 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 }); + } + + // A prompt we did NOT send because a gate already knew it would fail -- the positive signal that + // cooldown learning is working, and the counterpart to recordFailedAttempt. + 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/src/verify.ts b/packages/core/src/verify.ts new file mode 100644 index 00000000..4a97e869 --- /dev/null +++ b/packages/core/src/verify.ts @@ -0,0 +1,20 @@ +import { hexToBytes } from '@codra/schema/hex'; + +const encoder = new TextEncoder(); + +export async function verifyGitHubWebhookSignature(secret: string, headerValue: string | null, rawBody: string) { + if (!headerValue?.startsWith('sha256=')) { + return false; + } + + const signature = headerValue.slice('sha256='.length); + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'], + ); + + return crypto.subtle.verify('HMAC', key, hexToBytes(signature), encoder.encode(rawBody)); +} diff --git a/packages/core/test/in-memory.ts b/packages/core/test/in-memory.ts new file mode 100644 index 00000000..484ca4c8 --- /dev/null +++ b/packages/core/test/in-memory.ts @@ -0,0 +1,420 @@ +// A complete in-memory ReviewRuntime: every port the engine takes, implemented with plain maps and +// canned model output. No Postgres, no Miniflare, no network, no clock. +// +// This is the demonstration that the extraction actually worked. If the engine ever regains a +// dependency on a database, a platform binding or a git provider, THIS file stops being enough to +// drive it, and the spec next door fails. + +import { defaultRepoConfig, reviewSettingsSchema, type ParsedReviewComment, type RepoConfig, type ReviewSettings } from '@codra/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, + }; +} + +// A two-file unified diff, small enough that the planner packs it into one bin. +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); + + // Advanced explicitly by tests; never reads the wall clock, so durations are deterministic. + const now = { value: 1_700_000_000_000 }; + + const settings = reviewSettingsSchema.parse({ maxFiles: 25, ...seed.settings }); + + // The engine only ever reads `status` and `check_run_id` off a row, and hands it back to mapJob. + 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, + + 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, botUsername) => `Reviewed ${commitSha.slice(0, 7)} by ${botUsername}`, + }), + + 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 }; +} + +// The engine constructs a tracker and passes it to the github/model factories, which ignore it here. +// Stands in for the real TokenTracker so the fake runtime needs no import from the engine's internals. +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 new file mode 100644 index 00000000..0a65c345 --- /dev/null +++ b/packages/core/test/logger.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from 'vitest'; +import { consoleLogger, formatLogRecord, logger, redact, scrubString, setLoggerSink } from '../src/logger'; + +// Redaction had no coverage at all before the logger split, and it is load-bearing in both +// directions: src/server/core/token-tracker.ts and src/server/models/google.ts both document +// workarounds for the `token` key being redacted. These tests pin the behaviour so the move cannot +// change it silently. +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', () => { + // The predecessor check was "contains exactly two periods", which deleted file paths while + // missing real JWTs. Both halves of that regression are pinned here. + 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]', + // `token` is a substring of this key, which is exactly why the token tracker logs its counts + // under names that avoid it. + 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/review-in-memory.spec.ts b/packages/core/test/review-in-memory.spec.ts new file mode 100644 index 00000000..c94e5a47 --- /dev/null +++ b/packages/core/test/review-in-memory.spec.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { runReview, type ReviewJobRunResult } from '../src'; +import { setLoggerSink } from '../src/logger'; +import { createInMemoryRuntime } from './in-memory'; + +// The acceptance criterion for extracting @codra/core: the engine runs a review end to end against +// in-memory ports alone. No Postgres, no Miniflare, no Worker, no network, no wall clock. +// +// Note what is NOT here: no vi.mock, no module interception, no test database, no fetch stub. The +// engine is driven purely through the ReviewRuntime it declares, which is the whole point. + +beforeEach(() => { + // Quiet, and it proves the Logger port is honoured rather than console being reached for directly. + 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); + + // The driver contract: two hand-offs, then an ack. + expect(results.map((r) => r.action)).toEqual(['next_phase', 'next_phase', 'ack']); + expect(results[0]).toMatchObject({ action: 'next_phase', phase: 'review' }); + // Finalize demands a fresh instance so it starts on a clean subrequest budget. + 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'); + + // Both diff files were reviewed and persisted. + expect([...recorded.fileReviews.keys()].sort()).toEqual(['src/log.ts', 'src/retry.ts']); + expect([...recorded.fileReviews.values()].every((row) => row.file_status === 'done')).toBe(true); + + // The finding reached GitHub as an inline comment. + expect(recorded.postedReviews).toHaveLength(1); + expect(recorded.postedReviews[0].comments).toEqual([ + { path: 'src/retry.ts', body: expect.stringContaining('Hard-coded delay') }, + ]); + expect(recorded.postedReviews[0].body).toContain('codra-bot'); + + // The check run was opened and closed, and telemetry was emitted exactly once. + 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'); + // One release per phase: nothing may return while still holding it. + 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']); + // The gate ran rather than being skipped, which is what makes the contrast below meaningful. + expect(kept.recorded.calls.some((call) => call === 'verifyFindings:2')).toBe(true); + + // Same input, one verdict flipped to 'drop': exactly one finding survives to the pull request. + 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); + // The dropped one is recorded with its disposition rather than silently vanishing. + 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); + + // Three phases each need the diff; only the first pays for it. This is the whole reason the + // KvStore port exists, and it is asserted here with a Map rather than a KV namespace. + 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'); + // Every file failed, so the job completes as a failure rather than a clean approval. + 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' }); + // Nothing was posted to the pull request. + 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/tsconfig.json b/packages/core/tsconfig.json index 6f8970b3..608f0e1c 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,8 +1,23 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": {}, - "include": ["src/**/*"], - "references": [ - { "path": "../schema" } - ] + "compilerOptions": { + // The base sets ES2024 only. The engine reaches for AbortController/AbortSignal (timeout.ts), + // crypto.subtle + TextEncoder (verify.ts), console (logger.ts) and fetch types -- all of which + // live in the DOM lib. Deliberately NOT @cloudflare/workers-types: KVNamespace must never + // resolve inside this package, which is what forces the KvStore port to exist. + "lib": ["ES2024", "DOM"], + "types": ["node"], + + // No emit, and therefore no project reference to ../schema. Packages here have no build step: + // @codra/schema is consumed as raw TS source through its `exports` map, exactly as the root + // program consumes it. Keeping the base's composite/declaration settings would instead demand + // that schema be built to dist/ first (TS6305) -- a build nothing in this repo performs. + // This project exists to typecheck the package against a NARROWER lib/types than the root + // program, which is what catches a stray KVNamespace or hono import. + "composite": false, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true + }, + "include": ["src/**/*", "test/**/*"] } diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 00000000..e47d01a9 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.spec.ts'], + environment: 'node', + // Deliberately NO setupFiles: the root test/setup.ts hard-fails when TEST_DATABASE_URL is + // unset, and this suite exists to prove the engine runs on in-memory ports with no Postgres. + // Borrowing that setup would defeat the point of it. + // globals: false to match the package tsconfig, which does not pull in vitest/globals -- specs + // here import describe/it/expect from 'vitest' explicitly. + globals: false, + }, +}); diff --git a/packages/schema/tsconfig.json b/packages/schema/tsconfig.json index cab8ed24..6d7be88c 100644 --- a/packages/schema/tsconfig.json +++ b/packages/schema/tsconfig.json @@ -1,5 +1,11 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": {}, + "compilerOptions": { + // tsconfig.base.json's `outDir` resolves relative to the base file itself, i.e. the repo root, so + // every package extending it inherits the SAME output dir. Two composite packages then collide + // over one dist/tsconfig.tsbuildinfo (TS6377). Nothing builds this package today -- it is consumed + // as TS source -- but the override keeps that latent collision from resurfacing. + "outDir": "dist" + }, "include": ["src/**/*"] } diff --git a/scripts/check-core-boundary.mjs b/scripts/check-core-boundary.mjs new file mode 100644 index 00000000..68bdeadc --- /dev/null +++ b/scripts/check-core-boundary.mjs @@ -0,0 +1,107 @@ +// Asserts the @codra/core purity criterion: the review engine must not depend on hono, postgres, +// wrangler types, or any git-provider SDK, and must not reach back into the legacy src/ tree. +// +// This exists alongside eslint's import-x/no-restricted-paths because that rule only sees file +// paths. It cannot see an npm dependency added to packages/core/package.json, and -- the case that +// actually matters -- it does not object to `import type { AppBindings } from '...'`, which leaves +// no runtime trace and would silently reintroduce the platform coupling this extraction removes. +// So this script checks the manifest AND bans the identifiers by name. + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +const ROOT = join(import.meta.dirname, '..'); +const PKG = join(ROOT, 'packages/core'); + +const BANNED_DEPS = ['hono', 'postgres', 'wrangler', '@cloudflare/workers-types', '@octokit/rest', '@octokit/core']; + +// Import specifiers no file in the package may name. +const BANNED_SPECIFIERS = [ + "from 'hono'", + "from 'postgres'", + "from 'cloudflare:workers'", + "from 'node:async_hooks'", + "from '@server/", + "from '@client/", + "from '@codra/worker", + '../../src/', + '../../../src/', +]; + +// Types and classes whose presence means a port was bypassed. Type-only imports of these are the +// exact regression this half of the check is for. +const BANNED_IDENTIFIERS = [ + 'AppBindings', + 'KVNamespace', + 'HyperdriveBinding', + 'GitHubService', + 'GitHubClient', + 'ModelService', + 'FormatterService', + 'queryRows', + 'runWithDb', +]; + +const failures = []; + +const manifest = JSON.parse(readFileSync(join(PKG, 'package.json'), 'utf8')); +for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) { + for (const name of Object.keys(manifest[field] ?? {})) { + if (BANNED_DEPS.includes(name)) { + failures.push(`packages/core/package.json: ${field} must not include "${name}"`); + } + } +} + +function* walk(dir) { + let entries; + try { + entries = readdirSync(dir); + } catch { + return; // test/ may not exist yet + } + for (const entry of entries) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) { + yield* walk(path); + } else if (path.endsWith('.ts') || path.endsWith('.tsx')) { + yield path; + } + } +} + +for (const dir of ['src', 'test']) { + for (const file of walk(join(PKG, dir))) { + const source = readFileSync(file, 'utf8'); + const where = relative(ROOT, file).replaceAll('\\', '/'); + + for (const specifier of BANNED_SPECIFIERS) { + if (source.includes(specifier)) { + failures.push(`${where}: must not import ${specifier.replace("from '", '').replace(/'$/, '')}`); + } + } + + for (const identifier of BANNED_IDENTIFIERS) { + // Word-boundary match so `ModelServiceOptions` or a comment mentioning the old name in prose + // does not trip it; an actual usage always appears as a bare identifier. + if (new RegExp(`\\b${identifier}\\b`).test(stripComments(source))) { + failures.push(`${where}: must not reference "${identifier}" -- take a port instead`); + } + } + } +} + +// Comments in core legitimately explain what a port replaced ("was env.BOT_USERNAME", "mirrors the +// GitHubService surface"), so the identifier scan runs over code only. +function stripComments(source) { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1'); +} + +if (failures.length > 0) { + console.error('@codra/core boundary check failed:\n'); + for (const failure of failures) console.error(` - ${failure}`); + console.error(`\n${failures.length} violation(s). The engine must depend on ports only; implementations live in src/server/adapters.`); + process.exit(1); +} + +console.log('@codra/core boundary check passed: no hono/postgres/wrangler/git-provider dependency, no reach back into src/.'); diff --git a/src/server/adapters/file-review-store.ts b/src/server/adapters/file-review-store.ts new file mode 100644 index 00000000..7ab97704 --- /dev/null +++ b/src/server/adapters/file-review-store.ts @@ -0,0 +1,31 @@ +import type { FileReviewStore } from '@codra/core/ports'; +import type { AppBindings } from '@server/env'; +import { + bulkInheritFileReviews, + bulkMarkFilesFailed, + bulkRecordRetryableFileReviewFailures, + bulkUpsertFileReviews, + getFileReviewsForJobs, + getSuppressedFindings, + markCommentDispositions, + markCommentsPosted, + recordRetryableFileReviewFailure, + upsertFileReview, +} from '@server/db/file-reviews'; + +export function makeFileReviewStore(env: AppBindings): FileReviewStore { + return { + upsertFileReview: (jobId, input) => upsertFileReview(env, jobId, input), + recordRetryableFileReviewFailure: (jobId, input) => recordRetryableFileReviewFailure(env, jobId, input), + getFileReviewsForJobs: (jobIds) => getFileReviewsForJobs(env, jobIds), + + bulkInheritFileReviews: (input) => bulkInheritFileReviews(env, input), + bulkUpsertFileReviews: (jobId, inputs) => bulkUpsertFileReviews(env, jobId, inputs), + bulkRecordRetryableFileReviewFailures: (jobId, inputs, opts) => bulkRecordRetryableFileReviewFailures(env, jobId, inputs, opts), + bulkMarkFilesFailed: (jobId, files, opts) => bulkMarkFilesFailed(env, jobId, files, opts), + + getSuppressedFindings: (jobId) => getSuppressedFindings(env, jobId), + markCommentsPosted: (jobId, fingerprints) => markCommentsPosted(env, jobId, fingerprints), + markCommentDispositions: (jobId, byFingerprint) => markCommentDispositions(env, jobId, byFingerprint), + }; +} diff --git a/src/server/adapters/index.ts b/src/server/adapters/index.ts new file mode 100644 index 00000000..3135aea0 --- /dev/null +++ b/src/server/adapters/index.ts @@ -0,0 +1,56 @@ +import type { ReviewRuntime } from '@codra/core/ports'; +import { TokenTracker } from '@codra/core/token-tracker'; +import type { AppBindings } from '@server/env'; +// Imported for its side effect as well as never: this installs the AsyncLocalStorage-backed logger as +// the sink @codra/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 { cryptoIds, makeKvStore, makeTelemetrySink, systemClock } from './platform'; +import { makeJobStore } from './jobs-store'; +import { makeFileReviewStore } from './file-review-store'; +import { + makeLearningStore, + makeModelConfigReader, + makeRepoConfigLoader, + makeReviewSettingsReader, + makeWebhookDeliveryReader, +} from './settings-store'; +import { + makeFormatterFactory, + makeGitHubClientFactory, + makeGitHubFactory, + makeModelErrorClassifier, + makeModelFactory, +} from './services'; + +// The composition root: the one place Cloudflare bindings, Postgres and the GitHub/model services are +// wired to the engine's ports. @codra/core sees this object and nothing else. +// +// Called once per Worker invocation, before the job is known. It only allocates closures, so it is +// cheap enough to build on the webhook path too. +export function createReviewRuntime(env: AppBindings): ReviewRuntime { + return { + kv: makeKvStore(env), + clock: systemClock, + ids: cryptoIds, + + botUsername: env.BOT_USERNAME, + + jobs: makeJobStore(env), + fileReviews: makeFileReviewStore(env), + settings: makeReviewSettingsReader(env), + webhooks: makeWebhookDeliveryReader(env), + learning: makeLearningStore(env), + modelConfigs: makeModelConfigReader(env), + repoConfig: makeRepoConfigLoader(env), + telemetry: makeTelemetrySink(env), + + createTokenTracker: () => new TokenTracker(), + createGitHub: makeGitHubFactory(env), + createModel: makeModelFactory(env), + createFormatter: makeFormatterFactory(env), + + githubClients: makeGitHubClientFactory(env), + modelErrors: makeModelErrorClassifier(), + }; +} diff --git a/src/server/adapters/jobs-store.ts b/src/server/adapters/jobs-store.ts new file mode 100644 index 00000000..4aff94f9 --- /dev/null +++ b/src/server/adapters/jobs-store.ts @@ -0,0 +1,78 @@ +import type { JobLeaseClaim as CoreJobLeaseClaim, JobRow as CoreJobRow, JobStore, PersistedReviewJob } from '@codra/core/ports'; +import type { AppBindings } from '@server/env'; +import { + claimJobLease, + completeJob, + completePreparationStep, + failJob, + findExistingJobForHead, + getJobForProcessing, + getOtherRunningJobsCount, + heartbeatJobLease, + insertJob, + mapJob, + markJobCheckRunCompleted, + markJobContinuationQueued, + releaseJobLease, + resetJobContinuationCount, + setJobPullRequestMeta, + setJobWorkflowInstance, + supersedeOlderJobs, + updateJobCheckRun, + updateJobStep, + type JobRow, +} from '@server/db/jobs'; + +// Pins PersistedReviewJob to what mapJob actually returns, in both directions. mapJob ends in +// jobSummarySchema.parse(), so the two are already the same type -- this makes that a compile error +// to break rather than something to notice later. +type _PinPersistedReviewJob = ReturnType extends PersistedReviewJob + ? PersistedReviewJob extends ReturnType ? true : never + : never; +const _pinPersistedReviewJob: _PinPersistedReviewJob = true; +void _pinPersistedReviewJob; + +// JobLeaseClaim is the one port contract that stays hand-copied rather than re-exported: the db +// version carries the FULL jobs row, which the engine must not see, so the two cannot be the same +// type. This pins the part that matters -- the discriminant set and the extra `busy` field -- so +// adding a fifth status on the db side is a compile error here rather than a silent fall-through in +// the engine's claim ladder. +type _PinLeaseStatuses = Awaited>['status'] extends CoreJobLeaseClaim['status'] + ? CoreJobLeaseClaim['status'] extends Awaited>['status'] ? true : never + : never; +const _pinLeaseStatuses: _PinLeaseStatuses = true; +void _pinLeaseStatuses; + +type _PinBusyRetryField = Extract>, { status: 'busy' }>['retryAfterSeconds'] extends number ? true : never; +const _pinBusyRetryField: _PinBusyRetryField = true; +void _pinBusyRetryField; + +export function makeJobStore(env: AppBindings): JobStore { + return { + // The one cast in the extraction. The db row type flows INTO core's JobRow freely (it is an + // object-literal alias, so TS gives it an implicit index signature); only the return leg needs + // telling that a row core handed back is the same row it was given. + mapJob: (row: CoreJobRow) => mapJob(row as unknown as JobRow), + + getJobForProcessing: (jobId) => getJobForProcessing(env, jobId), + claimJobLease: (jobId, leaseOwner, leaseSeconds) => claimJobLease(env, jobId, leaseOwner, leaseSeconds), + heartbeatJobLease: (jobId, leaseOwner, leaseSeconds) => heartbeatJobLease(env, jobId, leaseOwner, leaseSeconds), + releaseJobLease: (jobId, leaseOwner) => releaseJobLease(env, jobId, leaseOwner), + markJobContinuationQueued: (jobId, delaySeconds) => markJobContinuationQueued(env, jobId, delaySeconds), + resetJobContinuationCount: (jobId) => resetJobContinuationCount(env, jobId), + getOtherRunningJobsCount: (excludeJobId) => getOtherRunningJobsCount(env, excludeJobId), + + setJobWorkflowInstance: (jobId, workflowInstanceId) => setJobWorkflowInstance(env, jobId, workflowInstanceId), + setJobPullRequestMeta: (jobId, meta) => setJobPullRequestMeta(env, jobId, meta), + insertJob: (input) => insertJob(env, input), + findExistingJobForHead: (input) => findExistingJobForHead(env, input), + + updateJobCheckRun: (jobId, checkRunId) => updateJobCheckRun(env, jobId, checkRunId), + markJobCheckRunCompleted: (jobId) => markJobCheckRunCompleted(env, jobId), + completePreparationStep: (jobId, fileCount) => completePreparationStep(env, jobId, fileCount), + updateJobStep: (jobId, stepName, update) => updateJobStep(env, jobId, stepName, update), + completeJob: (jobId, input) => completeJob(env, jobId, input), + failJob: (jobId, errorMessage) => failJob(env, jobId, errorMessage), + supersedeOlderJobs: (input) => supersedeOlderJobs(env, input), + }; +} diff --git a/src/server/adapters/platform.ts b/src/server/adapters/platform.ts new file mode 100644 index 00000000..be9d0313 --- /dev/null +++ b/src/server/adapters/platform.ts @@ -0,0 +1,23 @@ +import type { Clock, IdGenerator, KvStore, TelemetrySink } from '@codra/core/ports'; +import type { AppBindings } from '@server/env'; +import { sendTelemetryEvent } from '@server/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. +export function makeKvStore(env: AppBindings): KvStore { + return { + get: (key) => env.APP_KV.get(key), + put: (key, value, options) => env.APP_KV.put(key, value, options), + }; +} + +export const systemClock: Clock = { now: () => Date.now() }; + +export const cryptoIds: IdGenerator = { randomUUID: () => crypto.randomUUID() }; + +// The instance id, package version, opt-out checks and the fetch all stay in +// src/server/core/telemetry.ts: none of them belong behind a package boundary, and the version comes +// from a repo-root package.json that no package-relative path can reach. +export function makeTelemetrySink(env: AppBindings): TelemetrySink { + return { send: (event) => sendTelemetryEvent(env, event) }; +} diff --git a/src/server/adapters/services.ts b/src/server/adapters/services.ts new file mode 100644 index 00000000..ac5117e5 --- /dev/null +++ b/src/server/adapters/services.ts @@ -0,0 +1,34 @@ +import type { GitHubClientFactory, ModelErrorClassifier, ReviewFormatter, ReviewGitHub, ReviewModel } from '@codra/core/ports'; +import type { TokenTracker } from '@codra/core/token-tracker'; +import type { AppBindings } from '@server/env'; +import { GitHubClient } from '@server/core/github'; +import { GitHubService } from '@server/services/github'; +import { isRetryableModelError, ModelService, nextChainIndexOf } from '@server/services/model'; +import { FormatterService } from '@server/services/formatter'; + +// The only place the four job-scoped collaborators are constructed. Every specifier above is the +// barrel form on purpose: nine specs vi.mock '@server/services/github' and '@server/services/model', +// and reaching for a sibling here would bypass those mocks while the tests kept passing. + +export function makeGitHubFactory(env: AppBindings) { + return (installationId: string, tracker: TokenTracker): ReviewGitHub => new GitHubService(env, installationId, tracker); +} + +export function makeModelFactory(env: AppBindings) { + return (jobId: string, tracker: TokenTracker): ReviewModel => new ModelService(env, tracker, { jobId }); +} + +export function makeFormatterFactory(env: AppBindings) { + return (): ReviewFormatter => new FormatterService(env.APP_URL); +} + +// Webhook resolution runs before a job row exists, so it cannot go through the job-scoped factory +// above: GitHubClient is the lower-level client the engine uses for label cleanup on a closed pull +// request and for finding the pull request behind an issue comment. +export function makeGitHubClientFactory(env: AppBindings): GitHubClientFactory { + return { forInstallation: (installationId) => new GitHubClient(env, installationId) }; +} + +export function makeModelErrorClassifier(): ModelErrorClassifier { + return { isRetryableModelError, nextChainIndexOf }; +} diff --git a/src/server/adapters/settings-store.ts b/src/server/adapters/settings-store.ts new file mode 100644 index 00000000..9d635b13 --- /dev/null +++ b/src/server/adapters/settings-store.ts @@ -0,0 +1,32 @@ +import type { LearningStore, ModelConfigReader, RepoConfigLoader, ReviewSettingsReader, WebhookDeliveryReader } from '@codra/core/ports'; +import type { AppBindings } from '@server/env'; +import { getReviewSettings } from '@server/db/app-settings'; +import { getResolvedModelConfig } from '@server/db/model-configs'; +import { getWebhookDelivery } from '@server/db/webhook-deliveries'; +import { getRejectedExemplars, getRepositoryIdForJob } from '@server/db/learning'; +import { loadRepoConfig } from '@server/core/config'; + +export function makeReviewSettingsReader(env: AppBindings): ReviewSettingsReader { + return { getReviewSettings: () => getReviewSettings(env) }; +} + +export function makeModelConfigReader(env: AppBindings): ModelConfigReader { + // Returns the full ResolvedModelConfig, which the narrower port type discards -- deliberately, so + // encryptedApiKey has no path into the engine. + return { getResolvedModelConfig: (modelId) => getResolvedModelConfig(env, modelId) }; +} + +export function makeWebhookDeliveryReader(env: AppBindings): WebhookDeliveryReader { + return { getWebhookDelivery: (deliveryId) => getWebhookDelivery(env, deliveryId) }; +} + +export function makeLearningStore(env: AppBindings): LearningStore { + return { + getRepositoryIdForJob: (jobId) => getRepositoryIdForJob(env, jobId), + getRejectedExemplars: (input) => getRejectedExemplars(env, input), + }; +} + +export function makeRepoConfigLoader(env: AppBindings): RepoConfigLoader { + return { loadRepoConfig: (input) => loadRepoConfig(env, input) }; +} diff --git a/src/server/core/claim-checks.ts b/src/server/core/claim-checks.ts index c3c623f5..a4251d81 100644 --- a/src/server/core/claim-checks.ts +++ b/src/server/core/claim-checks.ts @@ -1,340 +1,2 @@ -// 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 { DiffLine, FileDiff } from './diff'; -import { normalizeDiffText } from './fingerprint'; - -// Refute only when the identifier turns up in the same hunk, or this close in the new file. -const PROXIMITY_WINDOW_LINES = 25; - -// Shorter than this and an identifier is too generic to carry a refutation. -const MIN_IDENTIFIER_LENGTH = 3; - -// Anchored on verbs, not bare "missing": that also matches undecidable claims like "missing error handling". -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, -]; - -// Never refute on these: finding `await` elsewhere does not refute "`await` is missing". -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', -]); - -// Wording that marks a claim as about an external version or config key, not the code shown. -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, - // A claim about what an installed library's API offers is the same kind of claim as one about a - // version: it is settled by node_modules, not by the diff. Added after a P0 on codra's own PR #86 - // asserted that `z.uuid()` "does not expose" a top-level validator and would throw at runtime -- - // Zod 4 has had it since the 4.0 release, and the suggested fix reverted to the deprecated form. - // "does not exist" was already covered; the miss was purely the verb. - /\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, -]; - -// ---- Undecidable-claim refutations --------------------------------------------------------------- -// CLAIM_TYPE_DECIDABILITY answers "can this be settled from a diff hunk?" per claim TYPE, which leaves -// `other` -- the deliberate escape hatch, marked diff_local -- carrying whatever a model wants to -// assert. These answer the same question per CLAIM, for the two families that recur: -// -// cross-file the claim's consequence lands in a file that is not in the diff -// environment the claim is conditional on a runtime, framework or engine version not shown -// -// Both are already forbidden by the review prompt in prose; on codra's own PR #86 the models ignored -// that instruction four times in one review, and the verification pass confirmed every one of them -// (generator and verifier share a knowledge gap, so verification cannot close it). -// -// Same soundness rule as the absence checker above: a refutation asserts only that the claim cannot be -// settled HERE, never that the code is fine. Losing one is free; a wrong one silences a real defect. - -// The claim reaches for consumers it cannot see: "other modules", "downstream callers". -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; - -// Hedged, and hedged specifically about where the code runs rather than about what it does. -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; - -// "if `loadCooldowns()` fails, the rejection is unhandled" -- a claim about how a function HANDLES ITS -// OWN ERRORS, where that function's body is not in the diff. Posted as a P1 on codra's own PR: the -// callee already wrapped its only failure path in try/catch, in another file, with a comment saying so. -// Requires a call-shaped subject (`name(` or `name()`), a failure condition, and an unhandled-outcome -// word, so an ordinary claim about visible code -- "this catch swallows the error" -- does not match. -// `(?!\.\s)` skips a sentence break but keeps dotted member expressions, so the condition still matches -// "if the `this.persistence.loadCooldowns()` call fails" without spanning two sentences. -const CALLEE_FAILURE_CONDITION = /\b(?:if|when|should|were)\b(?:(?!\.\s)[^;!?]){0,100}\b(?:fails?|failing|rejects?|rejecting|throws?|throwing|errors? out)\b/i; -const CALLEE_CALL_SHAPE = /[\w.$]+\s*\(\s*\)|`[\w.$]+\(/; -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, returning the family it belongs to or null. - * - * Deliberately requires TWO independent signals per family -- a subject and a consequence -- because - * either alone is ordinary review language. "This breaks the build" is a normal thing to say about - * code in the diff; "other modules import this" is a normal aside. Only together do they describe a - * consequence in a file nobody showed the model. - */ -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; -} - -// A full git object id: `uses: owner/action@<40 hex>` pins, and any version beside it is a comment. -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)); -} - -// A step pinned to a full SHA resolves by SHA, and the trailing `# v7.0.0` is never read, so "v7.0.0 does not exist" is not a defect there. -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); -} - -type PresenceEntry = { line: DiffLine; hunkIndex: number; code: string }; - -export type PresenceIndex = { - byToken: Map; - entries: PresenceEntry[]; - // new-file line number -> hunk index, so "same hunk" is answerable for the anchor line. - 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: DiffLine }; - -type CommentSyntax = { line: readonly string[]; block: boolean }; - -// By extension: `//` is floor division in Python, `#` a private field in JS. Guessing truncates code. -export function commentSyntaxFor(path: string): CommentSyntax { - const ext = path.toLowerCase().split('.').pop() ?? ''; - if (ext === 'py' || ext === 'rb' || ext === 'sh' || ext === 'yaml' || ext === 'yml' || ext === 'toml') { - return { line: ['#'], block: false }; - } - if (ext === 'sql') return { line: ['--'], block: true }; - return { line: ['//'], block: true }; -} - -// Returns `null` when unscannable, biasing to `unknown`. Do NOT add cross-line state without a desync test: dropping real code silently is worse than giving up. -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; -} - -// Keeps `${...}` interiors and discards the literal text around them. -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; -} - -const TOKEN_PATTERN = /[A-Za-z_$][\w$]*/g; - -export function buildPresenceIndex(file: FileDiff): PresenceIndex { - const syntax = commentSyntaxFor(file.path); - const byToken = new Map(); - const entries: PresenceEntry[] = []; - const hunkByLine = new Map(); - - file.hunks.forEach((hunk, hunkIndex) => { - for (const line of hunk.lines) { - if (line.newLineNumber !== undefined) hunkByLine.set(line.newLineNumber, hunkIndex); - - // A removed line cannot prove presence: deletion is consistent with the claim. - if (line.kind === 'del') continue; - - const code = stripCommentsAndStrings(normalizeDiffText(line.content), syntax); - if (code === null) continue; - - const entry: PresenceEntry = { line, hunkIndex, code }; - entries.push(entry); - - for (const match of code.matchAll(TOKEN_PATTERN)) { - const token = match[0]; - const existing = byToken.get(token); - if (existing) existing.push(entry); - else byToken.set(token, [entry]); - } - } - }); - - return { byToken, entries, hunkByLine }; -} - -const SIMPLE_IDENTIFIER = /^[A-Za-z_$][\w$]*$/; -const DOTTED_IDENTIFIER = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/; - -// Delimited code spans only: prose would yield `days` and refute against any unrelated use. -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'; - // Two plausible identifiers means we cannot tell which one the claim is about, and refuting the wrong one is unsound. - 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 { - // Bounded so a long body cannot turn this into a CPU problem inside a 10ms-budget Worker. - 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' }; - - // Tried per sentence: TITLE usually gives the shape, BODY the identifier; ambiguity short-circuits rather than hunting for a tidier sentence. - 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' }; - - // Proximity: without it "X is not passed to f()" is refuted by an unrelated X hundreds of lines away. - const anchorHunk = input.anchorLine !== undefined ? input.index.hunkByLine.get(input.anchorLine) : undefined; - const nearby = occurrences.find((entry) => { - if (anchorHunk !== undefined && entry.hunkIndex === anchorHunk) return true; - if (input.anchorLine === undefined || entry.line.newLineNumber === undefined) return false; - return Math.abs(entry.line.newLineNumber - input.anchorLine) <= PROXIMITY_WINDOW_LINES; - }); - - if (!nearby) return { status: 'unknown', reason: 'out_of_window' }; - return { status: 'refuted', identifier, line: nearby.line }; -} +// Moved to @codra/core/claim-checks; see the note in ./fingerprint.ts. +export * from '@codra/core/claim-checks'; diff --git a/src/server/core/diff/index.ts b/src/server/core/diff/index.ts index bb99f459..e714101c 100644 --- a/src/server/core/diff/index.ts +++ b/src/server/core/diff/index.ts @@ -1,295 +1,2 @@ -import picomatch from 'picomatch'; -import type { RepoConfig } from '@codra/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; -} - -// The b-side path from `diff --git a/ b/`. Splitting on the LAST space breaks on `a/my file.ts b/my file.ts` (space in filename), which wedged jobs in a review -> finalize loop. -// A symmetric `a/X b/X` split handles spaces correctly since both sides match unless renamed; only a rename falls back to the first ` b/`. -export function parseDiffHeaderPath(line: string) { - const rest = line.slice('diff --git '.length); - - if (rest.startsWith('a/')) { - // len(X) for a symmetric "a/X b/X": total = 2 + n + 1 + 2 + n. - 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); -} - -// One entry of GitHub's `/pulls/{n}/files` response, narrowed to what we use. -export type GitHubDiffFileEntry = { - filename: string; - previous_filename?: string | null; - status?: string; - // Absent for binary files and ones GitHub considers too large to patch. - patch?: string | null; -}; - -// Rebuilds unified-diff text from GitHub's per-file JSON, because the diff media type returns 406 `too_large` past 20,000 lines with nothing to retry. Emitting text keeps `parseUnifiedDiff` the one format reader everywhere. -// Headers match real git output, including the mode lines that set `isNew`/`isDeleted` (`/dev/null` alone would not). -export function buildUnifiedDiffFromFiles(files: GitHubDiffFileEntry[]): 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}`); - } - - // No patch means binary or declined. Say so in the form the parser knows, or the file silently disappears and reads as reviewed-and-clean. - 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` : ''; -} - -// `maxFiles` is passed in, not read from repo config, because the subrequest ceiling and provider rate limit it protects are instance-wide, shared across repositories. -// Returns `skipped` so callers can say "100 of 106" instead of reporting a partial review as complete. -export function filterReviewableFiles( - files: FileDiff[], - config: RepoConfig['review'], - maxFiles: number, -): { 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)); - - return { - files: reviewable.slice(0, maxFiles), - skipped: Math.max(0, reviewable.length - maxFiles), - }; -} +// Moved to @codra/core/diff; see the note in ../fingerprint.ts. 22 importers name this path. +export * from '@codra/core/diff'; diff --git a/src/server/core/fingerprint.ts b/src/server/core/fingerprint.ts index bf809eb5..14989f19 100644 --- a/src/server/core/fingerprint.ts +++ b/src/server/core/fingerprint.ts @@ -1,55 +1,4 @@ -// Stable identifiers for a finding: `fingerprint` answers "is this the same finding?" (path + normalized title), `anchorHash` answers "has the code under it changed?" (content of the anchored line). Kept separate: a combined hash answers neither. - -// FNV-1a 32-bit hex. A dedupe key, not a security boundary; synchronous, so not `crypto.subtle`. -export function fnv1a32Hex(input: string): string { - let hash = 0x811c9dc5; - for (let i = 0; i < input.length; i++) { - hash ^= input.charCodeAt(i); - // hash *= 16777619, via shifts to stay in 32-bit integer math. - hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0; - } - return hash.toString(16).padStart(8, '0'); -} - -// The gutter strip is load-bearing: models quoting "verbatim" copy part of the ` 12 14 +` prefix. Whitespace is collapsed, not removed, so `a + b` and `a+b` stay distinct. -export function normalizeDiffText(input: string): string { - return input - .replace(/^\s*\d*\s+\d*\s*[+\- ]?/, '') - .replace(/\s+/g, ' ') - .trim(); -} - -// Typographic folding for matching a model's evidence quote: models retype rather than copy, and an unmatched curly quote is fatal. -// NEVER fold this into `normalizeDiffText` -- `buildAnchorHash` builds on that, so widening it re-hashes every affected anchor and re-raises findings suppression had already retired. -export function foldEvidenceText(input: string): string { - return normalizeDiffText(input) - .replace(/[‘’‚‛′]/g, "'") - .replace(/[“”„‟″]/g, '"') - .replace(/[‐-―−]/g, '-') - .replace(/…/g, '...'); -} - -// Normalized finding title, shared with the in-memory dedupe so both agree on identity. -export function normalizeFindingTitle(title: string): string { - return title.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); -} - -// Keep the NUL as an ESCAPE, never a literal control character: as a raw byte it made git and the GitHub API classify this file as *binary*. -// Changing this input resets every cross-run suppression and unmatches stored comment_feedback dismissals, re-posting findings a human deleted. Pinned by test/claim-types.spec.ts; must not move. -export function buildFindingFingerprint(path: string, title: string): string { - return fnv1a32Hex(`${path}\u0000${normalizeFindingTitle(title)}`); -} - -// A second identity, OR-matched with v1, because models reword titles and v1 missed most repeats. Inputs are machine-derived, so they don't move when the prose does; hashing the flagged line's CONTENT means editing that line re-raises the finding. Additive on purpose -- folding it into v1 would carry the reset cost described above. -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)); -} +// Moved to @codra/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 '@codra/core/fingerprint'; diff --git a/src/server/core/github/types.ts b/src/server/core/github/types.ts index cb57cb31..81088c15 100644 --- a/src/server/core/github/types.ts +++ b/src/server/core/github/types.ts @@ -1,3 +1,7 @@ +// Both of these are part of the git-provider PORT contract, so @codra/core/ports owns them and +// this module re-exports: one definition, and the engine does not depend on this file. +export type { GitHubReviewComment, PullRequestRecord } from '@codra/core/ports'; + // Response shapes from the GitHub REST API, narrowed to the fields this app reads. // Import these from @server/core/github, not from here: specs mock that barrel by replacing the whole GitHubClient class. @@ -12,16 +16,6 @@ export type GitHubRepository = { }; }; -export type GitHubReviewComment = { - path: string; - // File line to attach the comment to, paired with `side`. The model reports file lines, never diff offsets. - line?: number; - // 'RIGHT' = the head (post-change) file, which is where findings live. - side?: 'LEFT' | 'RIGHT'; - // Legacy diff-offset addressing. Kept for callers that already compute it. - position?: number; - body: string; -}; export type InstallationTokenCacheRecord = { token: string; @@ -33,15 +27,6 @@ export type GitHubAppRecord = { slug?: string; }; -export type PullRequestRecord = { - number: number; - title: string | null; - body: string | null; - draft: boolean; - head: { sha: string; ref: string }; - base: { sha: string; ref: string }; - user: { login: string }; -}; export type GitHubIssueLabel = { name?: string; diff --git a/src/server/core/logger.ts b/src/server/core/logger.ts index 5fdfd930..f6f8ec86 100644 --- a/src/server/core/logger.ts +++ b/src/server/core/logger.ts @@ -1,59 +1,12 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import { formatLogRecord, setLoggerSink } from '@codra/core/logger'; -const SENSITIVE_KEYS = [ - 'api_key', - 'api-key', - 'apikey', - 'secret', - 'password', - 'token', - 'private_key', - 'private-key', - 'database_url', - 'authorization', - 'session', - 'cookie', -]; - +// The request-context half of the logger. Scrubbing and record shaping live in @codra/core/logger; +// this file owns everything platform-bound -- AsyncLocalStorage and the console sink -- so that +// node:async_hooks never enters the engine package. Importing this module installs it as the sink +// that @codra/core's `logger` facade delegates to (see the bottom of the file). const storage = new AsyncLocalStorage>(); -// A JWT: three base64url segments, the first being base64 of `{"...` so it always starts `eyJ`. -// Anchoring on that is what keeps this from matching ordinary prose. -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; - -// Scrubs secrets OUT OF a string rather than discarding the whole string. The previous test, "contains exactly two periods", deleted messages with two dots (e.g. file paths) while missing real JWTs, protecting nothing. -function scrubString(value: string): string { - return value.replace(JWT, '[REDACTED_JWT]').replace(BEARER, (m) => `${m.split(/\s+/)[0]} [REDACTED]`); -} - -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); - // Error instances don't expose name/message/stack as own enumerable properties, so Object.entries() would serialize them to {}. - 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; -} - class Logger { constructor(private context: Record = {}) {} @@ -63,15 +16,7 @@ class Logger { private log(level: string, message: string, data?: any) { const store = storage.getStore() || {}; - // `message` and both context objects go through redaction too: scrubbing only `data` left unscrubbed paths to the same log line. - const output = { - timestamp: new Date().toISOString(), - level, - message: scrubString(message), - ...redact(store), - ...redact(this.context), - ...(data ? { data: redact(data) } : {}), - }; + const output = formatLogRecord(level, message, [store, this.context], data); if (level === 'error') { console.error(JSON.stringify(output)); @@ -112,3 +57,9 @@ class Logger { } export const logger = new Logger(); + +// Wired at import scope so engine code logging through @codra/core's facade lands here, with request +// context attached, rather than in core's bare console fallback. src/server/index.ts and +// src/server/adapters/index.ts both import this module explicitly to make the wiring deliberate +// rather than a side effect of whichever file happened to be loaded first. +setLoggerSink(logger); diff --git a/src/server/core/model-output/index.ts b/src/server/core/model-output/index.ts index 0229d588..20cee825 100644 --- a/src/server/core/model-output/index.ts +++ b/src/server/core/model-output/index.ts @@ -1,436 +1,2 @@ -import { - fileReviewModelOutputSchema, - parsedReviewCommentSchema, - toClaimType, - CLAIM_TYPE_CATEGORY, - type ClaimType, - type ParsedReviewComment, - reviewSeverities, -} from '@codra/schema'; -import { renderDiffSnippet } from '@server/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'; - -// Tolerates the prefix noise models add to paths (`./src/a.ts`, `b/src/a.ts`, `/src/a.ts`). -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; - // Path of the entry enclosing the finding being grounded. - 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\`\`\``; -} - -// Relabels an `other` finding when its vocabulary is unmistakable, so the denylist can see it. Deliberately excludes react_missing_cleanup/resource_leak/null_or_undefined_deref: that vocabulary also appears in legitimate `other` findings. -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}`; - - // Version claims arrive labelled `other`, and every one in the corpus has been false. - 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]; - -// Dropped finding for the off-diff list. Only the position-validation drop omits `tag`. -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}`; -} - -// Stage 2: resolve the evidence quote against the diff; only a match passes, on every provider. -// unmatched = discriminating but absent, weak = under 8 normalized chars, absent = no quote. -function groundFindingInEvidence( - finding: RawFinding, - evidenceIndex: EvidenceIndex, - evidenceStats: { total: number; matched: number; unmatched: number; weak: number; absent: 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}` } }; - } - - // Batch path only: a quote shared across packed files PLUS a mismatched claimed path means a misfiled finding. Either signal alone is ordinary. - 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', - }, - }; - } - } - - // Anchor comes from the matched quote; `code_location.line` only disambiguates repeated lines. - return { diffLine: evidence.line }; -} - -// Stage 3: anchors a grounded evidence line to a concrete, postable diff position. -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 }; -} - -// Stage 4: normalize raw priority/title/body, independent of evidence and claim-type decisions. -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', - }; - // Missing priority falls back to P3 rather than dropping a possible P0. - 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 }; -} - -// Stage 5: resolve the claim type, then enforce the denylist and pinned-SHA refutation. Counts update BEFORE the deny check, or a working denylist would tally identically to an idle one. -function applyClaimGate( - finding: RawFinding, - title: string, - body: string, - anchorContent: string, - deniedClaimTypes: Set, - claimTypeCounts: Record, - deniedClaimCounts: Record, -): { claimType: ClaimType } | { withheld: Withheld } { - // Coerce to 'other' rather than throw: a Zod rejection discards the whole file over one bad label. - 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}` } }; - } - - // A full commit SHA pin refutes a version claim outright. - 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' } }; - } - - // Claims whose consequence lives in a file, framework or engine version the model was never shown. - // Counted under its own key and tagged distinctly, so every suppression stays auditable in the - // off-diff list rather than vanishing -- a wrong refutation must be findable. - 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 }; -} - -// Stage 6: assemble the persisted comment. Absence-check stats are SHADOW: counted, never acted on. Promote to a drop only once `refuted` is non-zero on real claims and the gold set passes. -function buildParsedComment(params: { - file: FileDiff; - line: number; - position: number; - severity: typeof reviewSeverities[number]; - title: string; - body: string; - claimType: ClaimType; - anchorContent: string; - finding: RawFinding; - presenceIndex: ReturnType; - absenceCheckStats: { absenceShaped: number; identifierExtracted: number; refuted: number }; -}): ParsedReviewComment { - const { file, line, position, severity, title, body, claimType, anchorContent, finding, presenceIndex, absenceCheckStats } = params; - - const absence = checkAbsenceClaim({ title, body, anchorLine: line, index: presenceIndex }); - if (absence.status === 'refuted') { - absenceCheckStats.absenceShaped += 1; - absenceCheckStats.identifierExtracted += 1; - absenceCheckStats.refuted += 1; - } else if (absence.reason !== 'not_absence_shaped') { - absenceCheckStats.absenceShaped += 1; - if (absence.reason !== 'no_identifier' && absence.reason !== 'ambiguous_identifier') { - absenceCheckStats.identifierExtracted += 1; - } - } - - // Never `undefined`: the gate fires on typeof==='number', so an omission would sail past it. - const confidenceScore = typeof finding.confidence_score === 'number' - ? finding.confidence_score - : 0; - - // An empty or whitespace-only suggestion means "no suggestion", not "discard this finding" -- but - // `codeSuggestion` is `z.string().min(1)`, so passing `""` straight through threw a ZodError and the - // catch below binned the whole comment as `unverified:unassemblable`. Measured across an 800-review - // sweep: 256 findings destroyed this way, including real ones (a hardcoded-secret P1 among them). - // `evidence` on the next line has always had this guard; this field simply never got it. - const codeSuggestion = typeof finding.code_suggestion === 'string' && finding.code_suggestion.trim() - ? finding.code_suggestion - : undefined; - - return parsedReviewCommentSchema.parse({ - path: file.path, - line, - position, - severity, - // Derived, never model-emitted: asking produced 'quality' on all 705 rows. - category: CLAIM_TYPE_CATEGORY[claimType], - claimType, - // Unrecoverable later: 003 nulls diff_input and the KV diff cache expires after 6h. - 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, - // Title-independent identity, OR-matched with the first so a reworded repeat is still recognised. - fingerprintV2: buildFindingFingerprintV2( - file.path, - claimType, - anchorContent ? buildAnchorHash(anchorContent) : undefined, - ) ?? undefined, - }); -} - -// One file's worth of extracted output, so the batch path can hand-build it per file instead of going through the single-file `parseRawPayload`. -export type FileReviewPayload = z.infer; - -export type GroundingOptions = { - // Rejected outright. Enforced here, not in the grammar: only Workers AI and Google AI Studio honor the schema. - deniedClaimTypes?: readonly ClaimType[]; - // Batch path only. - ambiguity?: BinAmbiguity; -}; - -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 }; - claimTypeCounts: Record; - // Denied per type; these also appear in `claimTypeCounts`. - deniedClaimCounts: Record; - // Absence-check funnel, shadow-only; three counters keep refuted:0 distinct from "never fired". - absenceCheckStats: { absenceShaped: number; identifierExtracted: number; refuted: number }; -}; - -// Grounding is per file, never per response: the indexes come from one `FileDiff`. Split out of `parseFileReviewResponse` so batches can reuse it per file. -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 }; - const claimTypeCounts: Record = {}; - const deniedClaimCounts: Record = {}; - const deniedClaimTypes = new Set(options?.deniedClaimTypes ?? []); - const presenceIndex = buildPresenceIndex(file); - 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); - - // Anchor on content, not line number: an edit above shifts it, an edit TO the line must re-raise. - 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; - } - - // Contained per finding: under batching, propagating would discard the rest of the bin. - try { - return buildParsedComment({ - file, - line: anchored.line, - position: anchored.position, - severity, - title, - body, - claimType: gated.claimType, - anchorContent, - finding, - presenceIndex, - absenceCheckStats, - }); - } catch (error) { - // ZodError only: a wider catch would swallow systemic failures. - 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, - }; -} - -// Provider-independent by design: gating these on a Cloudflare-only flag once disabled the evidence gate and min_confidence on the Google chain. -export function parseFileReviewResponse( - raw: string, - file: FileDiff, - options?: GroundingOptions, -): GroundedFileReview { - return groundParsedFindings(parseRawPayload(raw), file, options); -} - - -export { dedupeFindings } from './dedupe'; -export { - isNonAnswerReview, - NON_ANSWER_MAX_RESPONSE_CHARS, - NON_ANSWER_MIN_DIFF_LINES, -} from './non-answer'; -export { parseRawBatchPayload, type RawBatchPayload } from './json-batch'; -export { parseBatchReviewResponse, type BatchParseStats, type BatchReviewResult } from './batch'; +// Moved to @codra/core/model-output; see the note in ../fingerprint.ts. +export * from '@codra/core/model-output'; diff --git a/src/server/core/review/index.ts b/src/server/core/review/index.ts index 1020604b..7c90c19a 100644 --- a/src/server/core/review/index.ts +++ b/src/server/core/review/index.ts @@ -1,373 +1,46 @@ -import { logger } from '../logger'; -import { isSupportedGitHubWebhookEvent, type GitHubWebhookPayload, type PullRequestWebhookPayload } from '@codra/schema/github'; -import { REVIEW_CONCURRENCY_LIMITS, type ReviewJobMessage } from '@codra/schema'; -import type { AppBindings } from '@server/env'; -import { getFileReviewsForJobs } from '@server/db/file-reviews'; -import { - claimJobLease, - findExistingJobForHead, - getJobForProcessing, - getOtherRunningJobsCount, - insertJob, - mapJob, - markJobContinuationQueued, - resetJobContinuationCount, - releaseJobLease, - setJobWorkflowInstance, - supersedeOlderJobs, -} from '@server/db/jobs'; -import { extractReviewRequest } from './request'; - -// Re-exports below keep existing '@server/core/review' specifiers working for routes and specs. -export { getDiffFiles, getOrFetchRawDiffForCompletedJob } from './diff-cache'; - -export { budgetAwareFileLimit, estimatedSubrequestsPerFile } from './budget'; - -export { - BIN_DIFF_CHAR_BUDGET, - BIN_MAX_FILES, - BIN_TARGET_DIFF_LINES, - PACKABLE_MAX_DIFF_LINES, - narrowUnit, - planReviewUnits, - unitFiles, - type LedgerEntry, - type ReviewUnit, -} from './pack'; - -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 -// importing phase-control directly. -export { FRESH_INVOCATION_YIELD_SECONDS } from './phase-control'; - -import { GitHubService } from '../../services/github'; -import { GitHubClient } from '../github'; -import { isRetryableModelError, ModelService } from '../../services/model'; -import { FormatterService } from '../../services/formatter'; -import { TokenTracker } from '../token-tracker'; -import { loadRepoConfig } from '../config'; -import { getWebhookDelivery } from '@server/db/webhook-deliveries'; -import { getReviewSettings } from '@server/db/app-settings'; -import { - type PersistedReviewJob, - BUSY_RETRY_SECONDS, - FRESH_INVOCATION_YIELD_SECONDS, - JOB_LEASE_SECONDS, - MAX_FINALIZE_CONTINUATIONS, - MAX_JOB_CONTINUATIONS, - NextPhaseError, - failJobAndCheckRun, -} from './phase-control'; -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 } - // jobId is resolved (mention-triggered jobs carry none). freshInstance starts a new Workflow instance: set on a subrequest deferral or the move into finalize. - | { action: 'next_phase'; phase: 'prepare' | 'review' | 'finalize'; delaySeconds: number; jobId?: string; freshInstance?: boolean }; - -export async function runReviewJob(env: AppBindings, message: ReviewJobMessage): Promise { - const resolved = await resolveQueuedJob(env, message); - if (!resolved) { - return { action: 'ack' }; - } - - // Admission only: re-gating a job already 'running' would retry forever and stale its lease. - if (resolved.job.status === 'queued') { - const { concurrencyLevel } = await getReviewSettings(env); - const maxConcurrentJobs = REVIEW_CONCURRENCY_LIMITS[concurrencyLevel]; - const runningCount = await getOtherRunningJobsCount(env, 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 = crypto.randomUUID(); - const claim = await claimJobLease(env, 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 = mapJob(claim.row); - - // Bind the Workflow instance id so stop/delete/rerun hit the right one; webhook jobs key theirs on deliveryId, so the earlier bind step cannot. Idempotent. - if (message.workflowInstanceId && job.workflowInstanceId !== message.workflowInstanceId) { - try { - await setJobWorkflowInstance(env, 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 = new TokenTracker(); - const github = new GitHubService(env, job.installationId, tracker); - const model = new ModelService(env, tracker, { jobId: job.id }); - const formatter = new FormatterService(env.APP_URL); - - 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 releaseJobLease(env, 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 releaseJobLease(env, job.id, leaseOwner); - return { action: 'ack' }; - } - - if (error instanceof NextPhaseError) { - await releaseJobLease(env, job.id, leaseOwner); - // Finalize needs a fresh instance for a clean budget; other transitions hibernate instead. - return { action: 'next_phase', phase: error.phase, delaySeconds: error.delaySeconds, jobId: job.id, freshInstance: error.phase === 'finalize' }; - } - - if (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'); - } - - // Not a job failure: every phase is idempotent enough to resume on a fresh budget. - if (isSubrequestBudgetError(error)) { - // Only a long-enough sleep hibernates the workflow into the fresh invocation this needs. - 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'); - } - - logger.error(`Review job failed: ${job.owner}/${job.repo} PR #${job.prNumber}`, error); - await failJobAndCheckRun(env, job, github, messageText); - await releaseJobLease(env, job.id, leaseOwner); - return { action: 'ack' }; - } -} - -// Records a same-phase continuation and enforces the ceiling. Completing any file resets the counter, so only a genuinely wedged job gets there. -async function continueOrFailWedgedJob( - env: AppBindings, - job: PersistedReviewJob, - github: GitHubService, - leaseOwner: string, - phase: 'prepare' | 'review' | 'finalize', - delaySeconds: number, - reason: string, -): Promise { - const continuationCount = await markJobContinuationQueued(env, job.id, delaySeconds); - - // Finalize fails fast instead of looping ~20 min; other phases make real per-file progress. - const ceiling = phase === 'finalize' ? MAX_FINALIZE_CONTINUATIONS : MAX_JOB_CONTINUATIONS; - - if (continuationCount > ceiling) { - if (phase === 'review') { - // Must RETURN the transition: enqueueJobPhase() throws, and this runs inside a catch. - logger.error(`Review job exceeded the continuation ceiling; degrading to a partial review: ${job.owner}/${job.repo} PR #${job.prNumber}`, { - phase, - continuationCount, - reason, - }); - // A file still awaiting an async batch would otherwise finalize as an empty 'successful'. - const stillPending = (await getFileReviewsForJobs(env, [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, - }); - } - // Finalize needs its own continuation budget: the counter is already past the ceiling. - await resetJobContinuationCount(env, job.id); - await releaseJobLease(env, 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 releaseJobLease(env, job.id, leaseOwner); - return { action: 'ack' }; - } - } - - await releaseJobLease(env, job.id, leaseOwner); - // A subrequest-limit deferral saturated THIS instance; a transient model deferral did not. - const freshInstance = reason.includes('subrequest'); - return { action: 'next_phase', phase, delaySeconds, jobId: job.id, freshInstance }; -} - -async function resolveQueuedJob( - env: AppBindings, - message: ReviewJobMessage, -): Promise<{ job: PersistedReviewJob; phase: 'prepare' | 'review' | 'finalize' } | null> { - if (message.jobId) { - const row = await getJobForProcessing(env, message.jobId); - return row ? { job: 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 GitHubWebhookPayload | undefined; - - if (payload === undefined) { - const delivery = await getWebhookDelivery(env, 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 GitHubWebhookPayload; - } - - if (!isSupportedGitHubWebhookEvent(eventName)) { - logger.info(`Queue message ignored: unsupported GitHub event ${eventName}`); - return null; - } - - const installationId = String(payload.installation?.id ?? ''); - if (!installationId || !('repository' in payload) || !payload.repository) { - logger.info('Queue message ignored: missing installation or repository info'); - return null; - } - - const repoConfig = await loadRepoConfig(env, { - installationId, - owner: payload.repository.owner.login, - repo: payload.repository.name, - }); - - if (repoConfig.enabled === false) { - logger.info(`Job ignored: repository ${payload.repository.owner.login}/${payload.repository.name} is disabled`); - return null; - } - - const extracted = extractReviewRequest({ - eventName, - payload, - botUsername: env.BOT_USERNAME, - config: repoConfig.parsedJson, - }); - - if (!extracted) { - if (eventName === 'pull_request') { - const prPayload = payload as PullRequestWebhookPayload; - if (prPayload.action === 'closed' && repoConfig.parsedJson.review.labels !== false) { - const labels = repoConfig.parsedJson.review.labels; - const gh = new GitHubClient(env, installationId); - await gh.removeIssueLabelsIfPresent( - prPayload.repository.owner.login, - prPayload.repository.name, - prPayload.pull_request.number, - [labels.p1, labels.p2, labels.p3], - ); - } - } - return null; - } - - let resolved = extracted; - const githubClient = new GitHubClient(env, installationId); - if (eventName === 'issue_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 findExistingJobForHead(env, { - 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 insertJob(env, { - 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 supersedeOlderJobs(env, { - installationId: resolved.installationId, - owner: resolved.owner, - repo: resolved.repo, - prNumber: resolved.prNumber, - newJobId: job.id, - }); - - return { job, phase: 'prepare' }; -} +import type { ReviewJobMessage } from '@codra/schema'; +import { runReview, type ReviewJobRunResult } from '@codra/core'; +import { createReviewRuntime } from '@server/adapters'; +import type { AppBindings } from '@server/env'; + +// The seam between the Worker and the engine. The engine moved to @codra/core; this converts +// AppBindings into the ports it takes, and is the ONLY place in production that does. +// +// Kept as a module rather than repointing callers at @codra/core because +// test/review/workflow-finalize-fresh-instance.spec.ts partially mocks this specifier -- substituting +// runReviewJob while keeping FRESH_INVOCATION_YIELD_SECONDS real -- and test/mocks/review-harness.ts +// types itself as Parameters[1]. Sixteen DB-backed review suites reach the +// engine through here, which is what makes them cover the adapters too. + +export type { ReviewJobRunResult }; + +export function runReviewJob(env: AppBindings, message: ReviewJobMessage): Promise { + return runReview(createReviewRuntime(env), message); +} + +// The rest of the engine's surface, re-exported so existing '@server/core/review' importers and the +// specs that name that specifier keep working unchanged. +export { + FRESH_INVOCATION_YIELD_SECONDS, + NextPhaseError, + failJobAndCheckRun, + extractReviewRequest, + getDiffFiles, + getOrFetchRawDiffForCompletedJob, + budgetAwareFileLimit, + estimatedSubrequestsPerFile, + BIN_DIFF_CHAR_BUDGET, + BIN_MAX_FILES, + BIN_TARGET_DIFF_LINES, + PACKABLE_MAX_DIFF_LINES, + narrowUnit, + planReviewUnits, + unitFiles, + proportionalSplit, + verifyFindings, + type LedgerEntry, + type ReviewRequest, + type ReviewUnit, + type VerifyDrop, + type VerifyOutcome, +} from '@codra/core'; diff --git a/src/server/core/rules/detect.ts b/src/server/core/rules/detect.ts index 8a3d4cac..783de864 100644 --- a/src/server/core/rules/detect.ts +++ b/src/server/core/rules/detect.ts @@ -1,159 +1,2 @@ -import type { ClaimType, ParsedReviewComment } from '@codra/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 '@codra/schema'; -import { RULES, type Rule } from './table'; - -// Cap on added lines scanned per file: the binding constraint is the 10ms CPU budget, not memory. Reported as `truncated` rather than silently applied. -const MAX_RULE_SCAN_ADDED_LINES = 600; - -export type RuleHit = { - rule: Rule; - line: DiffLine; - // Set when the rule is in shadow mode: counted and logged, never turned into a comment. - shadow: boolean; -}; - -export type RuleScanStats = { - addedLinesScanned: number; - // Lines that passed the cheap substring sieve and were actually stripped + regex-tested. - sievePassed: number; - hits: number; - shadowHits: number; - // Hits discarded because the identical line already existed as a `del` - the PR only moved it. - suppressedAsMoved: number; - // Lines the stripper refused to scan (unterminated quote / unclosed block comment). - 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); -} - -// Zero subrequests and no model call: this channel still produces findings when the LLM returns nothing or the file's review fails outright. -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 }; - - // One flat sieve over every active rule's triggers: cheap substring checks reject >95% of lines before regexes run. - const triggers = [...new Set(active.flatMap((rule) => rule.triggers))]; - const syntax = commentSyntaxFor(file.path); - - for (const hunk of file.hunks) { - // Same discipline as buildPresenceIndex: collected per hunk so reformat-move suppression can compare within the same window. - 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; - - // The "defect" pre-existed and the PR only moved or reindented the line. - 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; - // One hit per line: two rules firing on one line would post two comments at one anchor. - break; - } - } - if (stats.truncated) break; - } - - return { hits, stats }; -} - -// Turns rule hits into the same `ParsedReviewComment` shape the LLM channel produces, so downstream stages treat them uniformly. -// The fingerprint deliberately includes the anchor hash: a rule's title is a CONSTANT, so two hits of one rule in one file would otherwise collide on a single fingerprint identity. -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; -} +// Moved to @codra/core/rules/detect; see the note in ../fingerprint.ts. +export * from '@codra/core/rules/detect'; diff --git a/src/server/core/rules/table.ts b/src/server/core/rules/table.ts index af5234c5..5cbea257 100644 --- a/src/server/core/rules/table.ts +++ b/src/server/core/rules/table.ts @@ -1,149 +1,2 @@ -import type { ClaimType, reviewSeverities } from '@codra/schema'; - -type ReviewSeverity = typeof reviewSeverities[number]; - -// Deterministic rules, the second finding channel: models GENERATE at F1 0.07-0.37 but TRIAGE pre-grounded candidates at 0.88-0.96, so rules propose and the model judges. -export type Rule = { - id: string; - claimType: ClaimType; - severity: ReviewSeverity; - title: string; - body: string; - // Cheap substrings: absent from the raw line, the rule is never considered. - triggers: readonly string[]; - // Runs against the stripped line. Must not backtrack catastrophically. - pattern: RegExp; - // Veto against the RAW line, where stripping destroys the evidence that clears a hit: a block comment - // becomes a space, so an intentionally-empty catch looks genuinely empty. - rejectRaw?: RegExp; - // File extensions this applies to. Empty means all. - extensions?: readonly string[]; - // Tier-2 ships disabled: reviewable code, untrusted rule. - 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*\}/, - // A documented empty catch is deliberate. Checked on the RAW line: the stripper collapses the comment - // to a space and the block looks empty. - 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'], - // Non-literal right-hand side only: the stripper removes literals, so `= ''` cannot match, `= html` can. - 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'], - // DROP COLUMN/TABLE/TRUNCATE only. Not DROP INDEX/CONSTRAINT/DEFAULT/NOT NULL: they discard no rows - // and this repo's migrations use them routinely. - pattern: /\b(?:drop\s+(?:column|table)|truncate\s+table|truncate\s+\w)/i, - extensions: ['sql'], - enabled: true, - }, - - // ── Tier 2: shipped but disabled ──────────────────────────────────────────────────────────── - - { - 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/, - // Disabled: the stripper removes literals, where credentials live, so only unquoted tokens fire. Needs a different scanning mode, not a different regex. - 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, - // Disabled: the name heuristic is the whole rule, and a test fixture or React key is a false positive. - enabled: false, - }, -]; - -// NOT SHIPPED, `sql-string-concat`: the stripper deletes literals, so a safe tagged `sql` template is indistinguishable from real concatenation. Telling them apart needs a parse, not a regex. +// Moved to @codra/core/rules/table; see the note in ../fingerprint.ts. +export * from '@codra/core/rules/table'; diff --git a/src/server/core/timeout.ts b/src/server/core/timeout.ts index 703f24a4..1ac1008e 100644 --- a/src/server/core/timeout.ts +++ b/src/server/core/timeout.ts @@ -1,22 +1,3 @@ -export class TimeoutError extends Error { - constructor(label: string, ms: number) { - super(`${label} timed out after ${ms}ms`); - this.name = 'TimeoutError'; - } -} - -export async function withTimeout(label: string, ms: number, fn: (signal: AbortSignal) => Promise): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), ms); - - try { - return await fn(controller.signal); - } catch (err: any) { - if (controller.signal.aborted || err?.name === 'AbortError') { - throw new TimeoutError(label, ms); - } - throw err; - } finally { - clearTimeout(timer); - } -} +// Moved to @codra/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 '@codra/core/timeout'; diff --git a/src/server/core/token-tracker.ts b/src/server/core/token-tracker.ts index 3b61295e..8b707f52 100644 --- a/src/server/core/token-tracker.ts +++ b/src/server/core/token-tracker.ts @@ -1,131 +1,2 @@ -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'; - -// Prompts we paid to transmit but got nothing back for. Estimated, never billed: a failed call -// returns no usageMetadata, so this is `estimatePromptTokens` output and must not be compared to a -// provider's own promptTokenCount as an equal. -// -// `estimatedInput` is a token count but must NOT be named `...Tokens`: logger.ts redacts any key -// whose name contains "token", so the field would log as [REDACTED] and the metric would be useless. -export interface WastedUsage { - attempts: number; - estimatedInput: number; - skips: number; - byReason: Record; -} - -export class TokenTracker { - private usage: Map = new Map(); - // Kept out of `usage` so estimates can never leak into billed accounting or telemetry. - private wasted = { attempts: 0, estimatedInput: 0, skips: 0 }; - private wastedByReason: Map = new Map(); - private subrequests = 0; - private readonly MAX_SUBREQUESTS = 50; - // Covers untracked Hyperdrive queries per chunk (lease heartbeats, review reads/writes, etc.) that the tracker never sees. - 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; - } - - // Subrequests left before crossing into the reserved safety margin below Cloudflare's per-invocation cap; size variable concurrent work against this instead of a fixed constant. - 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 - }); - } - - // A full prompt went over the wire and produced no reviewable response. - 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 }); - } - - // A prompt we did NOT send because a gate already knew it would fail -- the positive signal that - // cooldown learning is working, and the counterpart to recordFailedAttempt. - 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(); - } -} +// Moved to @codra/core/token-tracker; see the note in ./fingerprint.ts. +export * from '@codra/core/token-tracker'; diff --git a/src/server/core/verify.ts b/src/server/core/verify.ts index 4a97e869..42ebb87c 100644 --- a/src/server/core/verify.ts +++ b/src/server/core/verify.ts @@ -1,20 +1,2 @@ -import { hexToBytes } from '@codra/schema/hex'; - -const encoder = new TextEncoder(); - -export async function verifyGitHubWebhookSignature(secret: string, headerValue: string | null, rawBody: string) { - if (!headerValue?.startsWith('sha256=')) { - return false; - } - - const signature = headerValue.slice('sha256='.length); - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['verify'], - ); - - return crypto.subtle.verify('HMAC', key, hexToBytes(signature), encoder.encode(rawBody)); -} +// Moved to @codra/core/verify; see the note in ./fingerprint.ts. +export * from '@codra/core/verify'; diff --git a/src/server/db/file-reviews-bulk.ts b/src/server/db/file-reviews-bulk.ts index 9666116b..ac4fb978 100644 --- a/src/server/db/file-reviews-bulk.ts +++ b/src/server/db/file-reviews-bulk.ts @@ -1,4 +1,4 @@ -import type { ParsedReviewComment } from '@codra/schema'; +import type { BulkFileReviewInput } from '@codra/core/ports'; import type { AppBindings } from '@server/env'; import { queryRows, queryTransaction } from './client'; import { @@ -63,26 +63,9 @@ export async function bulkInheritFileReviews( }); } -export type BulkFileReviewInput = { - filePath: string; - fileStatus: 'pending' | 'done' | 'skipped' | 'failed'; - modelUsed: string; - modelProvider?: string | null; - diffLineCount: number; - 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; - withheldCounts?: { evidence: number; claimDenied: number } | null; - // 1 for a file reviewed alone, N for a file that shared a model call with N-1 others. - batchSize: number; -}; +// Part of the FileReviewStore port contract, so @codra/core/ports owns the shape and this module +// re-exports it: one definition, and the engine does not depend on this file. +export type { BulkFileReviewInput } from '@codra/core/ports'; // One transaction: per-file upserts would spend the saved model calls back on DB subrequests. `diff_input` is not written (migration 003 nulls it). export async function bulkUpsertFileReviews( diff --git a/src/server/db/file-reviews-findings.ts b/src/server/db/file-reviews-findings.ts index fbba2cc1..b78f4996 100644 --- a/src/server/db/file-reviews-findings.ts +++ b/src/server/db/file-reviews-findings.ts @@ -1,15 +1,9 @@ +import type { SuppressedFinding } from '@codra/core/ports'; import type { AppBindings } from '@server/env'; import { queryRows } from './client'; -export type SuppressedFinding = { - fingerprint: string | null; - // Null for repo-wide rejections, which suppress regardless of what the code now says. - anchor_hash: string | null; - // Title-independent identity; already includes the anchor, so it needs no separate anchor check. - fingerprint_v2: string | null; - // True when this came from an earlier posted comment rather than from human rejection. - anchored: boolean; -}; +// Part of the FileReviewStore port contract; @codra/core/ports owns it and this module re-exports. +export type { SuppressedFinding } from '@codra/core/ports'; // Findings already posted on an EARLIER commit with the anchored line unchanged, or rejected by a human anywhere in this repository. // `j.commit_sha <> me.commit_sha` is load-bearing: retries and mention-triggered re-reviews reuse the SAME head commit. diff --git a/src/server/models/types.ts b/src/server/models/types.ts index c3815c82..4a9a1fa6 100644 --- a/src/server/models/types.ts +++ b/src/server/models/types.ts @@ -1,18 +1,8 @@ -export type ModelResponse = { - rawText: string; - inputTokens: number; - outputTokens: number; - modelUsed: string; - provider: string; - // Grammar rejected, so the call ran unconstrained but succeeded. Read by services/model.ts and `/models/:id/test`. - degraded?: 'schema-dropped'; -}; - -// Honored only by Workers AI and Google AI Studio -- not by `vertex`, despite it serving the same Gemini models. -export type ModelResponseSchema = { - name: string; - schema: Record; -}; +// Both live in @codra/core/ports now: prompts/file-review.ts builds a ModelResponseSchema, and it is +// the only reason a pure prompt module ever imported from models/. Re-exported here so the ~20 +// existing `@server/models/types` importers are unaffected, and so there is exactly one definition. +import type { ModelResponseSchema } from '@codra/core/ports'; +export type { ModelResponse, ModelResponseSchema } from '@codra/core/ports'; // `responseSchema` is per-call on purpose: file review, verification, and summary each need a different output shape. export type ModelInput = { diff --git a/src/server/prompts/file-review.ts b/src/server/prompts/file-review.ts index bf146a3e..356c3701 100644 --- a/src/server/prompts/file-review.ts +++ b/src/server/prompts/file-review.ts @@ -1,428 +1,2 @@ -import { claimTypes, type RepoConfig } from '@codra/schema'; -import type { FileDiff } from '@server/core/diff'; -import type { ModelResponseSchema } from '@server/models/types'; -import { getLanguageForFile } from './languages'; - -// Generator cap, NOT the posted cap: per CHUNK, upstream of four remove-only filters, where `max_comments` is once per job. -// -// Deliberately NOT divided by the size of a batched bin. That was tried, on the theory that a six-file -// bin asking 20 findings per file requested more than one response could hold: measured on a 221-file -// job, all 71 bin responses ended cleanly at 967-1,845 chars and the whole job spent 17,158 output -// tokens -- about 3% of the ceiling that was supposedly binding. The cap has never been what limits -// findings, so lowering it only removes room a genuinely defective file might need. -export function generatorFindingCap(maxComments: number): number { - return Math.max(1, maxComments * 2); -} - -// Shared by the single-file and batched grammars, so the field-order invariant is stated once. -function findingItemSchema() { - return { - type: 'object', - additionalProperties: false, - // Field order is load-bearing under constrained decoding: `evidence` first forces a real quote before any prose. - required: ['evidence', 'code_location', 'claim_type', 'title', 'body', 'priority'], - // `properties` order must match `required`: generation follows declaration order, so gemini-schema.ts must never sort or rebuild this. - 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 }, - }, - }, - }, - // Branch order matters: gemini-schema.ts collapses this to the first branch. - 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' }, - }, - }; -} - -// Response grammar for constrained decoding; same contract as the system and user prompts, all three must agree. -export function buildReviewResponseSchema(maxComments: 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: generatorFindingCap(maxComments), - 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 }, - }, - }, - }; -} - -// Batched grammar: `absolute_file_path` is required here even though its per-finding twin is optional; no `minItems` on `files` (uneven provider support) so the count is checked at parse time. -export function buildBatchReviewResponseSchema(maxComments: 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, - // Path first, like `evidence` in a finding: commit to the file before describing it. - required: ['absolute_file_path', 'findings', 'overall_explanation', 'overall_correctness'], - properties: { - absolute_file_path: { type: 'string' }, - // Deliberately unbounded, unlike the single-file grammar: `maxItems` on an array nested - // inside another bounded array made Gemini reject the whole schema with "produces a - // constraint that has too many states for serving", losing constrained decoding for the - // bin. The cap is stated in prose ("per file") and enforced at parse time by the - // over-cap truncation, so nothing but the FSM size changes. - 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) -}`; - -// A finding belongs to whichever entry encloses it; the per-finding `absolute_file_path` is only a cross-check. -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) -}`; - -// No restraint language: behind four remove-only filters, asking for empty findings arrays measured 0.039 findings/file and no true positives. Wording is snapshot-locked. -export function buildFileReviewSystemPromptBase(opts?: { multiFile?: boolean }): string { - const multi = opts?.multiFile === true; - - 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.` - : '- You can see ONLY the diff below, not the whole file or the rest of the repository.'; - - 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.'; - - // The multi-file wording must demand one entry per file (the parser reports a missing file as - // unreviewed and re-queues it) WITHOUT handing out an empty array as the easy way to satisfy that. - // The previous phrasing -- "even for files with no defect, give those an empty findings array" -- - // presupposed clean files in every bin and reintroduced exactly the restraint language the note above - // says measured 0.039 findings/file. Review each diff on its own merits is the whole instruction. - 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.`; -} - -// Named export because several tests assert against the prompt text directly. -export const fileReviewSystemPromptBase = buildFileReviewSystemPromptBase(); - -export function buildFileReviewSystemPrompt( - config: RepoConfig['review'], - languagePersona?: string, - opts?: { multiFile?: boolean }, -) { - const persona = languagePersona ? ` as ${languagePersona}` : ''; - // Prose cap must be the generator cap: otherwise the grammar allows 2N while the text asks for N, and the model obeys the text. - const prompt = buildFileReviewSystemPromptBase(opts) - .replace('{{MAX_COMMENTS}}', generatorFindingCap(config.max_comments).toString()); - return `You are a world-class professional senior code reviewer${persona}. ${prompt}`; -} - -// Human-rejected findings as NEGATIVE few-shot exemplars. Rejections only, since `marked_right` is rare and an absent label means nothing. -export type RejectedExemplar = { title: string; claimType?: string | null }; - -// Hard cap: every character competes with the diff for a 16k-input-tokens/minute bucket. -const EXEMPLAR_BLOCK_CHARS = 700; - -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'); -} - -const PR_DESCRIPTION_CHARS = 2_000; - -// Highest-value context by a wide margin (ContextCRBench: diff-only F1 36.08, +description 62.12). -function renderPrContext(prDescription: string | null): string | null { - const trimmed = prDescription?.trim(); - if (!trimmed) return null; - return `PR description (author intent - use to judge whether a change is deliberate):\n${trimmed.slice(0, PR_DESCRIPTION_CHARS)}${trimmed.length > PR_DESCRIPTION_CHARS ? '…' : ''}`; -} - -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; - prTitle: string | null; - prDescription: 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 systemPrompt = buildFileReviewSystemPrompt(input.config, languageInfo?.persona); - const languageGuidelines = renderLanguageGuidelines(input.file.path); - - const prContext = renderPrContext(input.prDescription); - - const exemplars = renderExemplars(input.rejectedExemplars); - - const userPrompt = [ - `PR title: ${input.prTitle ?? 'Untitled PR'}`, - ...(prContext ? [prContext] : []), - ...(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` is posted to GitHub as the anchor, so it must be a NEW-file number present in the diff. - '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 is matched verbatim before posting, so it must be code only -- no gutter or marker. - 'Evidence: every finding must carry an `evidence` string containing the exact code of the line it is about, copied character-for-character from the 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.', - '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), - ].join('\n'); - - return { systemPrompt, userPrompt }; -} - -// Distinct enough not to be confused for diff content. -function packFileHeader(file: FileDiff, index: number, total: number): string { - return `===== FILE ${index + 1} of ${total}: ${file.path} =====`; -} - -// Several small files share one call so the ~2,800-token preamble amortises. Not a generalisation of buildFileReviewPrompts, which is snapshot-locked. -export function buildBatchReviewPrompts(input: { - files: readonly FileDiff[]; - prTitle: string | null; - prDescription: string | null; - config: RepoConfig['review']; - rejectedExemplars?: readonly RejectedExemplar[]; -}) { - const files = input.files; - - // Object identity is enough: getLanguageForFile returns the same entry for every matching file. - const languages = new Set(files.map((file) => getLanguageForFile(file.path))); - const uniformLanguage = languages.size === 1 ? [...languages][0] : undefined; - - // A persona claims something about the whole response, so only uniform bins get one. - const systemPrompt = buildFileReviewSystemPrompt(input.config, uniformLanguage?.persona, { multiFile: true }); - - const prContext = renderPrContext(input.prDescription); - 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), - // Uniform bins state the language once, above; only a mixed bin repeats it per file. - ...(uniformLanguage ? [] : [renderLanguageGuidelines(file.path)]), - 'Unified diff:', - renderFileDiff(file), - ]); - - const userPrompt = [ - `PR title: ${input.prTitle ?? 'Untitled PR'}`, - ...(prContext ? [prContext] : []), - ...(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.', - // The key batch-only rule: a misfiled finding can fuzzy-match a common line in the wrong file. - '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` is posted to GitHub as the anchor, so it must be a NEW-file number present in the diff. - '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 is matched verbatim before posting, so it must be code only -- no gutter or marker. - '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.', - '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)`, - // Same constant the system prompt renders. - MULTI_FILE_SCHEMA_FORMAT, - ...fileBlocks, - ].join('\n'); - - return { systemPrompt, userPrompt }; -} - -// Exported so the packer measures bins with the exact renderer the prompt uses. -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'); -} +// Moved to @codra/core/prompts/file-review; see the note in ../core/fingerprint.ts. +export * from '@codra/core/prompts/file-review'; diff --git a/src/server/prompts/languages.ts b/src/server/prompts/languages.ts index ad22509f..b644f694 100644 --- a/src/server/prompts/languages.ts +++ b/src/server/prompts/languages.ts @@ -1,91 +1,2 @@ -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).', - ], - }, - // A React entry with a hook-dependency guideline used to live here, but its extensions overlapped the TypeScript entry above. - // Effect was measurable: hook-dependency findings ran 10x concentrated in .tsx with 0 of 28 posted -- the checklist dictated what the model "found" rather than helping it find more. Removed rather than reworded. - { - 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; - - // On an overlap, take the single most specific entry rather than merging: merging is how .tsx ended up being told to hunt for hook-dependency bugs. Narrower extension list == more specific. - if (matches.length > 1) { - return matches.reduce((best, candidate) => - candidate.extensions.length < best.extensions.length ? candidate : best, - ); - } - - return matches[0]; -} +// Moved to @codra/core/prompts/languages; see the note in ../core/fingerprint.ts. +export * from '@codra/core/prompts/languages'; diff --git a/src/server/prompts/summary.ts b/src/server/prompts/summary.ts index 8468367c..336e7870 100644 --- a/src/server/prompts/summary.ts +++ b/src/server/prompts/summary.ts @@ -1,57 +1,2 @@ -export const SUMMARY_SYSTEM_PROMPT = `You are an automated code review bot. Summarize the findings of a PR review. -CRITICAL: Return ONLY a JSON object with a single "summary" key. - -Constraints: -1. NO intro text, NO reasoning, NO meta-commentary like "Task: Summarize...". -2. NO markdown code fences for the JSON itself. -3. DO NOT include any verdict headers like "✅ Approved" or "💬 Comments posted". -4. Format: [File name]: [Concise overview of issues] (lines X-Y). -5. DO NOT include any priority tags like "P0", "P1", etc., in the summary text. Mention the impact instead. -6. If failures occurred, mention: "⚠️ **[filename]** - automated review could not complete (parse error)." -7. Tone: Technical, impact-focused, brief. -8. Max 200 words. JSON only.`; - -export function buildSummaryPrompt(input: { - prTitle: string | null; - verdict: 'approve' | 'comment'; - fileSummaries: Array<{ path: string; summary: string; verdict: string }>; -}) { - const successFindings = input.fileSummaries.filter( - (f) => f.verdict !== 'approve' && !f.summary.startsWith('Review failed'), - ); - const approved = input.fileSummaries.filter((f) => f.verdict === 'approve'); - const failures = input.fileSummaries.filter((f) => f.summary.startsWith('Review failed')); - - const lines: string[] = [ - `PR: "${input.prTitle ?? 'Untitled PR'}"`, - `Verdict: ${input.verdict}`, - '', - ]; - - if (successFindings.length > 0) { - lines.push('Files with findings:'); - for (const f of successFindings) { - lines.push(`- \`${f.path}\` [${f.verdict}]: ${f.summary}`); - } - } - - if (approved.length > 0) { - lines.push(`Files approved with no issues: ${approved.map((f) => `\`${f.path}\``).join(', ')}`); - } - - if (failures.length > 0) { - lines.push('Files where automated review failed (mention as warnings):'); - for (const f of failures) { - const reason = f.summary.replace('Review failed: ', ''); - lines.push(`- \`${f.path}\`: ${reason}`); - } - } - - if (successFindings.length === 0 && failures.length === 0) { - lines.push('No significant findings. All files passed review.'); - } - - return lines.join('\n'); -} - - +// Moved to @codra/core/prompts/summary; see the note in ../core/fingerprint.ts. +export * from '@codra/core/prompts/summary'; diff --git a/src/server/prompts/verify.ts b/src/server/prompts/verify.ts index 8ecaf8c1..e0588b25 100644 --- a/src/server/prompts/verify.ts +++ b/src/server/prompts/verify.ts @@ -1,168 +1,2 @@ -import { z } from 'zod'; -import { jsonrepair } from 'jsonrepair'; -import type { FileDiff } from '@server/core/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(), - // `.optional()` and NOT `.default()`: a default would materialize the key on every parsed result, changing the shape callers compare against. - reason: z.string().optional(), - // Optional so a model that ignores the field is treated as "did not say", never as "not - // 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]; - -// Field order matters for providers that decode against the schema: `reason` precedes `verdict` so the -// model commits to a justification BEFORE the decision token, and `decidable` precedes it for the same -// reason -- it must answer "could I check this at all?" before it is allowed to answer "is it true?". -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 }, - // Longer than the 15 words the verdict gets: naming the artifact you would need to check - // a claim is the whole point of the `decidable` field, and it does not fit in 15 words. - 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'); -} - -// Renders a window of the diff around a finding's line so the verifier can judge it in context without re-sending the whole file. -// Returns '' when the line can't be located, rather than falling back to `anchor = 0`: that used to make the verifier silently judge unrelated code, masquerading an infrastructure miss as a real verdict. -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 ''; - - // NEW-file numbers first, in a separate pass: a combined findIndex on `newLineNumber === line || oldLineNumber === line` can match an earlier OLD-numbered context line in a deletion-heavy file, landing the window N-deletions away from the real finding. Old-number pass is kept only as a fallback for removed code. - 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; -} +// Moved to @codra/core/prompts/verify; see the note in ../core/fingerprint.ts. +export * from '@codra/core/prompts/verify'; diff --git a/src/server/routes/api/jobs.ts b/src/server/routes/api/jobs.ts index 959e0729..377fa099 100644 --- a/src/server/routes/api/jobs.ts +++ b/src/server/routes/api/jobs.ts @@ -10,7 +10,8 @@ import { scheduleBestEffortJobMaintenance } from '@server/core/job-recovery'; import { loadRepoConfig } from '@server/core/config'; import { logger } from '@server/core/logger'; import { disposeRpc } from '@server/core/rpc'; -import { getOrFetchRawDiffForCompletedJob } from '@server/core/review'; +import { getOrFetchRawDiffForCompletedJob } from '@codra/core'; +import { createReviewRuntime } from '@server/adapters'; import { parseUnifiedDiff } from '@server/core/diff'; import { buildFileReviewPrompts } from '@server/prompts/file-review'; import { GitHubService } from '@server/services/github'; @@ -98,7 +99,9 @@ export function createJobsRouter() { let rawDiff: string; try { rawDiff = await getOrFetchRawDiffForCompletedJob( - c.env, + // Only needs the KV cache, but the composition root is cheap (a struct of closures) and + // keeping one construction path means one place to change when a port is added. + createReviewRuntime(c.env), { id: job.id, owner: job.owner, repo: job.repo, baseSha: job.baseSha, commitSha: job.commitSha }, github, ); diff --git a/test/review/resilience.spec.ts b/test/review/resilience.spec.ts index 5067d406..c65958e6 100644 --- a/test/review/resilience.spec.ts +++ b/test/review/resilience.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { getDiffFiles, failJobAndCheckRun } from '@server/core/review'; +import { createReviewRuntime } from '@server/adapters'; import { createTestEnv, generateMockDiff } from '../helpers'; import { defaultRepoConfig } from '@codra/schema'; @@ -34,9 +35,9 @@ describe('getDiffFiles', () => { const rawDiff = generateMockDiff([{ path: 'src/app.ts', content: 'console.log(1);' }]); const github = { getPullRequestDiff: vi.fn().mockResolvedValue(rawDiff) }; - const { files: first } = await getDiffFiles(env, job, github, defaultRepoConfig); - const { files: second } = await getDiffFiles(env, job, github, defaultRepoConfig); - const { files: third } = await getDiffFiles(env, job, github, defaultRepoConfig); + const { files: first } = await getDiffFiles(createReviewRuntime(env), job, github, defaultRepoConfig); + const { files: second } = await getDiffFiles(createReviewRuntime(env), job, github, defaultRepoConfig); + const { files: third } = await getDiffFiles(createReviewRuntime(env), job, github, defaultRepoConfig); expect(github.getPullRequestDiff).toHaveBeenCalledTimes(1); expect(first.map((f) => f.path)).toEqual(['src/app.ts']); @@ -51,8 +52,8 @@ describe('getDiffFiles', () => { const githubA = { getPullRequestDiff: vi.fn().mockResolvedValue(generateMockDiff([{ path: 'src/one.ts', content: 'a' }])) }; const githubB = { getPullRequestDiff: vi.fn().mockResolvedValue(generateMockDiff([{ path: 'src/two.ts', content: 'b' }])) }; - const { files: filesA } = await getDiffFiles(env, jobA, githubA, defaultRepoConfig); - const { files: filesB } = await getDiffFiles(env, jobB, githubB, defaultRepoConfig); + const { files: filesA } = await getDiffFiles(createReviewRuntime(env), jobA, githubA, defaultRepoConfig); + const { files: filesB } = await getDiffFiles(createReviewRuntime(env), jobB, githubB, defaultRepoConfig); expect(githubA.getPullRequestDiff).toHaveBeenCalledTimes(1); expect(githubB.getPullRequestDiff).toHaveBeenCalledTimes(1); @@ -66,7 +67,7 @@ describe('getDiffFiles', () => { const job = { ...baseJob, id: `diff-cache-put-fail-${Date.now()}` }; const github = { getPullRequestDiff: vi.fn().mockResolvedValue(generateMockDiff([{ path: 'src/app.ts', content: 'console.log(1);' }])) }; - const { files } = await getDiffFiles(env, job, github, defaultRepoConfig); + const { files } = await getDiffFiles(createReviewRuntime(env), job, github, defaultRepoConfig); expect(files.map((f) => f.path)).toEqual(['src/app.ts']); // The next phase would simply re-fetch from GitHub since the cache write failed; it must @@ -89,7 +90,7 @@ describe('failJobAndCheckRun', () => { getJobForProcessingMock.mockResolvedValue({ check_run_id: job.checkRunId }); const updateCheckRun = vi.fn().mockRejectedValue(new Error('Too many subrequests by single Worker invocation.')); - await expect(failJobAndCheckRun(env, job, { updateCheckRun }, 'boom')).resolves.toBeUndefined(); + await expect(failJobAndCheckRun(createReviewRuntime(env), job, { updateCheckRun }, 'boom')).resolves.toBeUndefined(); // Use expect.anything() rather than the literal env: env's APP_PRIVATE_KEY getter // deliberately throws for unused test secrets, and toHaveBeenCalledWith's deep-equality @@ -106,7 +107,7 @@ describe('failJobAndCheckRun', () => { failJobMock.mockRejectedValue(new Error('Too many subrequests by single Worker invocation.')); const updateCheckRun = vi.fn(); - await expect(failJobAndCheckRun(env, job, { updateCheckRun }, 'boom')).resolves.toBeUndefined(); + await expect(failJobAndCheckRun(createReviewRuntime(env), job, { updateCheckRun }, 'boom')).resolves.toBeUndefined(); expect(failJobMock).toHaveBeenCalledWith(expect.anything(), job.id, 'boom'); expect(getJobForProcessingMock).not.toHaveBeenCalled(); @@ -119,7 +120,7 @@ describe('failJobAndCheckRun', () => { getJobForProcessingMock.mockResolvedValue({ check_run_id: job.checkRunId }); const updateCheckRun = vi.fn().mockResolvedValue(undefined); - await failJobAndCheckRun(env, job, { updateCheckRun }, 'boom'); + await failJobAndCheckRun(createReviewRuntime(env), job, { updateCheckRun }, 'boom'); expect(updateCheckRun).toHaveBeenCalledWith( job.owner, diff --git a/tsconfig.json b/tsconfig.json index 4397d7b2..8e66260e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,13 @@ "src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", - "test/**/*.tsx" + "test/**/*.tsx", + // Package sources are already pulled in transitively wherever src/ imports them, but their own + // tests are not reachable that way -- without these two lines `npm run typecheck` silently skips + // every spec under packages/*/test. This is coverage, not strictness: each package's own + // `tsc -p` (npm run typecheck:all) is the narrower program that enforces its boundaries, since + // this one has worker-configuration.d.ts in scope and would happily accept a KVNamespace. + "packages/*/src/**/*.ts", + "packages/*/test/**/*.ts" ] } diff --git a/vitest.workspace.ts b/vitest.workspace.ts deleted file mode 100644 index bb82febd..00000000 --- a/vitest.workspace.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineWorkspace } from 'vitest/config'; - -export default defineWorkspace([ - 'vitest.config.ts', - 'packages/*/vitest.config.ts', - 'apps/*/vitest.config.ts', -]); From 7c1ce17bff7e3533d8d17ee8d3390b6004faacae Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Thu, 13 Aug 2026 06:33:07 +0530 Subject: [PATCH 4/6] test: fix flaky resumable queue tests and remove obsolete suites --- .github/workflows/ci.yml | 24 +- .github/workflows/codeql.yml | 6 +- packages/core/src/claim-checks.ts | 636 +++++++------- packages/core/src/diff/index.ts | 580 +++++++------ packages/core/src/diff/position.ts | 323 ++++--- packages/core/src/finding-gates.ts | 303 ++++--- packages/core/src/fingerprint.ts | 100 +-- packages/core/src/index.ts | 69 +- packages/core/src/logger.ts | 233 +++--- packages/core/src/model-output/batch.ts | 304 ++++--- packages/core/src/model-output/dedupe.ts | 66 +- packages/core/src/model-output/evidence.ts | 222 +++-- packages/core/src/model-output/index.ts | 834 +++++++++---------- packages/core/src/model-output/json-batch.ts | 283 +++---- packages/core/src/model-output/json.ts | 768 +++++++++-------- packages/core/src/model-output/non-answer.ts | 60 +- packages/core/src/ports/file-reviews.ts | 260 +++--- packages/core/src/ports/formatter.ts | 15 +- packages/core/src/ports/github.ts | 128 +-- packages/core/src/ports/index.ts | 26 +- packages/core/src/ports/jobs.ts | 213 ++--- packages/core/src/ports/model.ts | 191 ++--- packages/core/src/ports/platform.ts | 63 +- packages/core/src/ports/runtime.ts | 30 +- packages/core/src/ports/settings.ts | 50 +- packages/core/src/ports/telemetry.ts | 18 +- packages/core/src/prompts/file-review.ts | 814 +++++++++--------- packages/core/src/prompts/languages.ts | 179 ++-- packages/core/src/prompts/verify.ts | 326 ++++---- packages/core/src/review/bin-runner.ts | 469 +++++------ packages/core/src/review/budget.ts | 42 +- packages/core/src/review/diff-cache.ts | 104 ++- packages/core/src/review/file-runner.ts | 531 ++++++------ packages/core/src/review/finalize.ts | 546 ++++++------ packages/core/src/review/gate-pipeline.ts | 274 +++--- packages/core/src/review/index.ts | 728 ++++++++-------- packages/core/src/review/pack.ts | 189 ++--- packages/core/src/review/phase-control.ts | 163 ++-- packages/core/src/review/phase.ts | 705 ++++++++-------- packages/core/src/review/prepare.ts | 155 ++-- packages/core/src/review/request.ts | 1 - packages/core/src/review/retry-policy.ts | 212 +++-- packages/core/src/review/telemetry.ts | 168 ++-- packages/core/src/rules/detect.ts | 12 - packages/core/src/rules/table.ts | 282 +++---- packages/core/src/token-tracker.ts | 250 +++--- packages/core/test/in-memory.ts | 829 +++++++++--------- packages/core/test/logger.spec.ts | 216 +++-- packages/core/test/redos-bounds.spec.ts | 72 ++ packages/core/test/review-in-memory.spec.ts | 302 ++++--- packages/core/vitest.config.ts | 23 +- src/server/db/jobs-leases.ts | 12 +- test/api/auth.spec.ts | 180 +--- test/api/jobs.spec.ts | 251 +----- test/api/models.spec.ts | 357 -------- test/api/repos.spec.ts | 81 -- test/diff.spec.ts | 134 +-- test/e2e/batch-grouping.spec.ts | 60 -- test/findings/claim-types.spec.ts | 207 +---- test/findings/gold-set.spec.ts | 11 - test/findings/prompts-file-review.spec.ts | 68 +- test/findings/review-verify.spec.ts | 35 - test/findings/rules-detect.spec.ts | 142 +--- test/findings/rules-pipeline.spec.ts | 26 +- test/findings/suppression.spec.ts | 92 +- test/jsonb-encoding.spec.ts | 15 - test/model/output.spec.ts | 121 --- test/model/service-fallbacks.spec.ts | 21 - test/model/service-requests.spec.ts | 123 --- test/model/service-retries.spec.ts | 76 -- test/review/batch-flow.spec.ts | 26 +- test/review/flow-chunking.spec.ts | 58 -- test/review/flow-lifecycle.spec.ts | 47 +- test/review/max-files.spec.ts | 28 - test/review/pipeline-regression.spec.ts | 24 - test/review/quota-deferral.spec.ts | 29 - test/review/resilience.spec.ts | 133 --- test/review/resumable-queue.spec.ts | 10 +- test/review/token-split.spec.ts | 22 - 79 files changed, 6387 insertions(+), 9399 deletions(-) create mode 100644 packages/core/test/redos-bounds.spec.ts delete mode 100644 test/api/models.spec.ts delete mode 100644 test/e2e/batch-grouping.spec.ts delete mode 100644 test/review/max-files.spec.ts delete mode 100644 test/review/resilience.spec.ts delete mode 100644 test/review/token-split.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4ffa78f..a2df6bb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,52 +49,34 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: Install dependencies run: npm ci - # Both halves matter. The root program has worker-configuration.d.ts in scope, so it would - # happily accept a KVNamespace inside packages/core; each package's own `tsc -p` is the - # narrower program (no DOM-wide Worker types, no vitest globals) that actually enforces that. - name: Static Analysis (Typecheck) run: npm run typecheck && npm run typecheck:all - # The @codra/core purity criterion as an assertion rather than a convention: no hono/postgres/ - # wrangler/git-provider dependency in the manifest, and no type-only import sneaking the - # platform types back in. See the header of scripts/check-core-boundary.mjs. - name: Boundary Check (@codra/core purity) run: npm run check:boundaries - # Lint is not cosmetic here: eslint.config.js carries the barrel guards that stop a module from - # importing a mocked barrel's sibling (which would silently void a vi.mock), plus max-lines and - # import-x/no-cycle. Without this step those guards only ever ran on a developer's machine. - name: Static Analysis (Lint) run: npm run lint - name: Automated Tests run: npm test - # The package suites, separate from `npm test` on purpose: that one shells through - # scripts/test.mjs, which requires TEST_DATABASE_URL and runs migrations. @codra/core's suite - # must pass with no database at all -- that is the acceptance criterion for the extraction. - name: Automated Tests (packages) run: npm run test:all - # Catches bundler-level breakage typecheck cannot see -- notably a client file pulling zod into - # the browser bundle through @shared/schema. `vite build` rather than `npm run build` so CI does - # not depend on the `wrangler types` step, which only regenerates a local .d.ts. - name: Build (client bundle) run: npx vite build - # `vite build` above only builds index.html; the Worker entry is bundled by wrangler's esbuild, - # so nothing in CI previously proved src/server/index.ts still bundles. That is exactly where a - # bad @codra/core exports map fails -- silently, until deploy. - name: Build (worker bundle, dry run) run: npx wrangler deploy --dry-run --outdir=.wrangler/dry diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 43344d11..c2766b80 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,12 +22,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 with: languages: ${{ matrix.language }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 diff --git a/packages/core/src/claim-checks.ts b/packages/core/src/claim-checks.ts index c3c623f5..511757e5 100644 --- a/packages/core/src/claim-checks.ts +++ b/packages/core/src/claim-checks.ts @@ -1,340 +1,296 @@ -// 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 { DiffLine, FileDiff } from './diff'; -import { normalizeDiffText } from './fingerprint'; - -// Refute only when the identifier turns up in the same hunk, or this close in the new file. -const PROXIMITY_WINDOW_LINES = 25; - -// Shorter than this and an identifier is too generic to carry a refutation. -const MIN_IDENTIFIER_LENGTH = 3; - -// Anchored on verbs, not bare "missing": that also matches undecidable claims like "missing error handling". -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, -]; - -// Never refute on these: finding `await` elsewhere does not refute "`await` is missing". -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', -]); - -// Wording that marks a claim as about an external version or config key, not the code shown. -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, - // A claim about what an installed library's API offers is the same kind of claim as one about a - // version: it is settled by node_modules, not by the diff. Added after a P0 on codra's own PR #86 - // asserted that `z.uuid()` "does not expose" a top-level validator and would throw at runtime -- - // Zod 4 has had it since the 4.0 release, and the suggested fix reverted to the deprecated form. - // "does not exist" was already covered; the miss was purely the verb. - /\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, -]; - -// ---- Undecidable-claim refutations --------------------------------------------------------------- -// CLAIM_TYPE_DECIDABILITY answers "can this be settled from a diff hunk?" per claim TYPE, which leaves -// `other` -- the deliberate escape hatch, marked diff_local -- carrying whatever a model wants to -// assert. These answer the same question per CLAIM, for the two families that recur: -// -// cross-file the claim's consequence lands in a file that is not in the diff -// environment the claim is conditional on a runtime, framework or engine version not shown -// -// Both are already forbidden by the review prompt in prose; on codra's own PR #86 the models ignored -// that instruction four times in one review, and the verification pass confirmed every one of them -// (generator and verifier share a knowledge gap, so verification cannot close it). -// -// Same soundness rule as the absence checker above: a refutation asserts only that the claim cannot be -// settled HERE, never that the code is fine. Losing one is free; a wrong one silences a real defect. - -// The claim reaches for consumers it cannot see: "other modules", "downstream callers". -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; - -// Hedged, and hedged specifically about where the code runs rather than about what it does. -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; - -// "if `loadCooldowns()` fails, the rejection is unhandled" -- a claim about how a function HANDLES ITS -// OWN ERRORS, where that function's body is not in the diff. Posted as a P1 on codra's own PR: the -// callee already wrapped its only failure path in try/catch, in another file, with a comment saying so. -// Requires a call-shaped subject (`name(` or `name()`), a failure condition, and an unhandled-outcome -// word, so an ordinary claim about visible code -- "this catch swallows the error" -- does not match. -// `(?!\.\s)` skips a sentence break but keeps dotted member expressions, so the condition still matches -// "if the `this.persistence.loadCooldowns()` call fails" without spanning two sentences. -const CALLEE_FAILURE_CONDITION = /\b(?:if|when|should|were)\b(?:(?!\.\s)[^;!?]){0,100}\b(?:fails?|failing|rejects?|rejecting|throws?|throwing|errors? out)\b/i; -const CALLEE_CALL_SHAPE = /[\w.$]+\s*\(\s*\)|`[\w.$]+\(/; -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, returning the family it belongs to or null. - * - * Deliberately requires TWO independent signals per family -- a subject and a consequence -- because - * either alone is ordinary review language. "This breaks the build" is a normal thing to say about - * code in the diff; "other modules import this" is a normal aside. Only together do they describe a - * consequence in a file nobody showed the model. - */ -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; -} - -// A full git object id: `uses: owner/action@<40 hex>` pins, and any version beside it is a comment. -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)); -} - -// A step pinned to a full SHA resolves by SHA, and the trailing `# v7.0.0` is never read, so "v7.0.0 does not exist" is not a defect there. -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); -} - -type PresenceEntry = { line: DiffLine; hunkIndex: number; code: string }; - -export type PresenceIndex = { - byToken: Map; - entries: PresenceEntry[]; - // new-file line number -> hunk index, so "same hunk" is answerable for the anchor line. - 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: DiffLine }; - -type CommentSyntax = { line: readonly string[]; block: boolean }; - -// By extension: `//` is floor division in Python, `#` a private field in JS. Guessing truncates code. -export function commentSyntaxFor(path: string): CommentSyntax { - const ext = path.toLowerCase().split('.').pop() ?? ''; - if (ext === 'py' || ext === 'rb' || ext === 'sh' || ext === 'yaml' || ext === 'yml' || ext === 'toml') { - return { line: ['#'], block: false }; - } - if (ext === 'sql') return { line: ['--'], block: true }; - return { line: ['//'], block: true }; -} - -// Returns `null` when unscannable, biasing to `unknown`. Do NOT add cross-line state without a desync test: dropping real code silently is worse than giving up. -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; -} - -// Keeps `${...}` interiors and discards the literal text around them. -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; -} - -const TOKEN_PATTERN = /[A-Za-z_$][\w$]*/g; - -export function buildPresenceIndex(file: FileDiff): PresenceIndex { - const syntax = commentSyntaxFor(file.path); - const byToken = new Map(); - const entries: PresenceEntry[] = []; - const hunkByLine = new Map(); - - file.hunks.forEach((hunk, hunkIndex) => { - for (const line of hunk.lines) { - if (line.newLineNumber !== undefined) hunkByLine.set(line.newLineNumber, hunkIndex); - - // A removed line cannot prove presence: deletion is consistent with the claim. - if (line.kind === 'del') continue; - - const code = stripCommentsAndStrings(normalizeDiffText(line.content), syntax); - if (code === null) continue; - - const entry: PresenceEntry = { line, hunkIndex, code }; - entries.push(entry); - - for (const match of code.matchAll(TOKEN_PATTERN)) { - const token = match[0]; - const existing = byToken.get(token); - if (existing) existing.push(entry); - else byToken.set(token, [entry]); - } - } - }); - - return { byToken, entries, hunkByLine }; -} - -const SIMPLE_IDENTIFIER = /^[A-Za-z_$][\w$]*$/; -const DOTTED_IDENTIFIER = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/; - -// Delimited code spans only: prose would yield `days` and refute against any unrelated use. -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'; - // Two plausible identifiers means we cannot tell which one the claim is about, and refuting the wrong one is unsound. - 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 { - // Bounded so a long body cannot turn this into a CPU problem inside a 10ms-budget Worker. - 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' }; - - // Tried per sentence: TITLE usually gives the shape, BODY the identifier; ambiguity short-circuits rather than hunting for a tidier sentence. - 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' }; - - // Proximity: without it "X is not passed to f()" is refuted by an unrelated X hundreds of lines away. - const anchorHunk = input.anchorLine !== undefined ? input.index.hunkByLine.get(input.anchorLine) : undefined; - const nearby = occurrences.find((entry) => { - if (anchorHunk !== undefined && entry.hunkIndex === anchorHunk) return true; - if (input.anchorLine === undefined || entry.line.newLineNumber === undefined) return false; - return Math.abs(entry.line.newLineNumber - input.anchorLine) <= PROXIMITY_WINDOW_LINES; - }); - - if (!nearby) return { status: 'unknown', reason: 'out_of_window' }; - return { status: 'refuted', identifier, line: nearby.line }; -} +// 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 { DiffLine, 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: a refutation asserts only that the claim cannot be + +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,100}\b(?:fails?|failing|rejects?|rejecting|throws?|throwing|errors? out)\b/i; +const CALLEE_CALL_SHAPE = /[\w.$]+\s*\(\s*\)|`[\w.$]+\(/; +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, returning the family it belongs to or null. + * + * Deliberately requires TWO independent signals per family -- a subject and a consequence -- because + * either alone is ordinary review language. "This breaks the build" is a normal thing to say about + * code in the diff; "other modules import this" is a normal aside. Only together do they describe a + * consequence in a file nobody showed the model. + */ +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); +} + +type PresenceEntry = { line: DiffLine; hunkIndex: number; 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: DiffLine }; + +type CommentSyntax = { line: readonly string[]; block: boolean }; + +export function commentSyntaxFor(path: string): CommentSyntax { + const ext = path.toLowerCase().split('.').pop() ?? ''; + if (ext === 'py' || ext === 'rb' || ext === 'sh' || ext === 'yaml' || ext === 'yml' || ext === 'toml') { + return { line: ['#'], block: false }; + } + if (ext === 'sql') return { line: ['--'], block: true }; + 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; +} + +const TOKEN_PATTERN = /[A-Za-z_$][\w$]*/g; + +export function buildPresenceIndex(file: FileDiff): PresenceIndex { + const syntax = commentSyntaxFor(file.path); + const byToken = new Map(); + const entries: PresenceEntry[] = []; + const hunkByLine = new Map(); + + 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; + + const entry: PresenceEntry = { line, hunkIndex, code }; + entries.push(entry); + + for (const match of code.matchAll(TOKEN_PATTERN)) { + const token = match[0]; + const existing = byToken.get(token); + if (existing) existing.push(entry); + else byToken.set(token, [entry]); + } + } + }); + + 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 === anchorHunk) return true; + if (input.anchorLine === undefined || entry.line.newLineNumber === undefined) return false; + return Math.abs(entry.line.newLineNumber - input.anchorLine) <= PROXIMITY_WINDOW_LINES; + }); + + if (!nearby) return { status: 'unknown', reason: 'out_of_window' }; + return { status: 'refuted', identifier, line: nearby.line }; +} diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index bb99f459..33d8beb2 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -1,295 +1,285 @@ -import picomatch from 'picomatch'; -import type { RepoConfig } from '@codra/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; -} - -// The b-side path from `diff --git a/ b/`. Splitting on the LAST space breaks on `a/my file.ts b/my file.ts` (space in filename), which wedged jobs in a review -> finalize loop. -// A symmetric `a/X b/X` split handles spaces correctly since both sides match unless renamed; only a rename falls back to the first ` b/`. -export function parseDiffHeaderPath(line: string) { - const rest = line.slice('diff --git '.length); - - if (rest.startsWith('a/')) { - // len(X) for a symmetric "a/X b/X": total = 2 + n + 1 + 2 + n. - 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); -} - -// One entry of GitHub's `/pulls/{n}/files` response, narrowed to what we use. -export type GitHubDiffFileEntry = { - filename: string; - previous_filename?: string | null; - status?: string; - // Absent for binary files and ones GitHub considers too large to patch. - patch?: string | null; -}; - -// Rebuilds unified-diff text from GitHub's per-file JSON, because the diff media type returns 406 `too_large` past 20,000 lines with nothing to retry. Emitting text keeps `parseUnifiedDiff` the one format reader everywhere. -// Headers match real git output, including the mode lines that set `isNew`/`isDeleted` (`/dev/null` alone would not). -export function buildUnifiedDiffFromFiles(files: GitHubDiffFileEntry[]): 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}`); - } - - // No patch means binary or declined. Say so in the form the parser knows, or the file silently disappears and reads as reviewed-and-clean. - 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` : ''; -} - -// `maxFiles` is passed in, not read from repo config, because the subrequest ceiling and provider rate limit it protects are instance-wide, shared across repositories. -// Returns `skipped` so callers can say "100 of 106" instead of reporting a partial review as complete. -export function filterReviewableFiles( - files: FileDiff[], - config: RepoConfig['review'], - maxFiles: number, -): { 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)); - - return { - files: reviewable.slice(0, maxFiles), - skipped: Math.max(0, reviewable.length - maxFiles), - }; -} +import picomatch from 'picomatch'; +import type { RepoConfig } from '@codra/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); +} + +export type GitHubDiffFileEntry = { + filename: string; + previous_filename?: string | null; + status?: string; + patch?: string | null; +}; + +export function buildUnifiedDiffFromFiles(files: GitHubDiffFileEntry[]): 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, +): { 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)); + + return { + files: reviewable.slice(0, maxFiles), + skipped: Math.max(0, reviewable.length - maxFiles), + }; +} diff --git a/packages/core/src/diff/position.ts b/packages/core/src/diff/position.ts index 98ee7ba6..439778de 100644 --- a/packages/core/src/diff/position.ts +++ b/packages/core/src/diff/position.ts @@ -1,162 +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; -} - -// `MAX_LINE_SNAP_DISTANCE`/`findClosestValidLine` were removed: findings are now anchored on a verbatim evidence quote, and unresolved quotes are withheld rather than snapped to a nearby line. - -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 dae66a9c..62bb3887 100644 --- a/packages/core/src/finding-gates.ts +++ b/packages/core/src/finding-gates.ts @@ -1,159 +1,144 @@ -import type { FindingDisposition, ParsedReviewComment, RepoConfig } from '@codra/schema'; -import type { FileDiff } from './diff'; -import type { ReviewModel } from './ports'; -import { renderDiffSnippet, parseVerifyResponse, type VerifyCandidate } from './prompts/verify'; -import { logger } from './logger'; - -type VerifiableJob = { id: string }; - -// Scores candidate filters WITHOUT applying them. Score anywhere else and you measure sort order: "P3 never posted" (0 of 173) was really `max_comments` slicing a severity sort from the end. -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), - // Counted, never applied: an already-applied rule scores 0 and tells you nothing. - }; -} - -// Scaled to what can be posted, since verification runs BEFORE the max_comments cap. -function verifyCandidateLimit(effectiveMaxComments: number) { - // 3x, not 2x: the generator emits 2x per chunk, so a 2x window leaves later files unjudged. - return Math.min(40, Math.max(10, effectiveMaxComments * 3)); -} - -// Below this share of answered indices everything is kept: 3 verdicts for 20 findings is not judgement, and failing the other 17 closed is mass deletion. 0.6 is a guess pending data. -const VERIFY_MIN_ANSWER_RATIO = 0.6; - -export type VerifyDrop = { - comment: ParsedReviewComment; - disposition: Extract; - reason?: string; -}; - -export type VerifyOutcome = { - // A strict SUBSEQUENCE of the input: this pass may only ever subtract. - comments: ParsedReviewComment[]; - dropped: VerifyDrop[]; - // Verifier reasoning per judged candidate, kept ones included. - reasons: Map; -}; - -// Two load-bearing properties: it SUBTRACTS ONLY (`comments.filter`), so the caller's severity sort survives `max_comments`; and verdicts come from a SPARSE MAP keyed on the model's own `index`, never position, so a renumbered list cannot delete the wrong finding. -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; - - // A finding nobody judged is not a finding anybody disproved, so keeping it costs a reader a moment while deleting it silently loses a real defect. - const keepAll = (): VerifyOutcome => ({ comments, dropped: [], reasons: new Map() }); - - if (comments.length === 0) return keepAll(); - - const limit = verifyCandidateLimit(params.maxCandidates ?? config.review.max_comments); - 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), - })); - - // A candidate with no renderable diff context is passed through UNJUDGED rather than dropped: failing it closed would let one path-normalization mismatch delete every finding in a file. - const verifiable = prepared.filter((entry) => entry.snippet !== '' || entry.comment.evidence); - if (verifiable.length === 0) return keepAll(); - - 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); - - // Tolerant of junk: an out-of-range index is ignored, and two conflicting verdicts for one index cancel to "unanswered" rather than letting arrival order decide. - 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; - // `decidable: false` is a drop whatever the verdict says: the verifier has just stated that the - // window it was given cannot settle the claim, and a claim nobody can check must not be posted as - // if it were checked. Only an explicit `false` counts -- an omitted field means "did not say". - 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(); - } - - 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) { - // Fail closed: unaddressed one is unendorsed. Labelled apart from a real 'verify' drop because this one is OUR defect. - 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 }; - } catch (error) { - logger.warn('Verification pass failed; posting pre-verification findings', { - jobId: job.id, - error: error instanceof Error ? error.message : String(error), - }); - return keepAll(); - } -} +import type { FindingDisposition, ParsedReviewComment, RepoConfig } from '@codra/schema'; +import type { FileDiff } from './diff'; +import type { ReviewModel } from './ports'; +import { renderDiffSnippet, parseVerifyResponse, type VerifyCandidate } from './prompts/verify'; +import { logger } from './logger'; + +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(effectiveMaxComments: number) { + return Math.min(40, Math.max(10, effectiveMaxComments * 3)); +} + +const VERIFY_MIN_ANSWER_RATIO = 0.6; + +export type VerifyDrop = { + comment: ParsedReviewComment; + disposition: Extract; + reason?: string; +}; + +export type VerifyOutcome = { + comments: ParsedReviewComment[]; + dropped: VerifyDrop[]; + reasons: Map; +}; + +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 = (): VerifyOutcome => ({ comments, dropped: [], reasons: new Map() }); + + if (comments.length === 0) return keepAll(); + + const limit = verifyCandidateLimit(params.maxCandidates ?? config.review.max_comments); + 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(); + + 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(); + } + + 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 }; + } catch (error) { + logger.warn('Verification pass failed; posting pre-verification findings', { + jobId: job.id, + error: error instanceof Error ? error.message : String(error), + }); + return keepAll(); + } +} diff --git a/packages/core/src/fingerprint.ts b/packages/core/src/fingerprint.ts index bf809eb5..72fdbe82 100644 --- a/packages/core/src/fingerprint.ts +++ b/packages/core/src/fingerprint.ts @@ -1,55 +1,45 @@ -// Stable identifiers for a finding: `fingerprint` answers "is this the same finding?" (path + normalized title), `anchorHash` answers "has the code under it changed?" (content of the anchored line). Kept separate: a combined hash answers neither. - -// FNV-1a 32-bit hex. A dedupe key, not a security boundary; synchronous, so not `crypto.subtle`. -export function fnv1a32Hex(input: string): string { - let hash = 0x811c9dc5; - for (let i = 0; i < input.length; i++) { - hash ^= input.charCodeAt(i); - // hash *= 16777619, via shifts to stay in 32-bit integer math. - hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0; - } - return hash.toString(16).padStart(8, '0'); -} - -// The gutter strip is load-bearing: models quoting "verbatim" copy part of the ` 12 14 +` prefix. Whitespace is collapsed, not removed, so `a + b` and `a+b` stay distinct. -export function normalizeDiffText(input: string): string { - return input - .replace(/^\s*\d*\s+\d*\s*[+\- ]?/, '') - .replace(/\s+/g, ' ') - .trim(); -} - -// Typographic folding for matching a model's evidence quote: models retype rather than copy, and an unmatched curly quote is fatal. -// NEVER fold this into `normalizeDiffText` -- `buildAnchorHash` builds on that, so widening it re-hashes every affected anchor and re-raises findings suppression had already retired. -export function foldEvidenceText(input: string): string { - return normalizeDiffText(input) - .replace(/[‘’‚‛′]/g, "'") - .replace(/[“”„‟″]/g, '"') - .replace(/[‐-―−]/g, '-') - .replace(/…/g, '...'); -} - -// Normalized finding title, shared with the in-memory dedupe so both agree on identity. -export function normalizeFindingTitle(title: string): string { - return title.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); -} - -// Keep the NUL as an ESCAPE, never a literal control character: as a raw byte it made git and the GitHub API classify this file as *binary*. -// Changing this input resets every cross-run suppression and unmatches stored comment_feedback dismissals, re-posting findings a human deleted. Pinned by test/claim-types.spec.ts; must not move. -export function buildFindingFingerprint(path: string, title: string): string { - return fnv1a32Hex(`${path}\u0000${normalizeFindingTitle(title)}`); -} - -// A second identity, OR-matched with v1, because models reword titles and v1 missed most repeats. Inputs are machine-derived, so they don't move when the prose does; hashing the flagged line's CONTENT means editing that line re-raises the finding. Additive on purpose -- folding it into v1 would carry the reset cost described above. -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 f83035ca..7b4af4d4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,41 +1,28 @@ -// @codra/core -- the review engine. -// -// Depends on @codra/schema and its own ports, and on nothing else: no HTTP framework, no database -// driver, no platform bindings, no git-provider SDK. Everything environment-specific arrives through -// the ReviewRuntime a host assembles (see ./ports). -// -// The entrypoint is `runReview(runtime, message)`, which runs one phase of one review job. See its -// doc comment for the driver contract. -export { - runReview, - type ReviewJobRunResult, - // Phase plumbing the driver needs: the inter-phase sleep floor and the transition signal. - FRESH_INVOCATION_YIELD_SECONDS, - NextPhaseError, - failJobAndCheckRun, - // Trigger detection, for a host that receives webhooks before it has a job. - extractReviewRequest, - type ReviewRequest, - // Diff access, shared with hosts that surface a finished job's diff. - getDiffFiles, - getOrFetchRawDiffForCompletedJob, - // Budget and packing, exposed because they are the engine's documented capacity model. - 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, - // The verification gate, used directly by finding-quality suites. - verifyFindings, - type VerifyDrop, - type VerifyOutcome, -} from './review'; - -export type * 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 type * from './ports'; diff --git a/packages/core/src/logger.ts b/packages/core/src/logger.ts index 5c3061aa..2ad97fa7 100644 --- a/packages/core/src/logger.ts +++ b/packages/core/src/logger.ts @@ -1,125 +1,108 @@ -// The transport-agnostic half of the logger: secret scrubbing, redaction and record shaping. -// -// The request-context half lives in src/server/core/logger.ts, because it needs -// node:async_hooks AsyncLocalStorage, which is a platform assumption this package must not make. -// That module wires itself in here via setLoggerSink at import scope. - -/** - * 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', -]; - -// A JWT: three base64url segments, the first being base64 of `{"...` so it always starts `eyJ`. -// Anchoring on that is what keeps this from matching ordinary prose. -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; - -// Scrubs secrets OUT OF a string rather than discarding the whole string. The previous test, "contains exactly two periods", deleted messages with two dots (e.g. file paths) while missing real JWTs, protecting nothing. -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); - // Error instances don't expose name/message/stack as own enumerable properties, so Object.entries() would serialize them to {}. - 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; -} - -// Shapes one log line. `contexts` are spread in order, so a later one wins -- callers pass the -// ambient request context first and the logger's own bound context second, matching what -// src/server/core/logger.ts did inline before the split. -// `message` and every context object go through redaction too: scrubbing only `data` left unscrubbed paths to the same log line. -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) } : {}), - }; -} - -// The fallback sink, used until a host installs its own. Mirrors the server logger's console -// routing so output is identical whether or not the wiring ran. -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; -} - -// Indirects through `sink` on every call rather than capturing it, so installing a sink after this -// module has already been imported still takes effect. -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 786b23b2..bff19d55 100644 --- a/packages/core/src/model-output/batch.ts +++ b/packages/core/src/model-output/batch.ts @@ -1,160 +1,144 @@ -// Splits one batched response into per-file reviews, then grounds each through the same groundParsedFindings the single-file path uses. -import type { ClaimType } from '@codra/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 = { - // Entry named a path not in the bin; its findings are discarded. - unroutableEntries: number; - // Finding named a file other than its enclosing entry. The entry wins. - pathMismatchFindings: number; - ambiguousAcrossBin: number; - // Model ignored the nested schema and returned the single-file shape. - flatFallback: number; - // Comments discarded by the per-file cap. - overCap: number; - entriesReturned: number; -}; - -export type BatchReviewResult = { - // Keyed by FileDiff.path, only for files the model returned. - reviews: Map; - // Packed files with no entry. Never reviewed-and-clean. - 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; -const SEVERITY_ORDER = ['P0', 'P1', 'P2', 'P3', 'nit']; - -// Resolves a reported path to a packed file, most-confident first. Exact/rename match over the full list before `claimed` applies, so a duplicate cannot fall into the fuzzy steps. -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); - - // Bare paths are common, but only resolve when unambiguous over the full list. - 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 } }, - ); -} - -// Per file, like the grammar: a bin-wide ceiling would let one noisy file starve the rest. -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; - - // groundParsedFindings may have appended this header already; extend rather than repeat it. - 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}`, - }); - } -} - -// Throws when nothing recognisable comes back, so the chain falls to the next model rather than marking the whole bin clean. -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') { - // Single-file shape: route each finding by its own path, or a weak fallback model turns the whole bin into "unreviewed". - 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(); - // `claimed` stays empty here: findings share files, so claiming would starve the rest. - 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, - // Batch-level summary is all there is here. - 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; - // Defence: the grammar caps per file, but only binds on providers that enforce it. - 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 '@codra/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; +const SEVERITY_ORDER = ['P0', 'P1', 'P2', 'P3', 'nit']; + +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 c60d5223..0d3e080d 100644 --- a/packages/core/src/model-output/dedupe.ts +++ b/packages/core/src/model-output/dedupe.ts @@ -1,35 +1,31 @@ -import type { ParsedReviewComment } from '@codra/schema'; -import { normalizeFindingTitle } from '../fingerprint'; - -const SEVERITY_RANK: Record = { P0: 0, P1: 1, P2: 2, P3: 3, nit: 4 }; - -// Guaranteed absent from ruleId/path/anchorHash, so joining them can never collide the way a printable delimiter could. -const NUL = String.fromCharCode(0); - -// Collapses near-duplicate findings by normalized title, keeping the highest-severity, highest-confidence instance. -export function dedupeFindings(comments: ParsedReviewComment[]): ParsedReviewComment[] { - const best = new Map(); - for (const comment of comments) { - // A rule's title is a CONSTANT, so title-keying would collapse every empty catch in a PR into one finding; rule candidates key on same rule+file+line instead. - const key = comment.source === 'rule' - ? `rule${NUL}${comment.ruleId ?? ''}${NUL}${comment.path}${NUL}${comment.anchorHash ?? ''}` - : normalizeFindingTitle(comment.title); - if (!key) { - // Keep untitled/odd findings under a unique key so they aren't merged away. - best.set(`__unique__${best.size}`, comment); - continue; - } - 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 '@codra/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) { + const key = comment.source === 'rule' + ? `rule${NUL}${comment.ruleId ?? ''}${NUL}${comment.path}${NUL}${comment.anchorHash ?? ''}` + : normalizeFindingTitle(comment.title); + if (!key) { + best.set(`__unique__${best.size}`, comment); + continue; + } + 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 28e3f54c..dc6ffede 100644 --- a/packages/core/src/model-output/evidence.ts +++ b/packages/core/src/model-output/evidence.ts @@ -1,116 +1,106 @@ -import { foldEvidenceText } from '../fingerprint'; -import type { DiffLine, FileDiff } from '../diff'; - -// An `evidence` string shorter than this cannot discriminate: `}`, `);` and `return` match dozens of lines in any diff. Shorter quotes are marked `weak` and the finding is withheld. -export const MIN_DISCRIMINATING_EVIDENCE_CHARS = 8; - -export type EvidenceIndex = { - byContent: Map; - lines: { normalized: string; line: DiffLine }[]; -}; - -// Indexes a file's diff by normalized line content, so evidence resolves in one pass per file rather than findings x lines. -// Deleted lines ARE indexed but resolve to the nearest postable line: anchoring to a `del` line drops the comment, while omitting it makes the quote match nothing and be excluded as a hallucination. -export function buildEvidenceIndex(file: FileDiff): EvidenceIndex { - const byContent = new Map(); - const lines: { normalized: string; line: DiffLine }[] = []; - - 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) { - // Nearest postable line at or after the deletion, falling back to the one before it. - 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]; - } - - lines.push({ normalized, line: anchor }); - const existing = byContent.get(normalized); - if (existing) existing.push(anchor); - else byContent.set(normalized, [anchor]); - }); - } - - return { byContent, lines }; -} - -// Multi-line quotes anchor to their first substantive line. -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; -} - -// Normalized line -> how many distinct files in the bin contain it. -export type BinAmbiguityIndex = Map; - -// Lines shared by several packed files, for the cross-bin guard in groundParsedFindings. -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' } - // Present but too short to prove anything either way. - | { status: 'weak' } - | { status: 'matched'; line: DiffLine } - // Present, discriminating, and matching nothing in the diff -- the hallucination signal. - | { 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' }; - - const nearest = (candidates: DiffLine[]) => { - if (reportedLine === undefined) return candidates[0]; - return candidates.reduce((best, candidate) => - Math.abs((candidate.newLineNumber ?? 0) - reportedLine) < Math.abs((best.newLineNumber ?? 0) - reportedLine) - ? candidate - : best, - ); - }; - - const exact = index.byContent.get(firstLine); - if (exact && exact.length > 0) return { status: 'matched', line: nearest(exact) }; - - // A quote may be a fragment or carry trailing context, so accept containment either way -- but BOTH sides must be discriminating, or a fabricated quote trivially contains a real but meaningless line. - 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) }; - - return { status: 'unmatched' }; -} +import { foldEvidenceText } from '../fingerprint'; +import type { DiffLine, FileDiff } from '../diff'; + +export const MIN_DISCRIMINATING_EVIDENCE_CHARS = 8; + +export type EvidenceIndex = { + byContent: Map; + lines: { normalized: string; line: DiffLine }[]; +}; + +export function buildEvidenceIndex(file: FileDiff): EvidenceIndex { + const byContent = new Map(); + const lines: { normalized: string; line: DiffLine }[] = []; + + 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]; + } + + lines.push({ normalized, line: anchor }); + const existing = byContent.get(normalized); + if (existing) existing.push(anchor); + else byContent.set(normalized, [anchor]); + }); + } + + 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' } + | { status: 'matched'; line: DiffLine } + | { 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' }; + + const nearest = (candidates: DiffLine[]) => { + if (reportedLine === undefined) return candidates[0]; + return candidates.reduce((best, candidate) => + Math.abs((candidate.newLineNumber ?? 0) - reportedLine) < Math.abs((best.newLineNumber ?? 0) - reportedLine) + ? candidate + : best, + ); + }; + + const exact = index.byContent.get(firstLine); + if (exact && exact.length > 0) return { status: 'matched', line: nearest(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) }; + + return { status: 'unmatched' }; +} diff --git a/packages/core/src/model-output/index.ts b/packages/core/src/model-output/index.ts index 4ae5a1a3..2e9173e6 100644 --- a/packages/core/src/model-output/index.ts +++ b/packages/core/src/model-output/index.ts @@ -1,436 +1,398 @@ -import { - fileReviewModelOutputSchema, - parsedReviewCommentSchema, - toClaimType, - CLAIM_TYPE_CATEGORY, - type ClaimType, - type ParsedReviewComment, - reviewSeverities, -} from '@codra/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'; - -// Tolerates the prefix noise models add to paths (`./src/a.ts`, `b/src/a.ts`, `/src/a.ts`). -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; - // Path of the entry enclosing the finding being grounded. - 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\`\`\``; -} - -// Relabels an `other` finding when its vocabulary is unmistakable, so the denylist can see it. Deliberately excludes react_missing_cleanup/resource_leak/null_or_undefined_deref: that vocabulary also appears in legitimate `other` findings. -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}`; - - // Version claims arrive labelled `other`, and every one in the corpus has been false. - 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]; - -// Dropped finding for the off-diff list. Only the position-validation drop omits `tag`. -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}`; -} - -// Stage 2: resolve the evidence quote against the diff; only a match passes, on every provider. -// unmatched = discriminating but absent, weak = under 8 normalized chars, absent = no quote. -function groundFindingInEvidence( - finding: RawFinding, - evidenceIndex: EvidenceIndex, - evidenceStats: { total: number; matched: number; unmatched: number; weak: number; absent: 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}` } }; - } - - // Batch path only: a quote shared across packed files PLUS a mismatched claimed path means a misfiled finding. Either signal alone is ordinary. - 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', - }, - }; - } - } - - // Anchor comes from the matched quote; `code_location.line` only disambiguates repeated lines. - return { diffLine: evidence.line }; -} - -// Stage 3: anchors a grounded evidence line to a concrete, postable diff position. -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 }; -} - -// Stage 4: normalize raw priority/title/body, independent of evidence and claim-type decisions. -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', - }; - // Missing priority falls back to P3 rather than dropping a possible P0. - 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 }; -} - -// Stage 5: resolve the claim type, then enforce the denylist and pinned-SHA refutation. Counts update BEFORE the deny check, or a working denylist would tally identically to an idle one. -function applyClaimGate( - finding: RawFinding, - title: string, - body: string, - anchorContent: string, - deniedClaimTypes: Set, - claimTypeCounts: Record, - deniedClaimCounts: Record, -): { claimType: ClaimType } | { withheld: Withheld } { - // Coerce to 'other' rather than throw: a Zod rejection discards the whole file over one bad label. - 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}` } }; - } - - // A full commit SHA pin refutes a version claim outright. - 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' } }; - } - - // Claims whose consequence lives in a file, framework or engine version the model was never shown. - // Counted under its own key and tagged distinctly, so every suppression stays auditable in the - // off-diff list rather than vanishing -- a wrong refutation must be findable. - 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 }; -} - -// Stage 6: assemble the persisted comment. Absence-check stats are SHADOW: counted, never acted on. Promote to a drop only once `refuted` is non-zero on real claims and the gold set passes. -function buildParsedComment(params: { - file: FileDiff; - line: number; - position: number; - severity: typeof reviewSeverities[number]; - title: string; - body: string; - claimType: ClaimType; - anchorContent: string; - finding: RawFinding; - presenceIndex: ReturnType; - absenceCheckStats: { absenceShaped: number; identifierExtracted: number; refuted: number }; -}): ParsedReviewComment { - const { file, line, position, severity, title, body, claimType, anchorContent, finding, presenceIndex, absenceCheckStats } = params; - - const absence = checkAbsenceClaim({ title, body, anchorLine: line, index: presenceIndex }); - if (absence.status === 'refuted') { - absenceCheckStats.absenceShaped += 1; - absenceCheckStats.identifierExtracted += 1; - absenceCheckStats.refuted += 1; - } else if (absence.reason !== 'not_absence_shaped') { - absenceCheckStats.absenceShaped += 1; - if (absence.reason !== 'no_identifier' && absence.reason !== 'ambiguous_identifier') { - absenceCheckStats.identifierExtracted += 1; - } - } - - // Never `undefined`: the gate fires on typeof==='number', so an omission would sail past it. - const confidenceScore = typeof finding.confidence_score === 'number' - ? finding.confidence_score - : 0; - - // An empty or whitespace-only suggestion means "no suggestion", not "discard this finding" -- but - // `codeSuggestion` is `z.string().min(1)`, so passing `""` straight through threw a ZodError and the - // catch below binned the whole comment as `unverified:unassemblable`. Measured across an 800-review - // sweep: 256 findings destroyed this way, including real ones (a hardcoded-secret P1 among them). - // `evidence` on the next line has always had this guard; this field simply never got it. - const codeSuggestion = typeof finding.code_suggestion === 'string' && finding.code_suggestion.trim() - ? finding.code_suggestion - : undefined; - - return parsedReviewCommentSchema.parse({ - path: file.path, - line, - position, - severity, - // Derived, never model-emitted: asking produced 'quality' on all 705 rows. - category: CLAIM_TYPE_CATEGORY[claimType], - claimType, - // Unrecoverable later: 003 nulls diff_input and the KV diff cache expires after 6h. - 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, - // Title-independent identity, OR-matched with the first so a reworded repeat is still recognised. - fingerprintV2: buildFindingFingerprintV2( - file.path, - claimType, - anchorContent ? buildAnchorHash(anchorContent) : undefined, - ) ?? undefined, - }); -} - -// One file's worth of extracted output, so the batch path can hand-build it per file instead of going through the single-file `parseRawPayload`. -export type FileReviewPayload = z.infer; - -export type GroundingOptions = { - // Rejected outright. Enforced here, not in the grammar: only Workers AI and Google AI Studio honor the schema. - deniedClaimTypes?: readonly ClaimType[]; - // Batch path only. - ambiguity?: BinAmbiguity; -}; - -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 }; - claimTypeCounts: Record; - // Denied per type; these also appear in `claimTypeCounts`. - deniedClaimCounts: Record; - // Absence-check funnel, shadow-only; three counters keep refuted:0 distinct from "never fired". - absenceCheckStats: { absenceShaped: number; identifierExtracted: number; refuted: number }; -}; - -// Grounding is per file, never per response: the indexes come from one `FileDiff`. Split out of `parseFileReviewResponse` so batches can reuse it per file. -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 }; - const claimTypeCounts: Record = {}; - const deniedClaimCounts: Record = {}; - const deniedClaimTypes = new Set(options?.deniedClaimTypes ?? []); - const presenceIndex = buildPresenceIndex(file); - 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); - - // Anchor on content, not line number: an edit above shifts it, an edit TO the line must re-raise. - 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; - } - - // Contained per finding: under batching, propagating would discard the rest of the bin. - try { - return buildParsedComment({ - file, - line: anchored.line, - position: anchored.position, - severity, - title, - body, - claimType: gated.claimType, - anchorContent, - finding, - presenceIndex, - absenceCheckStats, - }); - } catch (error) { - // ZodError only: a wider catch would swallow systemic failures. - 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, - }; -} - -// Provider-independent by design: gating these on a Cloudflare-only flag once disabled the evidence gate and min_confidence on the Google chain. -export function parseFileReviewResponse( - raw: string, - file: FileDiff, - options?: GroundingOptions, -): GroundedFileReview { - return groundParsedFindings(parseRawPayload(raw), file, options); -} - - -export { dedupeFindings } from './dedupe'; -export { - isNonAnswerReview, - NON_ANSWER_MAX_RESPONSE_CHARS, - NON_ANSWER_MIN_DIFF_LINES, -} from './non-answer'; -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 '@codra/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 }, + 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}` } }; + } + + 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; + presenceIndex: ReturnType; + absenceCheckStats: { absenceShaped: number; identifierExtracted: number; refuted: number }; +}): ParsedReviewComment { + const { file, line, position, severity, title, body, claimType, anchorContent, finding, presenceIndex, absenceCheckStats } = params; + + const absence = checkAbsenceClaim({ title, body, anchorLine: line, index: presenceIndex }); + if (absence.status === 'refuted') { + absenceCheckStats.absenceShaped += 1; + absenceCheckStats.identifierExtracted += 1; + absenceCheckStats.refuted += 1; + } else if (absence.reason !== 'not_absence_shaped') { + absenceCheckStats.absenceShaped += 1; + if (absence.reason !== 'no_identifier' && absence.reason !== 'ambiguous_identifier') { + absenceCheckStats.identifierExtracted += 1; + } + } + + 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; +}; + +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 }; + 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 }; + const claimTypeCounts: Record = {}; + const deniedClaimCounts: Record = {}; + const deniedClaimTypes = new Set(options?.deniedClaimTypes ?? []); + const presenceIndex = buildPresenceIndex(file); + 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; + } + + try { + return buildParsedComment({ + file, + line: anchored.line, + position: anchored.position, + severity, + title, + body, + claimType: gated.claimType, + anchorContent, + finding, + presenceIndex, + absenceCheckStats, + }); + } 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, + NON_ANSWER_MAX_RESPONSE_CHARS, + NON_ANSWER_MIN_DIFF_LINES, +} from './non-answer'; +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 c3d730f3..a02b47c5 100644 --- a/packages/core/src/model-output/json-batch.ts +++ b/packages/core/src/model-output/json-batch.ts @@ -1,146 +1,137 @@ -// Batched-response payload extraction. Separate from the single-file parser, whose force-filled `findings: []` would approve an unexamined file here. -import { batchReviewModelOutputSchema, fileReviewModelOutputSchema } from '@codra/schema'; -import { jsonrepair } from 'jsonrepair'; -import { z } from 'zod'; -import { logger } from '../logger'; -import { - extractJson, - hasReviewKeys, - normalizeFinding, - parseRawPayload, - preprocessJson, - stripNulls, - truncateJsonForLog, -} from './json'; - -// Models routinely report the path under a key other than the one the schema asked for. -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; -} - -// Returns null for an untrustworthy entry, so it surfaces as `missing` -- re-queued, not clean. -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; - - // Absence of the key, not an empty array: a truncated response repairs into a missing `findings`. - 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), - }; -} - -// Three shapes seen in practice: a grammar-honouring array, a path-keyed object, and a bare array. -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 } - // Model ignored the nested schema -- common on weaker fallback models. - | { shape: 'flat'; data: z.infer }; - -// NOT routed through parseRawPayload -- see the header. -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 { - // See stripNulls: one `"code_suggestion": null` used to discard the whole bin's response. - 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) { - // No usable `files`, but a findings array is present: the flat shape, not a lost response. - 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 '@codra/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 b70dd9d9..9bcb0716 100644 --- a/packages/core/src/model-output/json.ts +++ b/packages/core/src/model-output/json.ts @@ -1,393 +1,375 @@ -import { fileReviewModelOutputSchema } from '@codra/schema'; -import { jsonrepair } from 'jsonrepair'; -import { z } from 'zod'; -import { logger } from '../logger'; - -const MAX_LOGGED_JSON_CHARS = 2_000; - -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); -} - -// Walks a balanced `open`/`close` pair from `startIdx`, ignoring delimiters inside strings. -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; -} - -// `anchorKey` is the root key stage 3 anchors on. A batched payload anchored on "findings" silently matches files[0] alone. -export function extractJson(raw: string, anchorKey: 'findings' | 'files' = 'findings') { - const jsonBlocks = Array.from(raw.matchAll(/```json\s*([\s\S]*?)```/gi)); - if (jsonBlocks.length > 0) { - return jsonBlocks[jsonBlocks.length - 1][1].trim(); - } - - const genericBlocks = Array.from(raw.matchAll(/```(?:[\w+-]+)?\s*([\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; - } - } - - // Array root: a bare `[{…}, {…}]`, which the brace scan below would reduce to element 0. - const arrayStart = raw.indexOf('['); - if (arrayStart !== -1 && raw.slice(0, arrayStart).trim() === '') { - const matched = scanBalanced(raw, arrayStart, '[', ']'); - if (matched && hasReviewKeys(matched)) return matched; - } - - // Fall back to "findings" so batch mode still recovers a flat response. - 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); - } - - // No keyword: score every brace block and take the best. - 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; - - // Anti-indicators: looks like source code, not our JSON. - 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; - } - } - - // Last resort: the very first brace, if it looks like JSON at all. - 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); - } - } - } - } - - // Truncated: append the missing braces so jsonrepair gets a structurally complete object. - const partial = raw.slice(firstBrace).trim(); - let closing = ''; - if (inString) closing += '"'; - closing += '}'.repeat(Math.max(1, stack)); - return `${partial}${closing}`; - } - - return raw.trim(); -} - -// Fixes common LLM defects before jsonrepair. Avoids backtracking regexes, for CPU cost. -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; - // A model echoing the schema template back (`""`) has produced no finding at all. - 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, - // Clipped before Zod, or one bad title throws for the whole bin. The surrogate strip avoids halving an emoji, which the DB write cannot encode. - title: (f.title ? String(f.title) : '').trim().slice(0, 100).replace(/[\uD800-\uDBFF]$/, '') || 'Code finding', - // Clamped before Zod for the same reason. - 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, - }; -} - -// Extracts, repairs and schema-validates the raw model response. Each catch logs a truncated excerpt: enough to diagnose what the model returned, without 10k+ char dumps. -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 { - // Nulls out before anything else looks at the payload: `.optional()` rejects an explicit null. - 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 => { - // Best: findings array and summary. - 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; - - // Last resort: any review-like keys. - 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; - - // Fill essential keys so schema validation doesn't reject an otherwise-usable response. - 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'; - - // Expected 0-1; models often answer on a 1-10 scale instead. - 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 '@codra/schema'; +import { jsonrepair } from 'jsonrepair'; +import { z } from 'zod'; +import { logger } from '../logger'; + +const MAX_LOGGED_JSON_CHARS = 2_000; + +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\S]*?)```/gi)); + if (jsonBlocks.length > 0) { + return jsonBlocks[jsonBlocks.length - 1][1].trim(); + } + + const genericBlocks = Array.from(raw.matchAll(/```(?:[\w+-]+)?\s*([\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 0a57223e..fca48f80 100644 --- a/packages/core/src/model-output/non-answer.ts +++ b/packages/core/src/model-output/non-answer.ts @@ -1,38 +1,22 @@ -// A model can decline to review without failing. It returns valid JSON, an empty `findings` array, -// `overall_correctness: "patch is correct"`, and a one-sentence explanation -- and the pipeline records -// that as "this file is clean", which is indistinguishable from a real clean verdict. -// -// Measured on a 221-file job reviewed by a `-flash-lite` primary: 165 files came back under 100 output -// tokens, and `src/server/core/review/index.ts` answered a 751-line diff (15,022 input tokens) with 71 -// output tokens. Exactly one file in the job produced a response over 250 tokens, and it was the only -// file that produced a finding. The pipeline was working; the model was not reviewing. -// -// This detects that shape so the chain can escalate, rather than posting a clean review nobody earned. - -import type { FileDiff } from '../diff'; - -// Below this a zero-finding response has not said enough to be a considered judgement about a large -// diff. The real observations clustered at 305-476 chars for eight substantive files; 600 leaves room -// for a genuinely thorough "clean" explanation without admitting a one-liner. -export const NON_ANSWER_MAX_RESPONSE_CHARS = 600; - -// Only diffs at least this big. A short diff CAN be honestly dismissed in a sentence -- 162 files in that -// same job were comment-only cleanups whose empty findings arrays were correct -- so applying this to -// small files would manufacture failures out of accurate verdicts. -export const NON_ANSWER_MIN_DIFF_LINES = 200; - -/** - * 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'; + +export const NON_ANSWER_MAX_RESPONSE_CHARS = 600; + +export const NON_ANSWER_MIN_DIFF_LINES = 200; + +/** + * 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/ports/file-reviews.ts b/packages/core/src/ports/file-reviews.ts index 4e51bb0b..6fc414ff 100644 --- a/packages/core/src/ports/file-reviews.ts +++ b/packages/core/src/ports/file-reviews.ts @@ -1,148 +1,112 @@ -import type { ParsedReviewComment } from '@codra/schema'; - -// Per-file review persistence. Mirrors src/server/db/file-reviews{,-bulk,-findings}.ts minus `env`. - -/** - * A file_reviews row as returned by `getFileReviewsForJobs`, with the two JSON columns already - * decoded. Defined here rather than imported because it is a raw-column shape with no schema - * counterpart, and the review phase copies nearly every field through `upsertFileReview` when it - * inherits a parent job's reviews. - */ -export type FileReviewRow = { - 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[]; - 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 }; - // NULL pre-batching; 1 reviewed alone, N for a packed bin. - batch_size: number | null; -}; - -export type SuppressedFinding = { - fingerprint: string | null; - // Null for repo-wide rejections, which suppress regardless of what the code now says. - anchor_hash: string | null; - // Title-independent identity; already includes the anchor, so it needs no separate anchor check. - fingerprint_v2: string | null; - // True when this came from an earlier posted comment rather than from human rejection. - anchored: boolean; -}; - -export type BulkFileReviewInput = { - filePath: string; - fileStatus: 'pending' | 'done' | 'skipped' | 'failed'; - modelUsed: string; - modelProvider?: string | null; - diffLineCount: number; - 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; - withheldCounts?: { evidence: number; claimDenied: number } | null; - // 1 for a file reviewed alone, N for a file that shared a model call with N-1 others. - batchSize: number; -}; - -/** - * Per-file review rows, the findings attached to them, and their posted/rejected bookkeeping. - * - * A correct implementation must guarantee: - * - every write is IDEMPOTENT on (jobId, filePath). A phase that dies after reviewing a file - * re-reviews it on the next invocation, so a second upsert for the same path must replace the row - * rather than adding one. This is the property that makes the whole phase re-runnable. - * - `recordRetryableFileReviewFailure` and `bulkRecordRetryableFileReviewFailures` return the - * transient failure count AFTER this attempt, and must only increment it when - * `countsAsAttempt` is not false. That flag distinguishes "the provider is down again" from "we - * advanced one step down the model chain"; conflating them burns the retry budget on progress. - * The count must never reset on its own -- MAX_RETRYABLE_FILE_REVIEW_FAILURES depends on it. - * - `getFileReviewsForJobs` returns rows in stable creation order across calls, for every jobId - * given, with `parsed_comments` and `withheld_counts` already decoded (never raw JSON strings). - * Finalize reads it to assemble the review, so an unstable order reorders posted comments. - * - `bulkInheritFileReviews` returns only the paths it actually inserted, skipping any that already - * exist. The caller treats the returned list as "these are now done" and re-reviews the rest. - * - `markCommentsPosted` and `markCommentDispositions` are additive and idempotent: re-marking an - * already-marked fingerprint is a no-op, never an error. Cross-run suppression reads these, so a - * lost write re-posts a finding a human already dismissed. - * - an empty input array is a no-op that must not touch the database or throw. - */ -export interface FileReviewStore { - upsertFileReview(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 } | null; - // Async batch bookkeeping: set on submit to the Workers AI queue, cleared once the batch completes. - asyncRequestId?: string | null; - asyncModel?: string | null; - }): Promise; - - recordRetryableFileReviewFailure(jobId: string, input: { - filePath: string; - modelUsed: string; - modelProvider?: string | null; - diffLineCount: number; - diffInput: string | null; - durationMs: number | null; - errorMessage: string; - countsAsAttempt?: boolean; - }): Promise; - - getFileReviewsForJobs(jobIds: string[]): Promise; - - bulkInheritFileReviews(input: { jobId: string; parentJobId: string; filePaths: string[] }): Promise; - bulkUpsertFileReviews(jobId: string, inputs: BulkFileReviewInput[]): Promise; - bulkRecordRetryableFileReviewFailures( - jobId: string, - inputs: Array<{ filePath: string; modelUsed: string; diffLineCount: number; errorMessage: string }>, - opts?: { countsAsAttempt?: boolean }, - ): Promise>; - bulkMarkFilesFailed( - jobId: string, - files: Array<{ filePath: string; diffLineCount: number }>, - opts: { modelUsed: string; errorMessage: string }, - ): Promise; - - getSuppressedFindings(jobId: string): Promise; - markCommentsPosted(jobId: string, fingerprints: string[]): Promise; - markCommentDispositions( - jobId: string, - byFingerprint: Map, - ): Promise; -} +import type { ParsedReviewComment } from '@codra/schema'; + + +export type FileReviewRow = { + 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[]; + 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 }; + batch_size: number | null; +}; + +export type SuppressedFinding = { + fingerprint: string | null; + anchor_hash: string | null; + fingerprint_v2: string | null; + anchored: boolean; +}; + +export type BulkFileReviewInput = { + filePath: string; + fileStatus: 'pending' | 'done' | 'skipped' | 'failed'; + modelUsed: string; + modelProvider?: string | null; + diffLineCount: number; + 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; + withheldCounts?: { evidence: number; claimDenied: number } | null; + batchSize: number; +}; + +export interface FileReviewStore { + upsertFileReview(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; + withheldCounts?: { evidence: number; claimDenied: number } | null; + asyncRequestId?: string | null; + asyncModel?: string | null; + }): Promise; + + recordRetryableFileReviewFailure(jobId: string, input: { + filePath: string; + modelUsed: string; + modelProvider?: string | null; + diffLineCount: number; + diffInput: string | null; + durationMs: number | null; + errorMessage: string; + countsAsAttempt?: boolean; + }): Promise; + + getFileReviewsForJobs(jobIds: string[]): Promise; + + bulkInheritFileReviews(input: { jobId: string; parentJobId: string; filePaths: string[] }): Promise; + bulkUpsertFileReviews(jobId: string, inputs: BulkFileReviewInput[]): Promise; + bulkRecordRetryableFileReviewFailures( + jobId: string, + inputs: Array<{ filePath: string; modelUsed: string; diffLineCount: number; errorMessage: string }>, + opts?: { countsAsAttempt?: boolean }, + ): Promise>; + bulkMarkFilesFailed( + jobId: string, + files: Array<{ filePath: string; diffLineCount: number }>, + opts: { modelUsed: string; errorMessage: string }, + ): Promise; + + getSuppressedFindings(jobId: string): Promise; + markCommentsPosted(jobId: string, fingerprints: string[]): Promise; + markCommentDispositions( + jobId: string, + byFingerprint: Map, + ): Promise; +} diff --git a/packages/core/src/ports/formatter.ts b/packages/core/src/ports/formatter.ts index 8c336696..284dca0f 100644 --- a/packages/core/src/ports/formatter.ts +++ b/packages/core/src/ports/formatter.ts @@ -1,18 +1,5 @@ -import type { ParsedReviewComment } from '@codra/schema'; +import type { ParsedReviewComment } from '@codra/schema'; -/** - * Renders findings into the markdown the provider will show. - * - * Four of `FormatterService`'s methods, which are the four finalize calls. The implementation is - * already pure apart from a base URL, and it stays outside the engine because the URL is deployment - * configuration. - * - * A correct implementation must be PURE and DETERMINISTIC: same finding in, same string out, no I/O, - * no clock, no randomness. Finalize is re-runnable, and a formatter whose output varied between - * invocations would make a retried finalize post text that no longer matches what was recorded -- - * and, because posted findings are tracked by fingerprint rather than by body, would do so silently. - * `summarizeVerdict` must treat `hasFailures` as decisive: a job with failed files cannot approve. - */ export interface ReviewFormatter { toReviewEvent(verdict: 'approve' | 'comment'): 'APPROVE' | 'COMMENT'; summarizeVerdict(comments: ParsedReviewComment[], hasFailures: boolean): { verdict: 'approve' | 'comment'; errors: number; warnings: number }; diff --git a/packages/core/src/ports/github.ts b/packages/core/src/ports/github.ts index 5f8d86d2..2c7f90cf 100644 --- a/packages/core/src/ports/github.ts +++ b/packages/core/src/ports/github.ts @@ -1,83 +1,45 @@ -// The git-provider port. Named for what the engine needs, not for GitHub's API: the ten methods here -// are the entire surface the review engine touches, out of a much larger service. A second provider -// implements these ten and nothing else. -// -// The two record types are owned here and re-exported by src/server/core/github/types.ts, so there is -// exactly one definition of each. - -export type PullRequestRecord = { - number: number; - title: string | null; - body: string | null; - draft: boolean; - head: { sha: string; ref: string }; - base: { sha: string; ref: string }; - user: { login: string }; -}; - -export type GitHubReviewComment = { - path: string; - // File line to attach the comment to, paired with `side`. The model reports file lines, never diff offsets. - line?: number; - // 'RIGHT' = the head (post-change) file, which is where findings live. - side?: 'LEFT' | 'RIGHT'; - // Legacy diff-offset addressing. Kept for callers that already compute it. - position?: number; - body: string; -}; - -/** - * Reads a pull request's contents and writes the review back. - * - * Retry-safety is NOT uniform here, and callers depend on knowing which is which: - * - `getPullRequest`, `getPullRequestDiff`, `getCompareDiff` are pure reads and freely retryable. - * `getCompareDiff` must resolve the diff for the two commits GIVEN, not the current head, because - * its caller reconstructs a finished job's diff after the pull request has moved on. - * - `createCheckRun` is not idempotent; the caller stores the returned id and passes it to - * `updateCheckRun` thereafter. `updateCheckRun` IS idempotent and may be called repeatedly, - * including to re-complete an already-completed run. - * - `createReview` is NOT retry-safe: it posts. A caller that may have already posted must first ask - * `findBotReviewForCommit` and reuse what it finds. It must return `postedIndices` naming which of - * the submitted comments were actually accepted -- when the provider rejects inline comments and - * the review falls back to a body-only post, that list is empty, and reporting all of them as - * posted would suppress those findings forever. - * - `findBotReviewForCommit` must scope to the given commit sha AND bot login, and return null - * rather than throwing when there is none. - * - `ensureLabel`, `addIssueLabels`, `removeIssueLabelsIfPresent` are idempotent. Removing a label - * that is absent must succeed, not 404. - * Every method may throw; the engine classifies transient failures and reschedules. - */ -export interface ReviewGitHub { - getPullRequest(owner: string, repo: string, prNumber: number): Promise; - getPullRequestDiff(owner: string, repo: string, prNumber: number): Promise; - getCompareDiff(owner: string, repo: string, base: string, head: string): Promise; - createCheckRun(owner: string, repo: string, params: { headSha: string; title: string; summary: string }): Promise<{ id: number }>; - updateCheckRun(owner: string, repo: string, checkRunId: number, params: { - title: string; - summary: string; - status?: 'in_progress' | 'completed'; - conclusion?: 'success' | 'neutral' | 'failure' | 'cancelled'; - }): Promise; - createReview(owner: string, repo: string, prNumber: number, params: { - commitSha: string; - event: 'APPROVE' | 'COMMENT'; - body: string; - comments: GitHubReviewComment[]; - }): Promise<{ id: number; postedIndices?: number[] }>; - findBotReviewForCommit(owner: string, repo: string, prNumber: number, commitSha: string, botLogin: string): Promise<{ id: number } | null>; - ensureLabel(owner: string, repo: string, name: string, color: string): Promise; - addIssueLabels(owner: string, repo: string, prNumber: number, labels: string[]): Promise; - removeIssueLabelsIfPresent(owner: string, repo: string, prNumber: number, labels: string[]): Promise; -} - -/** - * Builds a provider client for an installation whose job row does not exist yet. - * - * Needed because webhook resolution -- label cleanup on a closed pull request, and looking up the - * pull request behind an issue comment -- happens before any job is inserted, so there is no job to - * take the installation id from. A correct implementation must be cheap enough to call per webhook - * and must not perform I/O until one of the returned methods is called. - */ -export interface GitHubClientFactory { - forInstallation(installationId: string): ReviewGitHub; -} + +export type PullRequestRecord = { + number: number; + title: string | null; + body: string | null; + draft: boolean; + head: { sha: string; ref: string }; + base: { sha: string; ref: string }; + user: { login: string }; +}; + +export type GitHubReviewComment = { + path: string; + line?: number; + side?: 'LEFT' | 'RIGHT'; + position?: number; + body: string; +}; + +export interface ReviewGitHub { + getPullRequest(owner: string, repo: string, prNumber: number): Promise; + getPullRequestDiff(owner: string, repo: string, prNumber: number): Promise; + getCompareDiff(owner: string, repo: string, base: string, head: string): Promise; + createCheckRun(owner: string, repo: string, params: { headSha: string; title: string; summary: string }): Promise<{ id: number }>; + updateCheckRun(owner: string, repo: string, checkRunId: number, params: { + title: string; + summary: string; + status?: 'in_progress' | 'completed'; + conclusion?: 'success' | 'neutral' | 'failure' | 'cancelled'; + }): Promise; + createReview(owner: string, repo: string, prNumber: number, params: { + commitSha: string; + event: 'APPROVE' | 'COMMENT'; + body: string; + comments: GitHubReviewComment[]; + }): Promise<{ id: number; postedIndices?: number[] }>; + findBotReviewForCommit(owner: string, repo: string, prNumber: number, commitSha: string, botLogin: string): Promise<{ id: number } | null>; + ensureLabel(owner: string, repo: string, name: string, color: string): Promise; + addIssueLabels(owner: string, repo: string, prNumber: number, labels: string[]): Promise; + removeIssueLabelsIfPresent(owner: string, repo: string, prNumber: number, labels: string[]): Promise; +} + +export interface GitHubClientFactory { + forInstallation(installationId: string): ReviewGitHub; +} diff --git a/packages/core/src/ports/index.ts b/packages/core/src/ports/index.ts index c2f3cb3e..8b8687aa 100644 --- a/packages/core/src/ports/index.ts +++ b/packages/core/src/ports/index.ts @@ -1,16 +1,10 @@ -// The engine's ports: interfaces and data contracts only, never implementations. Every port carries a -// doc comment stating what a correct implementation must guarantee -- idempotency, ordering, -// retry-safety -- because those are the properties the engine relies on and cannot check. -// -// The dependency rule is one-way: this package may import @codra/schema and nothing else. Ports are -// implemented by hosts (src/server/adapters today, packages/{db,models,provider-github} later). - -export type { Clock, IdGenerator, KvStore, Logger } from './platform'; -export type { JobLeaseClaim, JobRow, JobStore, PersistedReviewJob } from './jobs'; -export type { BulkFileReviewInput, FileReviewRow, FileReviewStore, SuppressedFinding } from './file-reviews'; -export type { LearningStore, ModelConfigReader, RepoConfigLoader, ReviewSettingsReader, WebhookDeliveryReader } from './settings'; -export type { GitHubClientFactory, GitHubReviewComment, PullRequestRecord, ReviewGitHub } from './github'; -export type { FileReviewOutcome, ModelErrorClassifier, ModelResponse, ModelResponseSchema, ReviewModel } from './model'; -export type { ReviewFormatter } from './formatter'; -export type { ReviewTelemetryEvent, TelemetrySink } from './telemetry'; -export type { ReviewRuntime } from './runtime'; + +export type { Clock, IdGenerator, KvStore, Logger } from './platform'; +export type { JobLeaseClaim, JobRow, JobStore, PersistedReviewJob } from './jobs'; +export type { BulkFileReviewInput, FileReviewRow, FileReviewStore, SuppressedFinding } from './file-reviews'; +export type { LearningStore, ModelConfigReader, RepoConfigLoader, ReviewSettingsReader, WebhookDeliveryReader } from './settings'; +export type { GitHubClientFactory, GitHubReviewComment, PullRequestRecord, ReviewGitHub } from './github'; +export type { FileReviewOutcome, ModelErrorClassifier, ModelResponse, ModelResponseSchema, ReviewModel } from './model'; +export type { ReviewFormatter } from './formatter'; +export type { ReviewTelemetryEvent, TelemetrySink } from './telemetry'; +export type { ReviewRuntime } from './runtime'; diff --git a/packages/core/src/ports/jobs.ts b/packages/core/src/ports/jobs.ts index 39bf6dec..817c0d5f 100644 --- a/packages/core/src/ports/jobs.ts +++ b/packages/core/src/ports/jobs.ts @@ -1,130 +1,83 @@ -import type { JobSummary, RepoConfig } from '@codra/schema'; - -// Job persistence. Method signatures mirror src/server/db/{jobs,jobs-leases,jobs-lifecycle}.ts -// exactly, minus the leading `env` parameter, which the adapter closes over. - -/** - * A job as the engine sees it. - * - * This is `JobSummary` rather than a hand-copied shape, and that is not a convenience: `mapJob` ends - * in `jobSummarySchema.parse(...)`, and `.parse` strips unknown keys, so `ReturnType` - * IS this type. A field added to the mapper cannot widen it, and a field added to the schema widens - * both sides together -- there is no channel for the two to drift. src/server/adapters/jobs-store.ts - * carries a compile-time assertion pinning the equality. - */ -export type PersistedReviewJob = JobSummary; - -/** - * The raw jobs row, before `mapJob` decodes it. - * - * The engine reads exactly two columns off it -- `status`, to detect a job superseded mid-flight, and - * `check_run_id`, to reconcile a check run on the failure path -- and otherwise only hands the row - * straight back to `mapJob`. The index signature is what lets the db layer's own row type satisfy - * this without core knowing the other forty columns exist. - */ -export type JobRow = { - status: 'queued' | 'running' | 'done' | 'failed' | 'superseded' | 'cancelled' | 'stopped'; - check_run_id: number | null; - [column: string]: unknown; -}; - -export type JobLeaseClaim = - | { status: 'claimed'; row: JobRow } - | { status: 'busy'; row: JobRow; retryAfterSeconds: number } - | { status: 'terminal'; row: JobRow } - | { status: 'missing' }; - -/** - * Job rows and the lease that makes a phase safe to re-run. - * - * A correct implementation must guarantee: - * - `claimJobLease` is ATOMIC. Two concurrent callers for the same jobId must not both receive - * 'claimed'; the loser gets 'busy'. Everything else here assumes the caller holds the lease, and - * a lease two workers can hold simultaneously means two workers reviewing and posting the same - * pull request. Claiming must also flip a 'queued' job to 'running' in the same operation. - * - `releaseJobLease` and `heartbeatJobLease` are no-ops when `leaseOwner` does not match the - * current holder. A phase that lost its lease to expiry-recovery must not be able to release the - * successor's claim. - * - `markJobContinuationQueued` returns the count AFTER incrementing, and never decreases for a - * given job except via `resetJobContinuationCount`. The two continuation ceilings are the only - * thing standing between a wedged job and an infinite reschedule loop, so an implementation that - * lost increments would loop forever. - * - every write is idempotent under retry. Each of these may be called twice for one logical step, - * because a phase that dies after the write is re-run from the top. - * - `insertJob` and `findExistingJobForHead` agree on identity: what insert stores under - * (owner, repo, prNumber, commitSha, trigger) is what find must return. - * - `mapJob` is pure and total for any row this store returned. - * Ordering between calls is the caller's business; no method may reorder or batch across calls. - */ -export interface JobStore { - mapJob(row: JobRow): PersistedReviewJob; - - getJobForProcessing(jobId: string): Promise; - claimJobLease(jobId: string, leaseOwner: string, leaseSeconds: number): Promise; - heartbeatJobLease(jobId: string, leaseOwner: string, leaseSeconds: number): Promise; - releaseJobLease(jobId: string, leaseOwner: string): Promise; - markJobContinuationQueued(jobId: string, delaySeconds?: number): Promise; - resetJobContinuationCount(jobId: string): Promise; - getOtherRunningJobsCount(excludeJobId: string): Promise; - - setJobWorkflowInstance(jobId: string, workflowInstanceId: string): Promise; - setJobPullRequestMeta(jobId: string, meta: { prTitle: string | null; prAuthor: string | null }): Promise; - insertJob(input: { - installationId: string; - owner: string; - repo: string; - prNumber: number; - prTitle: string | null; - prAuthor: string | null; - commitSha: string; - baseSha: string; - trigger: 'auto' | 'mention' | 'retry'; - headRef: string | null; - baseRef: string | null; - configSnapshot?: RepoConfig | null; - retryOfJobId?: string | null; - }): Promise; - findExistingJobForHead(input: { - owner: string; - repo: string; - prNumber: number; - commitSha: string; - trigger: 'auto' | 'mention'; - }): Promise; - - updateJobCheckRun(jobId: string, checkRunId: number): Promise; - markJobCheckRunCompleted(jobId: string): Promise; - completePreparationStep(jobId: string, fileCount: number): Promise; - updateJobStep(jobId: string, stepName: string, update: { - status: 'pending' | 'running' | 'done' | 'failed'; - startedAt?: string | null; - finishedAt?: string | null; - error?: string | null; - }): Promise; - completeJob(jobId: string, input: { - verdict: 'approve' | 'comment'; - fileCount: number; - commentCount: number; - totalInputTokens: number; - totalOutputTokens: number; - summaryMarkdown: string; - reviewId: number | null; - summaryModel: string | null; - overallConfidenceScore?: number | null; - errorMessage?: string | null; - }): Promise; - /** - * Marks the job terminal. This is a MUST-NOT-LOSE write: it is what stops the queue redelivering - * the job forever, and what makes it eligible for check-run reconciliation afterwards. An - * implementation that can fail must fail loudly rather than silently no-op. - */ - failJob(jobId: string, errorMessage: string): Promise; - /** Returns how many older jobs were superseded. Must not supersede `newJobId` itself. */ - supersedeOlderJobs(input: { - installationId: string; - owner: string; - repo: string; - prNumber: number; - newJobId: string; - }): Promise; -} +import type { JobSummary, RepoConfig } from '@codra/schema'; + + +export type PersistedReviewJob = JobSummary; + +export type JobRow = { + status: 'queued' | 'running' | 'done' | 'failed' | 'superseded' | 'cancelled' | 'stopped'; + check_run_id: number | null; + [column: string]: unknown; +}; + +export type JobLeaseClaim = + | { status: 'claimed'; row: JobRow } + | { status: 'busy'; row: JobRow; retryAfterSeconds: number } + | { status: 'terminal'; row: JobRow } + | { status: 'missing' }; + +export interface JobStore { + mapJob(row: JobRow): PersistedReviewJob; + + getJobForProcessing(jobId: string): Promise; + claimJobLease(jobId: string, leaseOwner: string, leaseSeconds: number): Promise; + heartbeatJobLease(jobId: string, leaseOwner: string, leaseSeconds: number): Promise; + releaseJobLease(jobId: string, leaseOwner: string): Promise; + markJobContinuationQueued(jobId: string, delaySeconds?: number): Promise; + resetJobContinuationCount(jobId: string): Promise; + getOtherRunningJobsCount(excludeJobId: string): Promise; + + setJobWorkflowInstance(jobId: string, workflowInstanceId: string): Promise; + setJobPullRequestMeta(jobId: string, meta: { prTitle: string | null; prAuthor: string | null }): Promise; + insertJob(input: { + installationId: string; + owner: string; + repo: string; + prNumber: number; + prTitle: string | null; + prAuthor: string | null; + commitSha: string; + baseSha: string; + trigger: 'auto' | 'mention' | 'retry'; + headRef: string | null; + baseRef: string | null; + configSnapshot?: RepoConfig | null; + retryOfJobId?: string | null; + }): Promise; + findExistingJobForHead(input: { + owner: string; + repo: string; + prNumber: number; + commitSha: string; + trigger: 'auto' | 'mention'; + }): Promise; + + updateJobCheckRun(jobId: string, checkRunId: number): Promise; + markJobCheckRunCompleted(jobId: string): Promise; + completePreparationStep(jobId: string, fileCount: number): Promise; + updateJobStep(jobId: string, stepName: string, update: { + status: 'pending' | 'running' | 'done' | 'failed'; + startedAt?: string | null; + finishedAt?: string | null; + error?: string | null; + }): Promise; + completeJob(jobId: string, input: { + verdict: 'approve' | 'comment'; + fileCount: number; + commentCount: number; + totalInputTokens: number; + totalOutputTokens: number; + summaryMarkdown: string; + reviewId: number | null; + summaryModel: string | null; + overallConfidenceScore?: number | null; + errorMessage?: string | null; + }): Promise; + failJob(jobId: string, errorMessage: string): Promise; + supersedeOlderJobs(input: { + installationId: string; + owner: string; + repo: string; + prNumber: number; + newJobId: string; + }): Promise; +} diff --git a/packages/core/src/ports/model.ts b/packages/core/src/ports/model.ts index ad53e2b0..1672aaf3 100644 --- a/packages/core/src/ports/model.ts +++ b/packages/core/src/ports/model.ts @@ -1,120 +1,71 @@ -// The model port. Implementations live in src/server/services/model.ts (and, later, -// packages/models) -- nothing here may reach for a provider SDK or an API key. -import type { RepoConfig } from '@codra/schema'; -import type { FileDiff } from '../diff'; -import type { BatchReviewResult, parseFileReviewResponse } from '../model-output'; -import type { RejectedExemplar } from '../prompts/file-review'; -import type { VerifyCandidate } from '../prompts/verify'; - -type ParsedFileReview = ReturnType; - -/** - * One model call's result. `degraded: 'schema-dropped'` means the provider rejected the structured - * output grammar and the call ran unconstrained but succeeded, so the caller must be prepared to - * parse free-form text. - */ -export type ModelResponse = { - rawText: string; - inputTokens: number; - outputTokens: number; - modelUsed: string; - provider: string; - // Grammar rejected, so the call ran unconstrained but succeeded. Read by services/model.ts and `/models/:id/test`. - degraded?: 'schema-dropped'; -}; - -// Honored only by Workers AI and Google AI Studio -- not by `vertex`, despite it serving the same Gemini models. -export type ModelResponseSchema = { - name: string; - schema: Record; -}; - -/** One file's review, as the runner receives it: the raw call plus the grounded parse. */ -export type FileReviewOutcome = ModelResponse & { - parsed: ParsedFileReview; - reviewedLineCount: number; - wasPromptTruncated: boolean; - userPrompt: string; -}; - -/** - * Runs review prompts against whatever model chain the host has configured. - * - * The engine deliberately knows nothing about model selection, fallback order, rate limits or - * provider quirks -- all of that is the implementation's business. What it does depend on: - * - every method may throw, and the implementation must make transient failures DISTINGUISHABLE - * from permanent ones via `ModelErrorClassifier` below. Misclassifying a permanent failure as - * transient wedges the job until its continuation ceiling; the reverse fails a job that would - * have succeeded on retry. - * - `reviewFile` and `reviewFiles` must be safe to call again after a failure. They are pure - * request/response as far as the engine is concerned: no state carries between calls except - * whatever chain-resume bookkeeping the implementation keeps. - * - `reviewFiles` returns a result whose `batch.missing` names files the model did not answer for. - * Those must NOT be reported as reviewed; the caller re-runs them individually. - * - `submitReviewBatch` returns null when async batching is unusable for this model, and the caller - * falls back to `reviewFile`. Returning a requestId commits to `pollReviewBatch` being able to - * resolve it in a LATER Worker invocation -- the id is persisted, so it must not be tied to - * in-memory state. - * - `pollReviewBatch` must be safe to call repeatedly for the same requestId, returning 'pending' - * until the batch resolves. It must never block. - * - token counts on the response must reflect what the call actually consumed; the budget and the - * per-job totals are computed from them. - */ -export interface ReviewModel { - reviewFile(params: { - file: FileDiff; - prTitle: string | null; - prDescription: string | null; - config: RepoConfig; - totalLineCount: number; - compactPrompt?: boolean; - rejectedExemplars?: readonly RejectedExemplar[]; - }): Promise; - - reviewFiles(params: { - files: readonly FileDiff[]; - prTitle: string | null; - prDescription: string | null; - config: RepoConfig; - totalLineCount: number; - rejectedExemplars?: readonly RejectedExemplar[]; - }): Promise; - - submitReviewBatch(params: { - file: FileDiff; - prTitle: string | null; - prDescription: string | null; - config: RepoConfig; - totalLineCount: number; - compactPrompt?: boolean; - }): Promise<{ requestId: string; model: string } | null>; - - pollReviewBatch(params: { model: string; requestId: string; file: FileDiff; config: RepoConfig }): Promise< - | { status: 'pending' } - | { status: 'done'; response: FileReviewOutcome } - | { status: 'failed'; error: unknown } - >; - - verifyFindings(params: { candidates: VerifyCandidate[]; config: RepoConfig }): Promise; -} - -/** - * Classifies a thrown model/provider error. - * - * Kept as a port rather than moved into the engine even though both functions are pure predicates: - * five specs substitute them by mocking the '@server/services/model' specifier, and pulling them in - * here would void those mocks silently while the tests kept passing. - * - * A correct implementation must be: - * - total: any value may be passed, including non-Errors, and neither method may throw. - * - deterministic for a given error. The retry-delay ladder and the chain-advance memo are both - * derived from these answers across separate invocations, so an answer that changed between calls - * would produce an inconsistent retry plan. - * - conservative about `isRetryableModelError`: only return true when a later attempt has a real - * chance of succeeding. `nextChainIndexOf` returns the index to resume the fallback chain at, or - * null when the failure says nothing about chain position. - */ -export interface ModelErrorClassifier { - isRetryableModelError(error: unknown): boolean; - nextChainIndexOf(error: unknown): number | null; -} +import type { RepoConfig } from '@codra/schema'; +import type { FileDiff } from '../diff'; +import type { BatchReviewResult, parseFileReviewResponse } from '../model-output'; +import type { RejectedExemplar } from '../prompts/file-review'; +import type { VerifyCandidate } from '../prompts/verify'; + +type ParsedFileReview = ReturnType; + +export type ModelResponse = { + rawText: string; + inputTokens: number; + outputTokens: number; + modelUsed: string; + provider: string; + degraded?: 'schema-dropped'; +}; + +export type ModelResponseSchema = { + name: string; + schema: Record; +}; + +export type FileReviewOutcome = ModelResponse & { + parsed: ParsedFileReview; + reviewedLineCount: number; + wasPromptTruncated: boolean; + userPrompt: string; +}; + +export interface ReviewModel { + reviewFile(params: { + file: FileDiff; + prTitle: string | null; + prDescription: string | null; + config: RepoConfig; + totalLineCount: number; + compactPrompt?: boolean; + rejectedExemplars?: readonly RejectedExemplar[]; + }): Promise; + + reviewFiles(params: { + files: readonly FileDiff[]; + prTitle: string | null; + prDescription: string | null; + config: RepoConfig; + totalLineCount: number; + rejectedExemplars?: readonly RejectedExemplar[]; + }): Promise; + + submitReviewBatch(params: { + file: FileDiff; + prTitle: string | null; + prDescription: string | null; + config: RepoConfig; + totalLineCount: number; + compactPrompt?: boolean; + }): Promise<{ requestId: string; model: string } | null>; + + pollReviewBatch(params: { model: string; requestId: string; file: FileDiff; config: RepoConfig }): Promise< + | { status: 'pending' } + | { status: 'done'; response: FileReviewOutcome } + | { status: 'failed'; error: unknown } + >; + + verifyFindings(params: { candidates: VerifyCandidate[]; config: RepoConfig }): Promise; +} + +export interface ModelErrorClassifier { + isRetryableModelError(error: unknown): boolean; + nextChainIndexOf(error: unknown): number | null; +} diff --git a/packages/core/src/ports/platform.ts b/packages/core/src/ports/platform.ts index de563efd..21faea9e 100644 --- a/packages/core/src/ports/platform.ts +++ b/packages/core/src/ports/platform.ts @@ -1,48 +1,15 @@ -// Platform primitives the engine refuses to reach for as globals, so a caller can make a review -// deterministic (fixed clock, fixed ids) or run it with no key-value store at all. - -/** - * A best-effort string cache, satisfied structurally by Cloudflare's KVNamespace. - * - * A correct implementation must: - * - treat every entry as expendable. `get` returning null is always legal, for any key, at any - * time, including immediately after a successful `put` -- the engine re-derives the value. - * - never throw from `get`. A read failure must surface as null, not an exception, because the - * only caller (diff-cache) treats a miss as normal and a throw as a job failure. - * - honour `expirationTtl` in seconds if it can, and ignore it if it cannot. `put` MAY throw; the - * engine catches and continues, so a full or read-only store degrades to re-fetching. - * Reads need not be strongly consistent, and writes need not be visible to a concurrent reader. - */ -export interface KvStore { - get(key: string): Promise; - put(key: string, value: string, options?: { expirationTtl?: number }): Promise; -} - -/** - * Wall-clock time in epoch milliseconds, satisfied by `{ now: () => Date.now() }`. - * - * A correct implementation must be non-decreasing within one phase: the file runner and the phase - * loop both compute elapsed time by subtracting two `now()` readings, and a clock that went - * backwards would produce a negative duration and, worse, hide a breach of the 12-minute - * REVIEW_CHUNK_WALL_CLOCK_MS budget that exists to keep the phase inside its invocation limit. - * It need not be monotonic ACROSS phases -- each phase re-reads it from scratch. - */ -export interface Clock { - now(): number; -} - -/** - * Opaque unique identifiers, satisfied structurally by `globalThis.crypto`. - * - * A correct implementation must never return the same value twice for the lifetime of the - * deployment. The engine's one caller mints a job lease owner with it, and two workers agreeing on - * a lease owner string would let both believe they hold the same job's lease -- the one failure this - * whole locking scheme exists to prevent. Values need not be UUID-shaped, sortable, or unguessable. - */ -export interface IdGenerator { - randomUUID(): string; -} - -// Re-exported so `@codra/core/ports` is the single place a host looks for the contracts it must -// implement, even though the interface itself has to live next to the scrubbing it constrains. -export type { Logger } from '../logger'; + +export interface KvStore { + get(key: string): Promise; + put(key: string, value: string, options?: { expirationTtl?: number }): Promise; +} + +export interface Clock { + now(): number; +} + +export interface IdGenerator { + randomUUID(): string; +} + +export type { Logger } from '../logger'; diff --git a/packages/core/src/ports/runtime.ts b/packages/core/src/ports/runtime.ts index 1061ca8e..9affe1fa 100644 --- a/packages/core/src/ports/runtime.ts +++ b/packages/core/src/ports/runtime.ts @@ -1,4 +1,4 @@ -import type { TokenTracker } from '../token-tracker'; +import type { TokenTracker } from '../token-tracker'; import type { Clock, IdGenerator, KvStore } from './platform'; import type { FileReviewStore } from './file-reviews'; import type { GitHubClientFactory, ReviewGitHub } from './github'; @@ -8,27 +8,12 @@ import type { ReviewFormatter } from './formatter'; import type { LearningStore, ModelConfigReader, RepoConfigLoader, ReviewSettingsReader, WebhookDeliveryReader } from './settings'; import type { TelemetrySink } from './telemetry'; -/** - * Everything the review engine needs from the outside world. This single object is what replaced - * `env: AppBindings`, and assembling one is the whole job of a host: see - * src/server/adapters/index.ts for the Cloudflare/Postgres/GitHub implementation, and - * packages/core/test/in-memory.ts for a complete in-memory one. - * - * A correct runtime must be CHEAP TO CONSTRUCT and hold no per-job state: one is built per Worker - * invocation, before the job is known, and a phase that dies is re-run against a fresh one. - * Everything job-scoped is created through the factories below, after the lease is claimed. - */ export interface ReviewRuntime { kv: KvStore; clock: Clock; ids: IdGenerator; - /** - * The bot's own login, used to find its previous review on a retried finalize and to attribute the - * review overview. Must match the account the `github` port posts as, or finalize will fail to - * recognise its own earlier comment and post a duplicate. - */ - botUsername: string; + botUsername: string; jobs: JobStore; fileReviews: FileReviewStore; @@ -39,18 +24,11 @@ export interface ReviewRuntime { repoConfig: RepoConfigLoader; telemetry: TelemetrySink; - /** - * Job-scoped collaborators. Factories rather than instances because all four are built per phase, - * after the job row is claimed, and because the github and model ports must share ONE - * TokenTracker -- the subrequest budget that decides how many files a phase attempts counts both - * provider calls and model calls, so two trackers would let a phase overrun its invocation limit. - */ - createTokenTracker(): TokenTracker; + createTokenTracker(): TokenTracker; createGitHub(installationId: string, tracker: TokenTracker): ReviewGitHub; createModel(jobId: string, tracker: TokenTracker): ReviewModel; createFormatter(): ReviewFormatter; - /** For webhook resolution, which runs before any job row exists. */ - githubClients: GitHubClientFactory; + githubClients: GitHubClientFactory; modelErrors: ModelErrorClassifier; } diff --git a/packages/core/src/ports/settings.ts b/packages/core/src/ports/settings.ts index 928e8f7a..5f98cb05 100644 --- a/packages/core/src/ports/settings.ts +++ b/packages/core/src/ports/settings.ts @@ -1,69 +1,21 @@ -import type { ClaimType, RepoConfig, ReviewSettings } from '@codra/schema'; +import type { ClaimType, RepoConfig, ReviewSettings } from '@codra/schema'; -/** - * Instance-wide review settings (concurrency level, file caps). - * - * A correct implementation must always return a complete, valid `ReviewSettings` -- defaults when - * nothing is stored, never a partial object and never a throw. The engine reads this on the admission - * path, so a failure here rejects a job that should have run. It MAY cache: callers already assume - * one lookup serves a whole phase, and a setting changed mid-review taking effect on the next phase - * is the intended behaviour. - */ export interface ReviewSettingsReader { getReviewSettings(): Promise; } -/** - * Per-repository configuration (the committed `.codra.json`, merged over defaults). - * - * A correct implementation must guarantee: - * - the return is always a fully-populated `RepoConfig`, defaults included. Callers index into - * `parsedJson.review` without checking, and a partial config silently disables gates. - * - `enabled: false` means "this repo has opted out"; absence of any record means enabled. - * - it is safe to call repeatedly for the same repo within a phase. Caching is expected; the cache - * need not be invalidated mid-review. - */ export interface RepoConfigLoader { loadRepoConfig(input: { installationId: string; owner: string; repo: string }): Promise<{ parsedJson: RepoConfig; enabled: boolean }>; } -/** - * The model catalogue, narrowed to the one field the engine needs. - * - * Deliberately NOT the full `ResolvedModelConfig`: that carries `encryptedApiKey`, and a credential - * has no business crossing into the engine. The real implementation is still structurally assignable, - * so this is a narrowing rather than an API change. - * - * A correct implementation returns null for an unknown or disabled model id rather than throwing -- - * the sole caller is labelling a failure for telemetry and must not fail because of it. - */ export interface ModelConfigReader { getResolvedModelConfig(modelId: string): Promise<{ providerName: string } | null>; } -/** - * Webhook deliveries, replayed to recover a job whose queue message arrived without one. - * - * `payload` stays `unknown` on purpose: the caller narrows it to a `GitHubWebhookPayload` itself, and - * a port that pre-narrowed it would be asserting a git-provider shape the engine is meant not to - * assume. A correct implementation must return the payload already decoded from whatever column - * encoding it uses -- never a JSON string -- and null for an unknown delivery id. - */ export interface WebhookDeliveryReader { getWebhookDelivery(deliveryId: string): Promise<{ delivery_id: string; event_name: string; payload: unknown } | null>; } -/** - * Findings a human previously rejected, injected as negative few-shot exemplars. - * - * A correct implementation must guarantee: - * - results are drawn only from findings a human actually labelled. Absence of a label is not a - * rejection, and treating it as one would teach the model from silence. - * - `limit` is an upper bound and may be clamped down; returning fewer (including none) is always - * legal. Every caller treats exemplars as optional enrichment and must still work with zero. - * - it never throws for a repository with no history -- a new repo returns an empty array. - * - the field names stay snake_case: they are read straight through into the prompt builder. - */ export interface LearningStore { getRepositoryIdForJob(jobId: string): Promise; getRejectedExemplars(input: { repositoryId: number; claimTypes?: readonly ClaimType[]; limit?: number }): Promise< diff --git a/packages/core/src/ports/telemetry.ts b/packages/core/src/ports/telemetry.ts index 3b4cdb50..926b6691 100644 --- a/packages/core/src/ports/telemetry.ts +++ b/packages/core/src/ports/telemetry.ts @@ -1,8 +1,4 @@ -/** - * One completed review's anonymous metrics. Shaped entirely by the engine; the version and instance - * id are the sink's business, since neither is knowable from inside a review. - */ -export type ReviewTelemetryEvent = { +export type ReviewTelemetryEvent = { linesReviewed: number; findingsReported: number; inputTokens: number; @@ -19,18 +15,6 @@ export type ReviewTelemetryEvent = { retryCount: number; }; -/** - * Where finished-review metrics go. - * - * A correct implementation MUST NOT THROW, for any input or any transport failure -- it is called on - * the last step of a successful review, and an exception there would fail a job that has already - * posted its review. It must also not block: a slow or unreachable endpoint has to degrade to - * dropping the event, not to holding the phase open until the invocation times out. - * - * It may drop, batch, sample or refuse events entirely (a host with telemetry disabled implements - * this as a no-op), so the engine treats a resolved promise as no evidence that anything was sent. - * Delivery is at-most-once and unordered. - */ export interface TelemetrySink { send(event: ReviewTelemetryEvent): Promise; } diff --git a/packages/core/src/prompts/file-review.ts b/packages/core/src/prompts/file-review.ts index 693b9c61..31706f86 100644 --- a/packages/core/src/prompts/file-review.ts +++ b/packages/core/src/prompts/file-review.ts @@ -1,428 +1,386 @@ -import { claimTypes, type RepoConfig } from '@codra/schema'; -import type { FileDiff } from '../diff'; -import type { ModelResponseSchema } from '../ports/model'; -import { getLanguageForFile } from './languages'; - -// Generator cap, NOT the posted cap: per CHUNK, upstream of four remove-only filters, where `max_comments` is once per job. -// -// Deliberately NOT divided by the size of a batched bin. That was tried, on the theory that a six-file -// bin asking 20 findings per file requested more than one response could hold: measured on a 221-file -// job, all 71 bin responses ended cleanly at 967-1,845 chars and the whole job spent 17,158 output -// tokens -- about 3% of the ceiling that was supposedly binding. The cap has never been what limits -// findings, so lowering it only removes room a genuinely defective file might need. -export function generatorFindingCap(maxComments: number): number { - return Math.max(1, maxComments * 2); -} - -// Shared by the single-file and batched grammars, so the field-order invariant is stated once. -function findingItemSchema() { - return { - type: 'object', - additionalProperties: false, - // Field order is load-bearing under constrained decoding: `evidence` first forces a real quote before any prose. - required: ['evidence', 'code_location', 'claim_type', 'title', 'body', 'priority'], - // `properties` order must match `required`: generation follows declaration order, so gemini-schema.ts must never sort or rebuild this. - 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 }, - }, - }, - }, - // Branch order matters: gemini-schema.ts collapses this to the first branch. - 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' }, - }, - }; -} - -// Response grammar for constrained decoding; same contract as the system and user prompts, all three must agree. -export function buildReviewResponseSchema(maxComments: 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: generatorFindingCap(maxComments), - 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 }, - }, - }, - }; -} - -// Batched grammar: `absolute_file_path` is required here even though its per-finding twin is optional; no `minItems` on `files` (uneven provider support) so the count is checked at parse time. -export function buildBatchReviewResponseSchema(maxComments: 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, - // Path first, like `evidence` in a finding: commit to the file before describing it. - required: ['absolute_file_path', 'findings', 'overall_explanation', 'overall_correctness'], - properties: { - absolute_file_path: { type: 'string' }, - // Deliberately unbounded, unlike the single-file grammar: `maxItems` on an array nested - // inside another bounded array made Gemini reject the whole schema with "produces a - // constraint that has too many states for serving", losing constrained decoding for the - // bin. The cap is stated in prose ("per file") and enforced at parse time by the - // over-cap truncation, so nothing but the FSM size changes. - 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) -}`; - -// A finding belongs to whichever entry encloses it; the per-finding `absolute_file_path` is only a cross-check. -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) -}`; - -// No restraint language: behind four remove-only filters, asking for empty findings arrays measured 0.039 findings/file and no true positives. Wording is snapshot-locked. -export function buildFileReviewSystemPromptBase(opts?: { multiFile?: boolean }): string { - const multi = opts?.multiFile === true; - - 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.` - : '- You can see ONLY the diff below, not the whole file or the rest of the repository.'; - - 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.'; - - // The multi-file wording must demand one entry per file (the parser reports a missing file as - // unreviewed and re-queues it) WITHOUT handing out an empty array as the easy way to satisfy that. - // The previous phrasing -- "even for files with no defect, give those an empty findings array" -- - // presupposed clean files in every bin and reintroduced exactly the restraint language the note above - // says measured 0.039 findings/file. Review each diff on its own merits is the whole instruction. - 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.`; -} - -// Named export because several tests assert against the prompt text directly. -export const fileReviewSystemPromptBase = buildFileReviewSystemPromptBase(); - -export function buildFileReviewSystemPrompt( - config: RepoConfig['review'], - languagePersona?: string, - opts?: { multiFile?: boolean }, -) { - const persona = languagePersona ? ` as ${languagePersona}` : ''; - // Prose cap must be the generator cap: otherwise the grammar allows 2N while the text asks for N, and the model obeys the text. - const prompt = buildFileReviewSystemPromptBase(opts) - .replace('{{MAX_COMMENTS}}', generatorFindingCap(config.max_comments).toString()); - return `You are a world-class professional senior code reviewer${persona}. ${prompt}`; -} - -// Human-rejected findings as NEGATIVE few-shot exemplars. Rejections only, since `marked_right` is rare and an absent label means nothing. -export type RejectedExemplar = { title: string; claimType?: string | null }; - -// Hard cap: every character competes with the diff for a 16k-input-tokens/minute bucket. -const EXEMPLAR_BLOCK_CHARS = 700; - -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'); -} - -const PR_DESCRIPTION_CHARS = 2_000; - -// Highest-value context by a wide margin (ContextCRBench: diff-only F1 36.08, +description 62.12). -function renderPrContext(prDescription: string | null): string | null { - const trimmed = prDescription?.trim(); - if (!trimmed) return null; - return `PR description (author intent - use to judge whether a change is deliberate):\n${trimmed.slice(0, PR_DESCRIPTION_CHARS)}${trimmed.length > PR_DESCRIPTION_CHARS ? '…' : ''}`; -} - -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; - prTitle: string | null; - prDescription: 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 systemPrompt = buildFileReviewSystemPrompt(input.config, languageInfo?.persona); - const languageGuidelines = renderLanguageGuidelines(input.file.path); - - const prContext = renderPrContext(input.prDescription); - - const exemplars = renderExemplars(input.rejectedExemplars); - - const userPrompt = [ - `PR title: ${input.prTitle ?? 'Untitled PR'}`, - ...(prContext ? [prContext] : []), - ...(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` is posted to GitHub as the anchor, so it must be a NEW-file number present in the diff. - '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 is matched verbatim before posting, so it must be code only -- no gutter or marker. - 'Evidence: every finding must carry an `evidence` string containing the exact code of the line it is about, copied character-for-character from the 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.', - '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), - ].join('\n'); - - return { systemPrompt, userPrompt }; -} - -// Distinct enough not to be confused for diff content. -function packFileHeader(file: FileDiff, index: number, total: number): string { - return `===== FILE ${index + 1} of ${total}: ${file.path} =====`; -} - -// Several small files share one call so the ~2,800-token preamble amortises. Not a generalisation of buildFileReviewPrompts, which is snapshot-locked. -export function buildBatchReviewPrompts(input: { - files: readonly FileDiff[]; - prTitle: string | null; - prDescription: string | null; - config: RepoConfig['review']; - rejectedExemplars?: readonly RejectedExemplar[]; -}) { - const files = input.files; - - // Object identity is enough: getLanguageForFile returns the same entry for every matching file. - const languages = new Set(files.map((file) => getLanguageForFile(file.path))); - const uniformLanguage = languages.size === 1 ? [...languages][0] : undefined; - - // A persona claims something about the whole response, so only uniform bins get one. - const systemPrompt = buildFileReviewSystemPrompt(input.config, uniformLanguage?.persona, { multiFile: true }); - - const prContext = renderPrContext(input.prDescription); - 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), - // Uniform bins state the language once, above; only a mixed bin repeats it per file. - ...(uniformLanguage ? [] : [renderLanguageGuidelines(file.path)]), - 'Unified diff:', - renderFileDiff(file), - ]); - - const userPrompt = [ - `PR title: ${input.prTitle ?? 'Untitled PR'}`, - ...(prContext ? [prContext] : []), - ...(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.', - // The key batch-only rule: a misfiled finding can fuzzy-match a common line in the wrong file. - '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` is posted to GitHub as the anchor, so it must be a NEW-file number present in the diff. - '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 is matched verbatim before posting, so it must be code only -- no gutter or marker. - '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.', - '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)`, - // Same constant the system prompt renders. - MULTI_FILE_SCHEMA_FORMAT, - ...fileBlocks, - ].join('\n'); - - return { systemPrompt, userPrompt }; -} - -// Exported so the packer measures bins with the exact renderer the prompt uses. -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 '@codra/schema'; +import type { FileDiff } from '../diff'; +import type { ModelResponseSchema } from '../ports/model'; +import { getLanguageForFile } from './languages'; + +export function generatorFindingCap(maxComments: number): number { + return Math.max(1, maxComments * 2); +} + +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(maxComments: 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: generatorFindingCap(maxComments), + 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(maxComments: 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 }): string { + const multi = opts?.multiFile === true; + + 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.` + : '- You can see ONLY the diff below, not the whole file or the rest of the repository.'; + + 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.'; + + // presupposed clean files in every bin and reintroduced exactly the restraint language the note above + 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 }, +) { + const persona = languagePersona ? ` as ${languagePersona}` : ''; + const prompt = buildFileReviewSystemPromptBase(opts) + .replace('{{MAX_COMMENTS}}', generatorFindingCap(config.max_comments).toString()); + return `You are a world-class professional senior code reviewer${persona}. ${prompt}`; +} + +export type RejectedExemplar = { title: string; claimType?: string | null }; + +const EXEMPLAR_BLOCK_CHARS = 700; + +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'); +} + +const PR_DESCRIPTION_CHARS = 2_000; + +function renderPrContext(prDescription: string | null): string | null { + const trimmed = prDescription?.trim(); + if (!trimmed) return null; + return `PR description (author intent - use to judge whether a change is deliberate):\n${trimmed.slice(0, PR_DESCRIPTION_CHARS)}${trimmed.length > PR_DESCRIPTION_CHARS ? '…' : ''}`; +} + +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; + prTitle: string | null; + prDescription: 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 systemPrompt = buildFileReviewSystemPrompt(input.config, languageInfo?.persona); + const languageGuidelines = renderLanguageGuidelines(input.file.path); + + const prContext = renderPrContext(input.prDescription); + + const exemplars = renderExemplars(input.rejectedExemplars); + + const userPrompt = [ + `PR title: ${input.prTitle ?? 'Untitled PR'}`, + ...(prContext ? [prContext] : []), + ...(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 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.', + '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), + ].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; + 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 prContext = renderPrContext(input.prDescription); + 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 = [ + `PR title: ${input.prTitle ?? 'Untitled PR'}`, + ...(prContext ? [prContext] : []), + ...(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.', + '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 ad22509f..bb6e15d6 100644 --- a/packages/core/src/prompts/languages.ts +++ b/packages/core/src/prompts/languages.ts @@ -1,91 +1,88 @@ -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).', - ], - }, - // A React entry with a hook-dependency guideline used to live here, but its extensions overlapped the TypeScript entry above. - // Effect was measurable: hook-dependency findings ran 10x concentrated in .tsx with 0 of 28 posted -- the checklist dictated what the model "found" rather than helping it find more. Removed rather than reworded. - { - 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; - - // On an overlap, take the single most specific entry rather than merging: merging is how .tsx ended up being told to hunt for hook-dependency bugs. Narrower extension list == more specific. - if (matches.length > 1) { - return matches.reduce((best, candidate) => - candidate.extensions.length < best.extensions.length ? candidate : best, - ); - } - - return matches[0]; -} +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]; +} diff --git a/packages/core/src/prompts/verify.ts b/packages/core/src/prompts/verify.ts index ace50f10..36ad71fa 100644 --- a/packages/core/src/prompts/verify.ts +++ b/packages/core/src/prompts/verify.ts @@ -1,168 +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(), - // `.optional()` and NOT `.default()`: a default would materialize the key on every parsed result, changing the shape callers compare against. - reason: z.string().optional(), - // Optional so a model that ignores the field is treated as "did not say", never as "not - // 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]; - -// Field order matters for providers that decode against the schema: `reason` precedes `verdict` so the -// model commits to a justification BEFORE the decision token, and `decidable` precedes it for the same -// reason -- it must answer "could I check this at all?" before it is allowed to answer "is it true?". -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 }, - // Longer than the 15 words the verdict gets: naming the artifact you would need to check - // a claim is the whole point of the `decidable` field, and it does not fit in 15 words. - 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'); -} - -// Renders a window of the diff around a finding's line so the verifier can judge it in context without re-sending the whole file. -// Returns '' when the line can't be located, rather than falling back to `anchor = 0`: that used to make the verifier silently judge unrelated code, masquerading an infrastructure miss as a real verdict. -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 ''; - - // NEW-file numbers first, in a separate pass: a combined findIndex on `newLineNumber === line || oldLineNumber === line` can match an earlier OLD-numbered context line in a deletion-heavy file, landing the window N-deletions away from the real finding. Old-number pass is kept only as a fallback for removed code. - 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 0fc6ae4a..8dbb21d2 100644 --- a/packages/core/src/review/bin-runner.ts +++ b/packages/core/src/review/bin-runner.ts @@ -1,246 +1,223 @@ -import { logger } from '../logger'; -import type { RepoConfig } from '@codra/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, FRESH_INVOCATION_YIELD_SECONDS, MAX_RETRYABLE_FILE_REVIEW_FAILURES } from './phase-control'; -import { isSubrequestBudgetError, retryableModelFailureDelaySeconds } from './retry-policy'; -import { scanRuleChannel } from './file-runner'; -// One bin end to end: rule scan per file, one shared model call, then one row per file. Import from the core/review barrel, not here. - -// Phrased to match isRetryableFileReviewErrorMessage ("retrying later"), so the file is re-queued and narrowUnit explodes the bin back onto the single-file path. -const MISSING_FILE_ERROR = 'Model omitted this file from a batched review; retrying later.'; - -// Splits a total across weights, summing exactly to it -- cost reporting sums these columns. -export function proportionalSplit(total: number, weights: number[]): number[] { - if (weights.length === 0) return []; - - const sum = weights.reduce((a, b) => a + b, 0); - // A degenerate bin (all diffs empty) splits evenly rather than dividing by zero. - const parts = sum <= 0 - ? weights.map(() => Math.floor(total / weights.length)) - : weights.map((w) => Math.floor((total * w) / sum)); - - // Flooring loses up to n-1 tokens; give the remainder to the heaviest weight. - 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; -} - -// Reviews a packed bin in one model call, one row per file. Returns how many files reached a terminal state; re-queued files are excluded, or the wedge counter never advances. -export async function reviewAndPersistBin( - env: ReviewRuntime, - job: PersistedReviewJob, - files: FileDiff[], - pr: PullRequestRecord, - config: RepoConfig, - totalLineCount: number, - model: ReviewModel, - resolveFailureModelProvider: () => Promise, - rejectedExemplars: readonly RejectedExemplar[] = [], -): Promise { - const startedAt = env.clock.now(); - - // Scanned before the model call, so a rule hit reaches finalize even when the chain fails. - const ruleScans = new Map(files.map((file) => [file.path, scanRuleChannel(file, config)])); - - // The catch-all skips these, or a later failure would re-mark committed files failed and - // bulkUpsertFileReviews' comment DELETE would wipe their findings. - const persisted = new Set(); - let terminalCount = 0; - - // Failed rows keep rule-channel findings, produced before the model ran. - 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, - 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, - // The only debugging artifact left once 003 nulls diff_input and the KV cache expires. - rawAiOutput: response.rawText, - parsedComments: [...parsed.comments, ...rules.comments], - inputTokens: inputSplit[index], - outputTokens: outputSplit[index], - // Wall clock, not cost: every file waited this long. - durationMs, - verdict: parsed.verdict, - fileSummary: parsed.fileSummary, - overallCorrectness: parsed.overallCorrectness, - confidenceScore: parsed.confidenceScore, - errorMessage: null, - // Per file: a bin-wide total would let one noisy file mask four clean ones. - 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), - }, - 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; - } - - // Never done-and-clean (that approves unexamined code), and not terminal progress either. - 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); - - // Otherwise a file omitted every time never terminates through this path. - 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; - } - } - - // Every batch counter below is zero in a healthy run; non-zero is the alarm. - 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(); - - // No DB write: with no row every file stays outstanding and the bin is re-planned. - 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; - } - - // Committed rows stay committed: re-marking them would delete correct findings. - const outstanding = files.filter((file) => !persisted.has(file.path)); - - // Error after everything was recorded: rethrowing would discard terminalCount and fail the job. - 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)) { - // Set when the chain still has untried models: the next invocation resumes at that index, so - // this deferral is progress and must not spend one of the three allowed attempts. Keeping the - // count also keeps the bin intact, which is what we want while only the model is changing. - 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) { - // Terminal, but rule-channel findings survive the model's failure. - 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 '@codra/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, FRESH_INVOCATION_YIELD_SECONDS, MAX_RETRYABLE_FILE_REVIEW_FAILURES } from './phase-control'; +import { isSubrequestBudgetError, retryableModelFailureDelaySeconds } from './retry-policy'; +import { scanRuleChannel } from './file-runner'; + +const MISSING_FILE_ERROR = 'Model omitted this file from a batched review; retrying later.'; + +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[] = [], +): 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, + 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), + }, + 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 7dcdf500..a8461200 100644 --- a/packages/core/src/review/budget.ts +++ b/packages/core/src/review/budget.ts @@ -1,24 +1,18 @@ -// Subrequest budget arithmetic for the review loop. Pure functions over the Workers Free plan's 50-subrequests-per-invocation limit; no env, no I/O. -// KEEP `estimatedSubrequestsPerFile` AT 6 OR BELOW: fresh-budget headroom is 25 after SAFE_MARGIN, and floor(25 / 6) == 4 still honours the "max" concurrency level of 4; at 8, floor(25 / 8) == 3 silently caps that slider (regression pinned by chunk-concurrency.spec.ts). - -// Per-file cost that isn't the model call: the persisted-review write and its lookups. -const FILE_FIXED_SUBREQUESTS = 2; - -// Model attempts budgeted per file. Budgeting the full chain would collapse concurrency to one; SAFE_MARGIN absorbs attempts that cost more than one subrequest (google.ts grammar probe). -const MAX_MODEL_ATTEMPTS_ESTIMATE = 4; - -// Files a chunk may review concurrently: the configured level, capped by remaining safe budget. Must NOT silently override the user's choice at a healthy budget; it only throttles once earlier failures have eaten into it. -export function budgetAwareFileLimit( - remainingSafeBudget: number, - configuredChunkFileLimit: number, - modelChainLength = 1, -) { - const budgetLimit = Math.floor(remainingSafeBudget / estimatedSubrequestsPerFile(modelChainLength)); - return Math.min(configuredChunkFileLimit, budgetLimit); -} - -// Derived from the configured chain, not a flat 5: a flat estimate assumes the primary model answers, but a rate-limited primary walks the chain and each attempt costs a subrequest. -export function estimatedSubrequestsPerFile(modelChainLength: number) { - const modelAttempts = Math.max(1, Math.min(modelChainLength, MAX_MODEL_ATTEMPTS_ESTIMATE)); - return FILE_FIXED_SUBREQUESTS + modelAttempts; -} + +const FILE_FIXED_SUBREQUESTS = 2; + +const MAX_MODEL_ATTEMPTS_ESTIMATE = 4; + +export function budgetAwareFileLimit( + remainingSafeBudget: number, + configuredChunkFileLimit: number, + modelChainLength = 1, +) { + const budgetLimit = Math.floor(remainingSafeBudget / estimatedSubrequestsPerFile(modelChainLength)); + return Math.min(configuredChunkFileLimit, budgetLimit); +} + +export function estimatedSubrequestsPerFile(modelChainLength: number) { + const modelAttempts = Math.max(1, Math.min(modelChainLength, MAX_MODEL_ATTEMPTS_ESTIMATE)); + return FILE_FIXED_SUBREQUESTS + modelAttempts; +} diff --git a/packages/core/src/review/diff-cache.ts b/packages/core/src/review/diff-cache.ts index f454a4e7..99bcd6be 100644 --- a/packages/core/src/review/diff-cache.ts +++ b/packages/core/src/review/diff-cache.ts @@ -1,54 +1,50 @@ -import { reviewMaxFilesRange, type RepoConfig } from '@codra/schema'; -import { filterReviewableFiles, parseUnifiedDiff, type FileDiff } from '../diff'; -import type { ReviewGitHub, ReviewRuntime } from '../ports'; -import { logger } from '../logger'; - -const DIFF_CACHE_TTL_SECONDS = 6 * 60 * 60; - -// KV-cached access to a job's raw diff. The two readers below share one cache key, so they must stay together. -export function diffCacheKey(jobId: string) { - return `diff:${jobId}`; -} - -// Fetches and parses the PR diff from GitHub only once per job (cached in KV) instead of once per phase invocation. -export async function getDiffFiles( - env: Pick, - job: { id: string; owner: string; repo: string; prNumber: number }, - github: Pick, - config: RepoConfig, - // Passed in rather than read here so a single settings lookup can serve both this and the concurrency level in the same phase. - 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); -} - -// Reconstructs the raw PR diff for a finished job (diff_input isn't stored in Postgres; see /api/jobs/:id/diffs). Reuses getDiffFiles' KV cache while warm; once the 6h TTL lapses, re-derives from GitHub via the job's own base/head commits (not the live PR diff, which may have moved on) and rewrites the cache. -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 '@codra/schema'; +import { filterReviewableFiles, parseUnifiedDiff, type FileDiff } from '../diff'; +import type { ReviewGitHub, ReviewRuntime } from '../ports'; +import { logger } from '../logger'; + +const DIFF_CACHE_TTL_SECONDS = 6 * 60 * 60; + +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 de453dba..9f88aa68 100644 --- a/packages/core/src/review/file-runner.ts +++ b/packages/core/src/review/file-runner.ts @@ -1,274 +1,257 @@ -import { logger } from '../logger'; -import { type ParsedReviewComment, type RepoConfig } from '@codra/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, FRESH_INVOCATION_YIELD_SECONDS, MAX_RETRYABLE_FILE_REVIEW_FAILURES } from './phase-control'; -import { isSubrequestBudgetError, retryableModelFailureDelaySeconds } from './retry-policy'; -// Sibling of the core/review barrel; import from there, not here. One file end to end: rule scan, model review, persist. - -// Persists an async-batch poll result, clearing the bookkeeping columns. -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, - // Not persisted: rebuilt on demand from GitHub/KV rather than stored in Postgres. - 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, - }); -} - -// Terminal 'failed' upsert, one place for several near-identical ones. `clearAsync` wipes batch bookkeeping on queued rows. -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; - // Deterministic findings when the MODEL review failed: the file is marked failed and still contributes what a regex could establish. - 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 } : {}), - }); -} - -// Rule channel over one file: comments for live rules, stats including shadow hits. Never throws, so a bad regex cannot fail a completed review. -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, - // A denied claim type must not produce a candidate the parser would have dropped from the model. - 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 }; - } -} - -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[] = [], -) { - const startedAt = env.clock.now(); - const compactPrompt = (previousReview?.transient_error_count ?? 0) > 0; - - // Scanned BEFORE the model call, so a hit reaches finalize even when the whole chain fails. - const ruleScan = scanRuleChannel(file, config); - - try { - const response = await model.reviewFile({ - file, - prTitle: pr.title ?? null, - prDescription: pr.body ?? null, - config, - totalLineCount, - compactPrompt, - rejectedExemplars, - }); - - 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, ...ruleScan.comments], - inputTokens: response.inputTokens, - outputTokens: response.outputTokens, - durationMs: env.clock.now() - startedAt, - verdict: response.parsed.verdict, - fileSummary: response.parsed.fileSummary, - overallCorrectness: response.parsed.overallCorrectness, - confidenceScore: response.parsed.confidenceScore, - errorMessage: null, - // Dropped in the parser, so never rows. Without this, finalize cannot tell "found nothing" from "everything withheld". - 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), - }, - }); - - // The only per-file grounding view: unmatched/absent/weak climbing on one model is the earliest signal its output stopped being usable. - 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, - // Shadow only. `refuted` at 0 while `absenceShaped` climbs means extraction, not the idea, is broken. - absenceCheck: response.parsed.absenceCheckStats, - ruleChannel: ruleScan.stats, - // Ran unconstrained because the provider refused the grammar; otherwise the only trace is a single adapter warn on the first file. - 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(); - - // Subrequest pressure clears next invocation, so it is not a per-file outage; the job-level continuation ceiling bounds a wedged job. - 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; - } - - // Transient outages count against the file, so one unrecoverable file becomes a partial review instead of blocking the job forever. - 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, - // Progress down the chain, not a repeated outage: the retry resumes at the next model. - 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 }); - - // Real allocation exhaustion (CF 4006) will not clear by retrying; subrequest limits are deferred above. - 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 }); - // Fall through to failed so the review completes as partial. - } - - 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 '@codra/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, FRESH_INVOCATION_YIELD_SECONDS, MAX_RETRYABLE_FILE_REVIEW_FAILURES } from './phase-control'; +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 }; + } +} + +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[] = [], +) { + const startedAt = env.clock.now(); + const compactPrompt = (previousReview?.transient_error_count ?? 0) > 0; + + const ruleScan = scanRuleChannel(file, config); + + try { + const response = await model.reviewFile({ + file, + prTitle: pr.title ?? null, + prDescription: pr.body ?? null, + config, + totalLineCount, + compactPrompt, + rejectedExemplars, + }); + + 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, ...ruleScan.comments], + inputTokens: response.inputTokens, + outputTokens: response.outputTokens, + 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), + }, + }); + + 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 298ed427..f50c2610 100644 --- a/packages/core/src/review/finalize.ts +++ b/packages/core/src/review/finalize.ts @@ -1,287 +1,259 @@ -import { logger } from '../logger'; -import { defaultRepoConfig, type ParsedReviewComment, type RepoConfig } from '@codra/schema'; -import { shadowEvaluate } from '../finding-gates'; -import { getDiffFiles } from './diff-cache'; -import type { ReviewFormatter, ReviewGitHub, ReviewModel, ReviewRuntime } from '../ports'; -import { - type PersistedReviewJob, - FRESH_INVOCATION_YIELD_SECONDS, - enqueueJobPhase, - heartbeatAndCheckSuperseded, -} from './phase-control'; -import { sendReviewTelemetry } from './telemetry'; -import { applyFindingGates } from './gate-pipeline'; -// Reconciles reviews, gates findings, then composes and posts the review. Import from the core/review barrel, not here. - -export async function runFinalizePhase( - env: ReviewRuntime, - job: PersistedReviewJob, - leaseOwner: string, - github: ReviewGitHub, - 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; - // One lookup supplies both the file ceiling and the gating comment cap. - const reviewSettings = await env.settings.getReviewSettings(); - // The diff (KV/GitHub) and the file reviews (Postgres) share no state; two in flight cannot breach the subrequest cap. - const [{ files, skipped: filesOverCap }, initialReviews] = await Promise.all([ - getDiffFiles(env, job, github, config, reviewSettings.maxFiles), - env.fileReviews.getFileReviewsForJobs([job.id]), - ]); - let reviews = initialReviews; - - { - // Set difference, not counts: the re-fetched diff can differ, so equal counts can still hide unreviewed files. - 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.`); - // One INSERT: per-file writes would exhaust the subrequest budget right before posting. - 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) { - // Every path covered but fewer rows than files: review isn't done, so bounce back. Must stay an `else if`, or the healthy path loops finalize forever. - await env.jobs.updateJobStep(job.id, 'Reviewing Files', { status: 'running' }); - await enqueueJobPhase(env, job.id, 'review', FRESH_INVOCATION_YIELD_SECONDS); - return; - } - } - - // The continuation-ceiling degrade reaches finalize unmarked, stranding the step "In progress". - 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); - // One level of retryOfJobId only; deeper chains would need a dedicated column on jobs. - 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; - const { - finalComments, - dispositions, - verifyReasons, - 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, - droppedByFilters, - droppedBySuppression, - droppedByVerification, - droppedByCap, - posted: finalComments.length, - withheldByParser, - byClaimType, - // Partitioned by channel, or LLM-channel numbers silently include deterministic rule hits. - byChannel: { - llm: finalComments.filter((c) => c.source !== 'rule').length, - rule: finalComments.filter((c) => c.source === 'rule').length, - }, - // Retirement signal: high `generated` with `posted` at zero means the verifier always rejects it. - byRule: reviewedComments.reduce>((acc, c) => { - if (c.source === 'rule' && c.ruleId) acc[c.ruleId] = (acc[c.ruleId] ?? 0) + 1; - return acc; - }, {}), - // Canaries: one empty review is fine, three in a row means the filters went too far. - postedAny: finalComments.length > 0, - postedPer100Files: files.length > 0 - ? Math.round((finalComments.length / files.length) * 1000) / 10 - : 0, - }); - - // Scored, not applied. Read over ~20 reviews; wouldDropPosted is the cost side. - logger.info('Shadow filter evaluation', { - jobId: job.id, - ...shadowEvaluate(beforeVerifyList, finalComments), - }); - - // Verdict uses findings STILL OPEN: one suppressed from an earlier commit is still unaddressed. - const rawVerdict = formatter.summarizeVerdict([...finalComments, ...suppressedComments], hasFailures); - - // All-withheld must not read as clean; only "nothing to find" justifies a green approval. - 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); - - let formattedSummary = formatter.formatReviewOverview(pr.head.sha, env.botUsername); - - // Reviewing 100 of 106 files and calling it done looks identical to finding the other six clean. - if (filesOverCap > 0) { - formattedSummary += `\n\n> [!WARNING]\n> **${filesOverCap} file${filesOverCap === 1 ? ' was' : 's were'} not reviewed.** This pull request has ${files.length + filesOverCap} reviewable files and the limit is ${reviewSettings.maxFiles}. Raise it in Settings to cover the whole diff.`; - } - - // Deliberately NOT surfaced in the GitHub comment. The withheld tally is diagnostic -- it says how - // the pipeline behaved, not anything about the pull request -- and a reader of the review cannot act - // on "5 not grounded in a quoted line". It stays in the structured log above, in `withheld_counts` on - // each file_reviews row, and in the per-file off-diff list, which is where it is actually useful. - // A finalize that died after createReview but before completeJob left a review on GitHub; reuse it. - 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, - // `line` is what the model reports; sending only `position` discarded every inline comment. - comments: finalComments.map(comment => ({ - path: comment.path, - line: comment.line ?? undefined, - side: 'RIGHT' as const, - position: comment.position ?? undefined, - body: formatter.formatInlineComment(comment), - })), - }); - - // `postedIndices`, not `finalComments`: the 422 fallback posts nothing, and marking all posted would hide them forever. - 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); - } - - // Measurement only: a failure here must never fail a review already on GitHub. - try { - // Union of both maps. Kept findings have no disposition and must not clobber 'posted'. - 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 = hasFailures - ? `Partial review: ${failedFileCount} of ${files.length} file${files.length === 1 ? '' : 's'} could not be reviewed.` - : null; - // Done immediately after createReview: the review is on GitHub, so a budget-exhausted cosmetic call must not strand the job. - 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}`); - - // Cosmetics only from here (labels, check-run conclusion), best-effort: the review is posted, and completeTerminalCheckRuns reconciles on failure. - try { - // Check-run conclusion first: it drives the status badge, so it wins if the budget allows only one. - 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.` : ''}`, - }); - // Record completion so the maintenance sweep skips it. - 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 '@codra/schema'; +import { shadowEvaluate } from '../finding-gates'; +import { getDiffFiles } from './diff-cache'; +import type { ReviewFormatter, ReviewGitHub, ReviewModel, ReviewRuntime } from '../ports'; +import { + type PersistedReviewJob, + FRESH_INVOCATION_YIELD_SECONDS, + enqueueJobPhase, + heartbeatAndCheckSuperseded, +} from './phase-control'; +import { sendReviewTelemetry } from './telemetry'; +import { applyFindingGates } from './gate-pipeline'; + +export async function runFinalizePhase( + env: ReviewRuntime, + job: PersistedReviewJob, + leaseOwner: string, + github: ReviewGitHub, + 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; + const { + finalComments, + dispositions, + verifyReasons, + 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, + 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), + }); + + 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); + + let formattedSummary = formatter.formatReviewOverview(pr.head.sha, env.botUsername); + + if (filesOverCap > 0) { + formattedSummary += `\n\n> [!WARNING]\n> **${filesOverCap} file${filesOverCap === 1 ? ' was' : 's were'} not reviewed.** This pull request has ${files.length + filesOverCap} reviewable files and the limit is ${reviewSettings.maxFiles}. Raise it in Settings to cover the whole diff.`; + } + + 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); + } + + 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 = hasFailures + ? `Partial review: ${failedFileCount} of ${files.length} file${files.length === 1 ? '' : 's'} could not be reviewed.` + : null; + 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 1ffe24f5..09fc7058 100644 --- a/packages/core/src/review/gate-pipeline.ts +++ b/packages/core/src/review/gate-pipeline.ts @@ -1,144 +1,130 @@ -import { dedupeFindings } from '../model-output'; -import { verifyFindings } from '../finding-gates'; -import type { FindingDisposition, ParsedReviewComment, RepoConfig } from '@codra/schema'; -import type { FileDiff } from '../diff'; -import type { PersistedReviewJob } from './phase-control'; -import type { ReviewModel, ReviewRuntime } from '../ports'; -import { loadSuppressedFingerprints } from './telemetry'; - -// The finding funnel. Order is load-bearing: severity/confidence gates, cross-run suppression (before dedupe/verification), dedupe, a severity sort, then verification and the max_comments cap. -// Returns per-stage counts, since `posted = false` alone conflated six outcomes. Import from the core/review barrel, not here. -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 } | 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; - - // 1. Severity + confidence gates. The parser substitutes 0 for an omitted score on every provider, which is what stops this being a no-op. - const dispositions = new Map(); - // The verifier's reasoning, kept and dropped alike: the only surface explaining its rulings. - 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) => { - if ((severityRanks[c.severity] ?? 4) > minRank) { - recordDisposition([c], 'severity'); - return false; - } - if (typeof c.confidenceScore === 'number' && c.confidenceScore < minConfidence) { - recordDisposition([c], 'confidence'); - return false; - } - return true; - }); - - // 2. Cross-run suppression, BEFORE dedupe (a suppressed finding must not be elected as a title group's representative) and before verification (no tokens spent judging what won't post). - 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) => { - // EITHER identity matches: v1 hashes the title, so it missed reworded repeats (six of ten re-reports on one PR). - const rejected = (c.fingerprint && suppressed.rejected.has(c.fingerprint)) - || (c.fingerprintV2 && suppressed.rejectedV2.has(c.fingerprintV2)); - - // v1 also requires the anchored line unchanged; v2 has the anchor in its key already. - 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'); - - // 3. Collapse duplicates. One representative per title across ALL files, elected BEFORE verification, so a dropped representative takes a genuine sibling down with it. - const beforeDedupe = finalComments; - finalComments = dedupeFindings(finalComments); - const survivedDedupe = new Set(finalComments); - recordDisposition(beforeDedupe.filter((c) => !survivedDedupe.has(c)), 'dedupe'); - - // 4. Severity then confidence, so the max_comments cap keeps the strongest, not the first. - 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); - }); - - // 5. Verification: one model call re-checks survivors against the diff. Best-effort, and it returns a subsequence, so the severity sort survives. - const beforeVerifyList = finalComments; - const verify = await verifyFindings({ job, config, files, comments: finalComments, model, maxCandidates: effectiveMaxComments }); - finalComments = verify.comments; - const droppedByVerification = verify.dropped.length; - // Per-drop attribution, not a set difference: distinguishes "judged a drop" from "never answered" from "no context could be rendered". - 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; - // Everything removed before verification, minus suppression: severity/confidence gates + dedupe. - const droppedByFilters = omittedCount - droppedBySuppression - droppedByVerification - droppedByCap; - - // Parser-withheld findings never became review_comments rows, so the count rides on file_reviews; otherwise "found nothing" and "withheld" are indistinguishable. - const withheldByParser = reviews.reduce( - (sum, review) => sum + (review.withheld_counts?.evidence ?? 0) + (review.withheld_counts?.claimDenied ?? 0), - 0, - ); - - // Split by survival: a type generated repeatedly and never posted is a type to retire. - 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, - 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 '@codra/schema'; +import type { FileDiff } from '../diff'; +import type { PersistedReviewJob } from './phase-control'; +import type { ReviewModel, ReviewRuntime } from '../ports'; +import { loadSuppressedFingerprints } from './telemetry'; + +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 } | 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; + + 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) => { + if ((severityRanks[c.severity] ?? 4) > minRank) { + recordDisposition([c], 'severity'); + return false; + } + if (typeof c.confidenceScore === 'number' && c.confidenceScore < 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: effectiveMaxComments }); + 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), + 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, + 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 4eada7ff..26bb9b73 100644 --- a/packages/core/src/review/index.ts +++ b/packages/core/src/review/index.ts @@ -1,371 +1,357 @@ -import { logger } from '../logger'; -import { isSupportedGitHubWebhookEvent, type GitHubWebhookPayload, type PullRequestWebhookPayload } from '@codra/schema/github'; -import { REVIEW_CONCURRENCY_LIMITS, type ReviewJobMessage } from '@codra/schema'; -import type { ReviewGitHub, ReviewRuntime } from '../ports'; -import { extractReviewRequest } from './request'; - -// Re-exports below are the engine's public surface, reached through @codra/core. -export { getDiffFiles, getOrFetchRawDiffForCompletedJob } from './diff-cache'; - -export { budgetAwareFileLimit, estimatedSubrequestsPerFile } from './budget'; - -export { - BIN_DIFF_CHAR_BUDGET, - BIN_MAX_FILES, - BIN_TARGET_DIFF_LINES, - PACKABLE_MAX_DIFF_LINES, - narrowUnit, - planReviewUnits, - unitFiles, - type LedgerEntry, - type ReviewUnit, -} from './pack'; - -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 -// importing phase-control directly. -export { FRESH_INVOCATION_YIELD_SECONDS } from './phase-control'; - -import { - type PersistedReviewJob, - BUSY_RETRY_SECONDS, - FRESH_INVOCATION_YIELD_SECONDS, - JOB_LEASE_SECONDS, - MAX_FINALIZE_CONTINUATIONS, - MAX_JOB_CONTINUATIONS, - NextPhaseError, - failJobAndCheckRun, -} from './phase-control'; -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 } - // jobId is resolved (mention-triggered jobs carry none). freshInstance starts a new Workflow instance: set on a subrequest deferral or the move into finalize. - | { 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' }; - } - - // Admission only: re-gating a job already 'running' would retry forever and stale its lease. - 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); - - // Bind the Workflow instance id so stop/delete/rerun hit the right one; webhook jobs key theirs on deliveryId, so the earlier bind step cannot. Idempotent. - 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); - // Finalize needs a fresh instance for a clean budget; other transitions hibernate instead. - 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'); - } - - // Not a job failure: every phase is idempotent enough to resume on a fresh budget. - if (isSubrequestBudgetError(error)) { - // Only a long-enough sleep hibernates the workflow into the fresh invocation this needs. - 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'); - } - - 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' }; - } -} - -// Records a same-phase continuation and enforces the ceiling. Completing any file resets the counter, so only a genuinely wedged job gets there. -async function continueOrFailWedgedJob( - env: ReviewRuntime, - job: PersistedReviewJob, - github: ReviewGitHub, - leaseOwner: string, - phase: 'prepare' | 'review' | 'finalize', - delaySeconds: number, - reason: string, -): Promise { - const continuationCount = await env.jobs.markJobContinuationQueued(job.id, delaySeconds); - - // Finalize fails fast instead of looping ~20 min; other phases make real per-file progress. - const ceiling = phase === 'finalize' ? MAX_FINALIZE_CONTINUATIONS : MAX_JOB_CONTINUATIONS; - - if (continuationCount > ceiling) { - if (phase === 'review') { - // Must RETURN the transition: enqueueJobPhase() throws, and this runs inside a catch. - logger.error(`Review job exceeded the continuation ceiling; degrading to a partial review: ${job.owner}/${job.repo} PR #${job.prNumber}`, { - phase, - continuationCount, - reason, - }); - // A file still awaiting an async batch would otherwise finalize as an empty 'successful'. - 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, - }); - } - // Finalize needs its own continuation budget: the counter is already past the ceiling. - 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); - // A subrequest-limit deferral saturated THIS instance; a transient model deferral did not. - 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 GitHubWebhookPayload | 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 GitHubWebhookPayload; - } - - if (!isSupportedGitHubWebhookEvent(eventName)) { - logger.info(`Queue message ignored: unsupported GitHub event ${eventName}`); - return null; - } - - const installationId = String(payload.installation?.id ?? ''); - 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.login, - repo: payload.repository.name, - }); - - if (repoConfig.enabled === false) { - logger.info(`Job ignored: repository ${payload.repository.owner.login}/${payload.repository.name} is disabled`); - return null; - } - - const extracted = extractReviewRequest({ - eventName, - payload, - botUsername: env.botUsername, - config: repoConfig.parsedJson, - }); - - if (!extracted) { - if (eventName === 'pull_request') { - const prPayload = payload as PullRequestWebhookPayload; - 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.login, - prPayload.repository.name, - prPayload.pull_request.number, - [labels.p1, labels.p2, labels.p3], - ); - } - } - return null; - } - - let resolved = extracted; - const githubClient = env.githubClients.forInstallation(installationId); - if (eventName === 'issue_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 { isSupportedGitHubWebhookEvent, type GitHubWebhookPayload, type PullRequestWebhookPayload } from '@codra/schema/github'; +import { REVIEW_CONCURRENCY_LIMITS, type ReviewJobMessage } from '@codra/schema'; +import type { ReviewGitHub, ReviewRuntime } from '../ports'; +import { extractReviewRequest } from './request'; + +export { getDiffFiles, getOrFetchRawDiffForCompletedJob } from './diff-cache'; + +export { budgetAwareFileLimit, estimatedSubrequestsPerFile } from './budget'; + +export { + BIN_DIFF_CHAR_BUDGET, + BIN_MAX_FILES, + BIN_TARGET_DIFF_LINES, + PACKABLE_MAX_DIFF_LINES, + narrowUnit, + planReviewUnits, + unitFiles, + type LedgerEntry, + type ReviewUnit, +} from './pack'; + +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 './phase-control'; + +import { + type PersistedReviewJob, + BUSY_RETRY_SECONDS, + FRESH_INVOCATION_YIELD_SECONDS, + JOB_LEASE_SECONDS, + MAX_FINALIZE_CONTINUATIONS, + MAX_JOB_CONTINUATIONS, + NextPhaseError, + failJobAndCheckRun, +} from './phase-control'; +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'); + } + + 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: ReviewGitHub, + 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 GitHubWebhookPayload | 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 GitHubWebhookPayload; + } + + if (!isSupportedGitHubWebhookEvent(eventName)) { + logger.info(`Queue message ignored: unsupported GitHub event ${eventName}`); + return null; + } + + const installationId = String(payload.installation?.id ?? ''); + 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.login, + repo: payload.repository.name, + }); + + if (repoConfig.enabled === false) { + logger.info(`Job ignored: repository ${payload.repository.owner.login}/${payload.repository.name} is disabled`); + return null; + } + + const extracted = extractReviewRequest({ + eventName, + payload, + botUsername: env.botUsername, + config: repoConfig.parsedJson, + }); + + if (!extracted) { + if (eventName === 'pull_request') { + const prPayload = payload as PullRequestWebhookPayload; + 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.login, + prPayload.repository.name, + prPayload.pull_request.number, + [labels.p1, labels.p2, labels.p3], + ); + } + } + return null; + } + + let resolved = extracted; + const githubClient = env.githubClients.forInstallation(installationId); + if (eventName === 'issue_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 cdd0848f..cd581467 100644 --- a/packages/core/src/review/pack.ts +++ b/packages/core/src/review/pack.ts @@ -1,113 +1,76 @@ -// Groups small files into shared model calls, the inverse of chunkFileDiff, so a 4-line file does not pay the full ~2,800-token preamble. -import { renderFileDiff } from '../prompts/file-review'; -import type { FileDiff } from '../diff'; - -// Above this a file is reviewed alone -- it already amortises its own preamble. -export const PACKABLE_MAX_DIFF_LINES = 150; -// Lowered from 400 after a 383-line bin came back with a file missing from the response entirely -// (the model ran out of room and silently dropped one) and took 77s doing it. Omission is the -// expensive failure here: the file is re-queued and reviewed again from scratch, so the calls the -// larger bin "saved" get spent back, and every file in that bin waits out the long call first. -export const BIN_TARGET_DIFF_LINES = 300; -// Blast radius and attention: per-file recall degrades before the token budget runs out. -// -// 6 -> 3 -> 2, each step on measurement rather than intuition, and the last step on the only design -// that survives contact with these models: PAIRED. The same unchanged prompt scored 36.1% and 44.9% in -// two sessions hours apart, so cross-time comparisons at this effect size are worthless -- every arm -// below ran in the same block as its own baseline, back to back, and is differenced within that block. -// -// 3 files/call -> 2 gemini-3.5-flash-lite +11.1 pts recall (SE 2.7, t=4.15, 8 blocks) -// gemini-2.5-flash +12.1 pts recall (SE 1.4, t=8.88, 4 blocks) -// -// Precision stayed at 100% on both models at bin 2, so the gain is defects that were previously never -// mentioned, not extra noise. Going further to ONE file per call did not extend the gain (+2.9 pts -// against bin 3, i.e. worse than bin 2) while tripling the call count, so 2 is the knee of the curve. -// Two things that look like they should help do NOT, once paired: raising max_comments (-2.1 pts here, -// and it costs precision on the stronger model) and adding custom repo rules (+1.4, t=0.56 -- an -// earlier unpaired sweep put this at +7.0, which was drift). -// -// The cost is paid in subrequests, not tokens per finding: each call re-sends the ~2,800-token preamble, -// and on the Workers Free 50-subrequests-per-invocation ceiling a smaller bin means more continuations -// per job, which phase-control already handles but which shows up as wall clock. -// -// The value is nonetheless set ABOVE the measured knee, at 4, trading the recall the table above -// quantifies for a quarter of the model calls that bin 2 would spend on the same diff. Bin 2 is the -// recall-optimal setting and the table stands; if the subrequest pressure that motivated 4 goes away, -// this should go back down rather than being re-derived from scratch. -export const BIN_MAX_FILES = 4; -export const BIN_DIFF_CHAR_BUDGET = 24_000; - -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; -} - -// The exact renderer the prompt uses; any other estimate drifts. -const measure = (file: FileDiff) => renderFileDiff(file).length; - -// A one-file bin is a single file with scaffolding it does not need. -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), - }); - -// Takes the FULL file list, never the remainder: bin membership isn't persisted, so resumption re-derives this plan and narrows it with narrowUnit. -export function planReviewUnits(files: readonly FileDiff[], opts: { enabled: 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); - // Both ceilings: 150 short lines and 150 minified ones are not the same prompt. - if (file.lineCount > PACKABLE_MAX_DIFF_LINES || fileChars > BIN_DIFF_CHAR_BUDGET) { - // Emitted in place, so the plan preserves input order. - 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; -} - -// Applies the ledger, returning the units still needing review. A list, because a previously failed bin de-escalates into singles rather than re-forming on every retry. -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'; + +export const PACKABLE_MAX_DIFF_LINES = 150; +export const BIN_TARGET_DIFF_LINES = 300; +export const BIN_MAX_FILES = 4; +export const BIN_DIFF_CHAR_BUDGET = 24_000; + +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; + +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 }): 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); + if (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 3fa9ff00..74512322 100644 --- a/packages/core/src/review/phase-control.ts +++ b/packages/core/src/review/phase-control.ts @@ -1,89 +1,74 @@ -import { logger } from '../logger'; -import type { PersistedReviewJob, ReviewGitHub, ReviewRuntime } from '../ports'; - -// Sibling of core/review.ts -- import from that barrel, not from here. -// THE LEAF OF THE REVIEW FAMILY: phase.ts and finalize.ts both need exports from here, so this module must import NOTHING from any other review-* sibling or import-x/no-cycle fires. - -// Re-exported so the review family keeps its single source for the job type. It resolves to -// JobSummary, which is exactly what mapJob returns; see the note on the port. -export type { PersistedReviewJob }; - -export const REVIEW_CHUNK_WALL_CLOCK_MS = 12 * 60 * 1000; -export const JOB_LEASE_SECONDS = 15 * 60; -export const BUSY_RETRY_SECONDS = 60; -// Short first: most transient failures are momentary provider load or self-inflicted connection queuing, both of which clear in seconds. -export const RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS = [30, 2 * 60, 5 * 60]; -// Must force HIBERNATE to buy a fresh 50-subrequest budget -- 2s did not, causing false "Too many subrequests" loops. Do not lower without re-checking that. -export const FRESH_INVOCATION_YIELD_SECONDS = 8; -// Poll cadence for an in-flight Workers AI async batch, bounded by MAX_JOB_CONTINUATIONS so a stuck batch cannot loop forever. -export const ASYNC_BATCH_POLL_DELAY_SECONDS = 20; -// A big bin now spends a whole invocation on ONE model (MODEL_FALLBACK_CHAIN_BUDGET_MS is only a little -// above the per-call ceiling), so this is also the ceiling on how DEEP into its fallback chain a file -// can ever get: the resume memo advances one entry per deferral. At 3 a chain longer than three models -// lost its tail no matter how healthy those entries were. Costs worst-case latency, not attempts -- -// RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS tops out at 5 minutes per deferral. -export const MAX_RETRYABLE_FILE_REVIEW_FAILURES = 6; -// Ceiling on same-phase reschedules with no file completed; any progress resets it. -export const MAX_JOB_CONTINUATIONS = 20; -// Lower than review's: finalize either fits a fresh invocation's budget or it doesn't; the check-run reconciler recovers past that. -export const MAX_FINALIZE_CONTINUATIONS = 3; - -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, -) { - // Must-not-lose write: marks the job terminal so it stops retrying, and eligible for completeTerminalCheckRuns if the GitHub call below fails. - 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; - } - - // Best-effort: the job is already durably marked failed above, and completeTerminalCheckRuns retries this later. - 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, ReviewGitHub, ReviewRuntime } from '../ports'; + + +// JobSummary, which is exactly what mapJob returns; see the note on the port. +export type { PersistedReviewJob }; + +export const REVIEW_CHUNK_WALL_CLOCK_MS = 12 * 60 * 1000; +export const JOB_LEASE_SECONDS = 15 * 60; +export const BUSY_RETRY_SECONDS = 60; +export const RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS = [30, 2 * 60, 5 * 60]; +export const FRESH_INVOCATION_YIELD_SECONDS = 8; +export const ASYNC_BATCH_POLL_DELAY_SECONDS = 20; +export const MAX_RETRYABLE_FILE_REVIEW_FAILURES = 6; +export const MAX_JOB_CONTINUATIONS = 20; +export const MAX_FINALIZE_CONTINUATIONS = 3; + +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 c84dc1a4..bf4aa40e 100644 --- a/packages/core/src/review/phase.ts +++ b/packages/core/src/review/phase.ts @@ -1,365 +1,340 @@ -import { logger } from '../logger'; -import { defaultRepoConfig, REVIEW_CONCURRENCY_LIMITS, type ParsedReviewComment, type RepoConfig } from '@codra/schema'; -import { budgetAwareFileLimit } from './budget'; -import { narrowUnit, planReviewUnits } from './pack'; -import { reviewAndPersistBin } from './bin-runner'; -import { getDiffFiles } from './diff-cache'; -import type { ReviewGitHub, ReviewModel, ReviewRuntime } from '../ports'; -import { TokenTracker } from '../token-tracker'; -import { - type PersistedReviewJob, - ASYNC_BATCH_POLL_DELAY_SECONDS, - FRESH_INVOCATION_YIELD_SECONDS, - MAX_JOB_CONTINUATIONS, - NextPhaseError, - REVIEW_CHUNK_WALL_CLOCK_MS, - enqueueJobPhase, - hasCompletedStep, - heartbeatAndCheckSuperseded, -} from './phase-control'; -import { - canInheritParentFileReview, - countsAsHandledFileReview, - isAwaitingAsyncReview, - isSubrequestBudgetError, - resolveModelProviderName, -} from './retry-policy'; -import { loadRejectedExemplars, runPreparePhase } from './prepare'; -import { persistCompletedReview, persistFailedFileReview, reviewAndPersistFile } from './file-runner'; -// Import via the core/review.ts barrel, not from here: several specs mock that specifier. - -export async function runReviewPhase( - env: ReviewRuntime, - job: PersistedReviewJob, - leaseOwner: string, - github: ReviewGitHub, - 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' }); - - // One DB read and one GitHub read with nothing between them; two in flight cannot breach the subrequest cap. - 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 configuredChunkFileLimit = REVIEW_CONCURRENCY_LIMITS[concurrencyLevel]; - // Sized against the chain: a nine-model chain costs far more per file than a one-model one. - const modelChainLength = 1 + (config.model.fallbacks?.length ?? 0); - const reviewChunkFileLimit = budgetAwareFileLimit( - tracker.remainingSafeBudget(), - configuredChunkFileLimit, - modelChainLength, - ); - 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> = []; - // Single-threaded, so ++ is safe. - let terminalProgress = 0; - let awaitingAsync = 0; - - // Bulk-copy parent reviews in one DB pass, so a fully-inheritable retry finishes in one invocation. - 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, - }); - // Mark copied files handled so the loop below skips them. - 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`); - } - } - } - - // Planned over the full file list so bins are stable across invocations (they aren't persisted), - // then narrowed to files that still need a model call. - 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 }).flatMap((unit) => narrowUnit(unit, ledger)); - const plannedBins = units.filter((unit) => unit.kind === 'bin'); - let binsDispatched = 0; - let filesDispatchedInBins = 0; - - for (const unit of plannedBins) { - // A bin is one unit (one model chain + one bulk write); counting its files would stop the chunk after a single bin. - 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 () => { - // Two statements, not `+= await …`: compound assignment reads the left side first, so concurrent bins would clobber each other. - const terminal = await reviewAndPersistBin(env, job, binFiles, pr, config, totalLineCount, model, resolveFailureModelProvider, rejectedExemplars); - 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); - // An in-flight async submission must be polled (not skipped as "handled" and not resubmitted). - const awaitingReview = existingReview && isAwaitingAsyncReview(existingReview) ? existingReview : null; - if (existingReview && countsAsHandledFileReview(existingReview) && !awaitingReview) { - continue; - } - - // `continue`, not `break`: async polls are exempt and must still be reached. - if (!awaitingReview && processedThisChunk >= reviewChunkFileLimit) { - continue; - } - - const inherited = parentReviews.get(file.path); - const reviewTask = async () => { - // (0) Poll an already-submitted async batch review. - 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); - terminalProgress += 1; - return; - } - await persistCompletedReview(env, job, file, poll.response); - terminalProgress += 1; - return; - } - - if (!inherited) { - // (1) Try the async batch queue first; on any unavailability fall back to sync review. - const submitted = await model.submitReviewBatch({ - file, - prTitle: pr.title ?? null, - prDescription: pr.body ?? null, - 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); - 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); - 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()); - // A poll is one subrequest, not a review: charging it would strand every in-flight batch. - 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); - - // Only terminal rows count as progress; a submit/poll-only chunk must not reset the counter. - if (terminalProgress > 0) { - await env.jobs.resetJobContinuationCount(job.id); - } - - // Before the throw paths on purpose: a chunk that defers is exactly when waste is highest. - // `wasted` is estimated, `usage` is billed -- see TokenTracker. Skips rising while attempts fall - // is the shape that says the cooldown gates are doing their job. - 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); - }); - - // Surface as a single error so the orchestrator reschedules instead of failing on AggregateError. - 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]); - // Exclude files awaiting async results so the job doesn't finalize with pending reviews. - 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' }); - // Finalize needs a fresh budget: TokenTracker under-reports usage, so a conditional yield let finalize die with "Too many subrequests". - await enqueueJobPhase(env, job.id, 'finalize', FRESH_INVOCATION_YIELD_SECONDS); - return; - } - - // Only in-flight batches left: poll after a delay, degrading to a partial review if they never land. - 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) { - // Cosmetic only: reviews are already persisted, so a failure must not block the next chunk. - 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))); - } - } - // Yield long enough to force hibernation, rather than accumulating subrequests in this invocation. - await enqueueJobPhase(env, job.id, 'review', FRESH_INVOCATION_YIELD_SECONDS); -} +import { logger } from '../logger'; +import { defaultRepoConfig, REVIEW_CONCURRENCY_LIMITS, type ParsedReviewComment, type RepoConfig } from '@codra/schema'; +import { budgetAwareFileLimit } from './budget'; +import { narrowUnit, planReviewUnits } from './pack'; +import { reviewAndPersistBin } from './bin-runner'; +import { getDiffFiles } from './diff-cache'; +import type { ReviewGitHub, ReviewModel, ReviewRuntime } from '../ports'; +import { TokenTracker } from '../token-tracker'; +import { + type PersistedReviewJob, + ASYNC_BATCH_POLL_DELAY_SECONDS, + FRESH_INVOCATION_YIELD_SECONDS, + MAX_JOB_CONTINUATIONS, + NextPhaseError, + REVIEW_CHUNK_WALL_CLOCK_MS, + enqueueJobPhase, + hasCompletedStep, + heartbeatAndCheckSuperseded, +} from './phase-control'; +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: ReviewGitHub, + 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 configuredChunkFileLimit = REVIEW_CONCURRENCY_LIMITS[concurrencyLevel]; + const modelChainLength = 1 + (config.model.fallbacks?.length ?? 0); + const reviewChunkFileLimit = budgetAwareFileLimit( + tracker.remainingSafeBudget(), + configuredChunkFileLimit, + modelChainLength, + ); + 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 }).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); + 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); + 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); + terminalProgress += 1; + return; + } + await persistCompletedReview(env, job, file, poll.response); + terminalProgress += 1; + return; + } + + if (!inherited) { + const submitted = await model.submitReviewBatch({ + file, + prTitle: pr.title ?? null, + prDescription: pr.body ?? null, + 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); + 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); + 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 bf65e547..82605009 100644 --- a/packages/core/src/review/prepare.ts +++ b/packages/core/src/review/prepare.ts @@ -1,80 +1,75 @@ -import { logger } from '../logger'; -import { defaultRepoConfig, type RepoConfig } from '@codra/schema'; -import type { ReviewGitHub, ReviewRuntime } from '../ports'; -import { getDiffFiles } from './diff-cache'; -import type { RejectedExemplar } from '../prompts/file-review'; -import { type PersistedReviewJob, JOB_LEASE_SECONDS, FRESH_INVOCATION_YIELD_SECONDS, enqueueJobPhase } from './phase-control'; -// Sibling of core/review.ts -- import from that barrel, not from here. - -export async function runPreparePhase( - env: ReviewRuntime, - job: PersistedReviewJob, - leaseOwner: string, - github: ReviewGitHub, -) { - 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; - - // Refresh cached PR title/author: these are snapshotted at job creation and copied onto retries, so a title edited on GitHub afterwards would otherwise stay stale. - 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) { - // Best-effort progress cosmetics only: don't let a failed check-run update block enqueuing the review phase. - 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))); - } - } - // Yield: the review phase builds a FRESH TokenTracker starting at zero, so without a hibernating delay it would share this invocation's already-spent budget and fan out into "Too many subrequests". - await enqueueJobPhase(env, job.id, 'review', FRESH_INVOCATION_YIELD_SECONDS); -} - -// Negative few-shot exemplars for this repository. Best-effort, and per chunk so it costs one query rather than one per file. -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 '@codra/schema'; +import type { ReviewGitHub, ReviewRuntime } from '../ports'; +import { getDiffFiles } from './diff-cache'; +import type { RejectedExemplar } from '../prompts/file-review'; +import { type PersistedReviewJob, JOB_LEASE_SECONDS, FRESH_INVOCATION_YIELD_SECONDS, enqueueJobPhase } from './phase-control'; + +export async function runPreparePhase( + env: ReviewRuntime, + job: PersistedReviewJob, + leaseOwner: string, + github: ReviewGitHub, +) { + 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/request.ts b/packages/core/src/review/request.ts index 637eeb86..95efac49 100644 --- a/packages/core/src/review/request.ts +++ b/packages/core/src/review/request.ts @@ -6,7 +6,6 @@ import type { } from '@codra/schema/github'; import type { RepoConfig } from '@codra/schema'; -// Pure (no env/I/O) so the webhook-to-review-request mapping stays testable in isolation. function shouldTriggerFromPullRequest(action: PullRequestWebhookPayload['action'], config: RepoConfig['review']) { return (config.on as string[]).includes(action); } diff --git a/packages/core/src/review/retry-policy.ts b/packages/core/src/review/retry-policy.ts index 62343ce9..010b5463 100644 --- a/packages/core/src/review/retry-policy.ts +++ b/packages/core/src/review/retry-policy.ts @@ -1,111 +1,101 @@ -import { logger } from '../logger'; -import { normalizeModelId, type RepoConfig } from '@codra/schema'; -import { isSubrequestBudgetMessage, isTimeoutMessage, matchesAnyTransientSubstring } from '@codra/schema/transient-errors'; -import type { ReviewRuntime } from '../ports'; -import { RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS } from './phase-control'; - -// Sibling of core/review.ts -- import from that barrel, not from here. -// Which failures are worth another attempt, how long to wait, and whether a parent job's file review can be inherited by a retry. - -export function isRetryableFileReviewErrorMessage(message: string | null | undefined) { - if (!message) return false; - const lower = message.toLowerCase(); - - // Our own markers win over the heuristics below: messages interpolate file paths, so a path like `core/timeout.ts` would otherwise decide retry behaviour by coincidence. - if (lower.includes('retrying later') || lower.includes('all configured review models failed')) { - return true; - } - - // Explicitly fail fast for timeouts so they don't loop endlessly, aligning with isTransientModelFailure. - if (isTimeoutMessage(lower)) { - return false; - } - - return ( - matchesAnyTransientSubstring(lower) || - lower.includes('google request failed with 5') || - lower.includes('temporary') || - // Older jobs may have persisted subrequest-budget failures before that became a pure chunk-level deferral; keep retrying those rows. - lower.includes('subrequest') - ); -} - -// Clears on the next invocation, so never fail the job: persist progress and reschedule the same phase. -// Delegates so the model chain in services/ classifies this identically; a second copy of the -// substring is exactly how the two layers would drift. -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); -} - -// A file still running in the Workers AI batch queue: must be polled, not retried from scratch. -export function isAwaitingAsyncReview(review: { file_status: string; async_request_id?: string | null }) { - return review.file_status === 'pending' && !!review.async_request_id; -} - -// A file review stores the bare model id; the configured strategy stores `provider:model`. Comparing bare on both sides is what lets a retry recognise an inheritable file at all. -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 '@codra/schema'; +import { isSubrequestBudgetMessage, isTimeoutMessage, matchesAnyTransientSubstring } from '@codra/schema/transient-errors'; +import type { ReviewRuntime } from '../ports'; +import { RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS } from './phase-control'; + + +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 deceec19..64e49c1d 100644 --- a/packages/core/src/review/telemetry.ts +++ b/packages/core/src/review/telemetry.ts @@ -1,86 +1,82 @@ -import { logger } from '../logger'; -import type { ReviewRuntime } from '../ports'; -import { type PersistedReviewJob } from './phase-control'; -import { bareModelId } from './retry-policy'; -// Sibling of core/review.ts -- import from that barrel, not from here. - -// Success/all-failed fields come in as `overrides`. Token/model data comes from `done` reviews only, so failed or inherited rows don't deflate totals. -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))); - } -} - -// `posted` requires both fingerprint and anchor hash to match, so an edit to the flagged line re-raises it; `rejected` suppresses on fingerprint alone. -export async function loadSuppressedFingerprints(env: Pick, jobId: string) { - const posted = new Map>(); - const rejected = new Set(); - // v2 already contains the anchor hash, so membership alone means "same file, same claim class, byte-identical line". - 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 8a3d4cac..e2ccab2c 100644 --- a/packages/core/src/rules/detect.ts +++ b/packages/core/src/rules/detect.ts @@ -5,25 +5,20 @@ import { buildAnchorHash, buildFindingFingerprint, buildFindingFingerprintV2, no import { CLAIM_TYPE_CATEGORY } from '@codra/schema'; import { RULES, type Rule } from './table'; -// Cap on added lines scanned per file: the binding constraint is the 10ms CPU budget, not memory. Reported as `truncated` rather than silently applied. const MAX_RULE_SCAN_ADDED_LINES = 600; export type RuleHit = { rule: Rule; line: DiffLine; - // Set when the rule is in shadow mode: counted and logged, never turned into a comment. shadow: boolean; }; export type RuleScanStats = { addedLinesScanned: number; - // Lines that passed the cheap substring sieve and were actually stripped + regex-tested. sievePassed: number; hits: number; shadowHits: number; - // Hits discarded because the identical line already existed as a `del` - the PR only moved it. suppressedAsMoved: number; - // Lines the stripper refused to scan (unterminated quote / unclosed block comment). unstrippable: number; truncated: boolean; byRule: Record; @@ -45,7 +40,6 @@ function ruleApplies(rule: Rule, ext: string) { return !rule.extensions || rule.extensions.includes(ext); } -// Zero subrequests and no model call: this channel still produces findings when the LLM returns nothing or the file's review fails outright. export function scanFileForRuleHits(file: FileDiff, options: RuleScanOptions = {}): RuleScanResult { const stats: RuleScanStats = { addedLinesScanned: 0, @@ -73,12 +67,10 @@ export function scanFileForRuleHits(file: FileDiff, options: RuleScanOptions = { && ruleApplies(rule, ext)); if (active.length === 0) return { hits, stats }; - // One flat sieve over every active rule's triggers: cheap substring checks reject >95% of lines before regexes run. const triggers = [...new Set(active.flatMap((rule) => rule.triggers))]; const syntax = commentSyntaxFor(file.path); for (const hunk of file.hunks) { - // Same discipline as buildPresenceIndex: collected per hunk so reformat-move suppression can compare within the same window. const removed = new Set(); for (const l of hunk.lines) { if (l.kind === 'del') removed.add(normalizeDiffText(l.content)); @@ -107,7 +99,6 @@ export function scanFileForRuleHits(file: FileDiff, options: RuleScanOptions = { if (!rule.pattern.test(stripped)) continue; if (rule.rejectRaw?.test(raw)) continue; - // The "defect" pre-existed and the PR only moved or reindented the line. if (removed.has(normalizeDiffText(raw))) { stats.suppressedAsMoved += 1; continue; @@ -118,7 +109,6 @@ export function scanFileForRuleHits(file: FileDiff, options: RuleScanOptions = { stats.byRule[rule.id] = (stats.byRule[rule.id] ?? 0) + 1; if (shadow) stats.shadowHits += 1; else stats.hits += 1; - // One hit per line: two rules firing on one line would post two comments at one anchor. break; } } @@ -128,8 +118,6 @@ export function scanFileForRuleHits(file: FileDiff, options: RuleScanOptions = { return { hits, stats }; } -// Turns rule hits into the same `ParsedReviewComment` shape the LLM channel produces, so downstream stages treat them uniformly. -// The fingerprint deliberately includes the anchor hash: a rule's title is a CONSTANT, so two hits of one rule in one file would otherwise collide on a single fingerprint identity. export function ruleHitsToComments(file: FileDiff, result: RuleScanResult): ParsedReviewComment[] { const comments: ParsedReviewComment[] = []; for (const hit of result.hits) { diff --git a/packages/core/src/rules/table.ts b/packages/core/src/rules/table.ts index af5234c5..45ba7b37 100644 --- a/packages/core/src/rules/table.ts +++ b/packages/core/src/rules/table.ts @@ -1,149 +1,133 @@ -import type { ClaimType, reviewSeverities } from '@codra/schema'; - -type ReviewSeverity = typeof reviewSeverities[number]; - -// Deterministic rules, the second finding channel: models GENERATE at F1 0.07-0.37 but TRIAGE pre-grounded candidates at 0.88-0.96, so rules propose and the model judges. -export type Rule = { - id: string; - claimType: ClaimType; - severity: ReviewSeverity; - title: string; - body: string; - // Cheap substrings: absent from the raw line, the rule is never considered. - triggers: readonly string[]; - // Runs against the stripped line. Must not backtrack catastrophically. - pattern: RegExp; - // Veto against the RAW line, where stripping destroys the evidence that clears a hit: a block comment - // becomes a space, so an intentionally-empty catch looks genuinely empty. - rejectRaw?: RegExp; - // File extensions this applies to. Empty means all. - extensions?: readonly string[]; - // Tier-2 ships disabled: reviewable code, untrusted rule. - 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*\}/, - // A documented empty catch is deliberate. Checked on the RAW line: the stripper collapses the comment - // to a space and the block looks empty. - 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'], - // Non-literal right-hand side only: the stripper removes literals, so `= ''` cannot match, `= html` can. - 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'], - // DROP COLUMN/TABLE/TRUNCATE only. Not DROP INDEX/CONSTRAINT/DEFAULT/NOT NULL: they discard no rows - // and this repo's migrations use them routinely. - pattern: /\b(?:drop\s+(?:column|table)|truncate\s+table|truncate\s+\w)/i, - extensions: ['sql'], - enabled: true, - }, - - // ── Tier 2: shipped but disabled ──────────────────────────────────────────────────────────── - - { - 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/, - // Disabled: the stripper removes literals, where credentials live, so only unquoted tokens fire. Needs a different scanning mode, not a different regex. - 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, - // Disabled: the name heuristic is the whole rule, and a test fixture or React key is a false positive. - enabled: false, - }, -]; - -// NOT SHIPPED, `sql-string-concat`: the stripper deletes literals, so a safe tagged `sql` template is indistinguishable from real concatenation. Telling them apart needs a parse, not a regex. +import type { ClaimType, reviewSeverities } from '@codra/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 3b61295e..bcd1f904 100644 --- a/packages/core/src/token-tracker.ts +++ b/packages/core/src/token-tracker.ts @@ -1,131 +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'; - -// Prompts we paid to transmit but got nothing back for. Estimated, never billed: a failed call -// returns no usageMetadata, so this is `estimatePromptTokens` output and must not be compared to a -// provider's own promptTokenCount as an equal. -// -// `estimatedInput` is a token count but must NOT be named `...Tokens`: logger.ts redacts any key -// whose name contains "token", so the field would log as [REDACTED] and the metric would be useless. -export interface WastedUsage { - attempts: number; - estimatedInput: number; - skips: number; - byReason: Record; -} - -export class TokenTracker { - private usage: Map = new Map(); - // Kept out of `usage` so estimates can never leak into billed accounting or telemetry. - private wasted = { attempts: 0, estimatedInput: 0, skips: 0 }; - private wastedByReason: Map = new Map(); - private subrequests = 0; - private readonly MAX_SUBREQUESTS = 50; - // Covers untracked Hyperdrive queries per chunk (lease heartbeats, review reads/writes, etc.) that the tracker never sees. - 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; - } - - // Subrequests left before crossing into the reserved safety margin below Cloudflare's per-invocation cap; size variable concurrent work against this instead of a fixed constant. - 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 - }); - } - - // A full prompt went over the wire and produced no reviewable response. - 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 }); - } - - // A prompt we did NOT send because a gate already knew it would fail -- the positive signal that - // cooldown learning is working, and the counterpart to recordFailedAttempt. - 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 484ca4c8..5baa5755 100644 --- a/packages/core/test/in-memory.ts +++ b/packages/core/test/in-memory.ts @@ -1,420 +1,409 @@ -// A complete in-memory ReviewRuntime: every port the engine takes, implemented with plain maps and -// canned model output. No Postgres, no Miniflare, no network, no clock. -// -// This is the demonstration that the extraction actually worked. If the engine ever regains a -// dependency on a database, a platform binding or a git provider, THIS file stops being enough to -// drive it, and the spec next door fails. - -import { defaultRepoConfig, reviewSettingsSchema, type ParsedReviewComment, type RepoConfig, type ReviewSettings } from '@codra/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, - }; -} - -// A two-file unified diff, small enough that the planner packs it into one bin. -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); - - // Advanced explicitly by tests; never reads the wall clock, so durations are deterministic. - const now = { value: 1_700_000_000_000 }; - - const settings = reviewSettingsSchema.parse({ maxFiles: 25, ...seed.settings }); - - // The engine only ever reads `status` and `check_run_id` off a row, and hands it back to mapJob. - 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, - - 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, botUsername) => `Reviewed ${commitSha.slice(0, 7)} by ${botUsername}`, - }), - - 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 }; -} - -// The engine constructs a tracker and passes it to the github/model factories, which ignore it here. -// Stands in for the real TokenTracker so the fake runtime needs no import from the engine's internals. -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 '@codra/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, + + 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, botUsername) => `Reviewed ${commitSha.slice(0, 7)} by ${botUsername}`, + }), + + 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 0a65c345..db892ab7 100644 --- a/packages/core/test/logger.spec.ts +++ b/packages/core/test/logger.spec.ts @@ -1,112 +1,104 @@ -import { describe, expect, it, vi } from 'vitest'; -import { consoleLogger, formatLogRecord, logger, redact, scrubString, setLoggerSink } from '../src/logger'; - -// Redaction had no coverage at all before the logger split, and it is load-bearing in both -// directions: src/server/core/token-tracker.ts and src/server/models/google.ts both document -// workarounds for the `token` key being redacted. These tests pin the behaviour so the move cannot -// change it silently. -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', () => { - // The predecessor check was "contains exactly two periods", which deleted file paths while - // missing real JWTs. Both halves of that regression are pinned here. - 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]', - // `token` is a substring of this key, which is exactly why the token tracker logs its counts - // under names that avoid it. - 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 new file mode 100644 index 00000000..7bee9dfb --- /dev/null +++ b/packages/core/test/redos-bounds.spec.ts @@ -0,0 +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'); + }); +}); diff --git a/packages/core/test/review-in-memory.spec.ts b/packages/core/test/review-in-memory.spec.ts index c94e5a47..aafc6a81 100644 --- a/packages/core/test/review-in-memory.spec.ts +++ b/packages/core/test/review-in-memory.spec.ts @@ -1,160 +1,142 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { runReview, type ReviewJobRunResult } from '../src'; -import { setLoggerSink } from '../src/logger'; -import { createInMemoryRuntime } from './in-memory'; - -// The acceptance criterion for extracting @codra/core: the engine runs a review end to end against -// in-memory ports alone. No Postgres, no Miniflare, no Worker, no network, no wall clock. -// -// Note what is NOT here: no vi.mock, no module interception, no test database, no fetch stub. The -// engine is driven purely through the ReviewRuntime it declares, which is the whole point. - -beforeEach(() => { - // Quiet, and it proves the Logger port is honoured rather than console being reached for directly. - 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); - - // The driver contract: two hand-offs, then an ack. - expect(results.map((r) => r.action)).toEqual(['next_phase', 'next_phase', 'ack']); - expect(results[0]).toMatchObject({ action: 'next_phase', phase: 'review' }); - // Finalize demands a fresh instance so it starts on a clean subrequest budget. - 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'); - - // Both diff files were reviewed and persisted. - expect([...recorded.fileReviews.keys()].sort()).toEqual(['src/log.ts', 'src/retry.ts']); - expect([...recorded.fileReviews.values()].every((row) => row.file_status === 'done')).toBe(true); - - // The finding reached GitHub as an inline comment. - expect(recorded.postedReviews).toHaveLength(1); - expect(recorded.postedReviews[0].comments).toEqual([ - { path: 'src/retry.ts', body: expect.stringContaining('Hard-coded delay') }, - ]); - expect(recorded.postedReviews[0].body).toContain('codra-bot'); - - // The check run was opened and closed, and telemetry was emitted exactly once. - 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'); - // One release per phase: nothing may return while still holding it. - 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']); - // The gate ran rather than being skipped, which is what makes the contrast below meaningful. - expect(kept.recorded.calls.some((call) => call === 'verifyFindings:2')).toBe(true); - - // Same input, one verdict flipped to 'drop': exactly one finding survives to the pull request. - 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); - // The dropped one is recorded with its disposition rather than silently vanishing. - 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); - - // Three phases each need the diff; only the first pays for it. This is the whole reason the - // KvStore port exists, and it is asserted here with a Map rather than a KV namespace. - 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'); - // Every file failed, so the job completes as a failure rather than a clean approval. - 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' }); - // Nothing was posted to the pull request. - 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') }, + ]); + expect(recorded.postedReviews[0].body).toContain('codra-bot'); + + 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 e47d01a9..1ed732a9 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -1,14 +1,9 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - include: ['test/**/*.spec.ts'], - environment: 'node', - // Deliberately NO setupFiles: the root test/setup.ts hard-fails when TEST_DATABASE_URL is - // unset, and this suite exists to prove the engine runs on in-memory ports with no Postgres. - // Borrowing that setup would defeat the point of it. - // globals: false to match the package tsconfig, which does not pull in vitest/globals -- specs - // here import describe/it/expect from 'vitest' explicitly. - globals: false, - }, -}); +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.spec.ts'], + environment: 'node', + globals: false, + }, +}); diff --git a/src/server/db/jobs-leases.ts b/src/server/db/jobs-leases.ts index 3421a19f..f1e9d832 100644 --- a/src/server/db/jobs-leases.ts +++ b/src/server/db/jobs-leases.ts @@ -167,11 +167,17 @@ export async function resetJobContinuationCount(env: Pick, maxRecoveryCount = 3, unleasedGraceSeconds = 300, + onlyJobIds?: readonly string[] | null, ) { + const jobIdFilter = onlyJobIds ? [...onlyJobIds] : null; + const requeued = await queryRows<{ id: string }>( env, ` @@ -190,6 +196,7 @@ export async function recoverExpiredJobLeases( ) ) AND recovery_count < $1 + AND ($3::uuid[] IS NULL OR id = ANY($3::uuid[])) ORDER BY COALESCE(lease_expires_at, last_queue_message_at, heartbeat_at, started_at, created_at) ASC LIMIT 25 FOR UPDATE SKIP LOCKED @@ -205,7 +212,7 @@ export async function recoverExpiredJobLeases( WHERE j.id = expired.id RETURNING j.id `, - [maxRecoveryCount, String(unleasedGraceSeconds)], + [maxRecoveryCount, String(unleasedGraceSeconds), jobIdFilter], ); const failed = await queryRows( @@ -226,6 +233,7 @@ export async function recoverExpiredJobLeases( ) ) AND recovery_count >= $1 + AND ($3::uuid[] IS NULL OR id = ANY($3::uuid[])) ORDER BY COALESCE(lease_expires_at, last_queue_message_at, heartbeat_at, started_at, created_at) ASC LIMIT 25 FOR UPDATE SKIP LOCKED @@ -258,7 +266,7 @@ export async function recoverExpiredJobLeases( FROM updated u JOIN repositories r ON u.repository_id = r.id `, - [maxRecoveryCount, String(unleasedGraceSeconds)], + [maxRecoveryCount, String(unleasedGraceSeconds), jobIdFilter], ); return { diff --git a/test/api/auth.spec.ts b/test/api/auth.spec.ts index f8e0c845..7bc0ca3f 100644 --- a/test/api/auth.spec.ts +++ b/test/api/auth.spec.ts @@ -14,7 +14,7 @@ import { queryRows, runWithDb } from '@server/db/client'; import { syncUpdatesEmail } from '@server/core/updates-email'; -import type { AccountResponse, AuthSessionResponse, JobsResponse, UpdatesEmailResponse } from '@codra/schema/api'; +import type { AccountResponse, JobsResponse, UpdatesEmailResponse } from '@codra/schema/api'; import { createTestEnv, dbDescribe } from '../helpers'; import { vi } from 'vitest'; @@ -213,24 +213,6 @@ describe('Dashboard API: auth, session and account', () => { } }); - it('returns 400 for malformed review settings JSON', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const response = await app.request('/api/settings', { - method: 'PATCH', - headers: { - Cookie: `codra_session=${token}`, - 'x-requested-with': 'XMLHttpRequest', - 'content-type': 'application/json', - }, - body: '{', - }, env); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ error: 'Invalid review settings.' }); - }); - it('falls back invalid stored review settings independently', async () => { const env = createTestEnv(); const token = await getAuthCookie(env); @@ -278,46 +260,6 @@ describe('Dashboard API: auth, session and account', () => { } }); - it('rejects logout without the CSRF header', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const response = await app.request('/auth/logout', { - method: 'POST', - headers: { Cookie: `codra_session=${token}` }, - }, env); - - expect(response.status).toBe(403); - }); - - it('allows logout with a valid session and CSRF header', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const response = await app.request('/auth/logout', { - method: 'POST', - headers: { - Cookie: `codra_session=${token}`, - 'x-requested-with': 'XMLHttpRequest', - }, - }, env); - - expect(response.status).toBe(200); - }); - - it('returns the authenticated GitHub session user', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const response = await app.request('/api/auth/session', { - headers: { Cookie: `codra_session=${token}` }, - }, env); - - expect(response.status).toBe(200); - const data = await response.json() as AuthSessionResponse; - expect(data.user.login).toBe('devarshishimpi'); - }); - it('persists and returns a durable account record with a unique account id', async () => { const env = createTestEnv(); // Own github_user_id: this asserts a pristine record, so it must not share a @@ -337,66 +279,34 @@ describe('Dashboard API: auth, session and account', () => { expect(data.account.id.length).toBeGreaterThan(0); }); - it('updates the editable account display name', async () => { - const env = createTestEnv(); - // Own github_user_id: this test mutates the persisted row. - const token = await getAuthCookie(env, 'devarshishimpi', 4303); - - const response = await app.request('/api/auth/account', { - method: 'PATCH', - headers: { Cookie: `codra_session=${token}`, 'content-type': 'application/json', 'x-requested-with': 'XMLHttpRequest' }, - body: JSON.stringify({ name: 'Renamed Codra User' }), - }, env); - - expect(response.status).toBe(200); - const data = await response.json() as AccountResponse; - expect(data.account.accountName).toBe('Renamed Codra User'); - expect(data.account.githubUserId).toBe(4303); - - const followUp = await app.request('/api/auth/account', { - headers: { Cookie: `codra_session=${token}` }, - }, env); - const followUpData = await followUp.json() as AccountResponse; - expect(followUpData.account.accountName).toBe('Renamed Codra User'); - }); - - it('persists a display time zone and clears it back to the default', async () => { - const env = createTestEnv(); - // Own github_user_id: this test mutates the persisted row. - const token = await getAuthCookie(env, 'devarshishimpi', 4301); - const headers = { - Cookie: `codra_session=${token}`, - 'content-type': 'application/json', - 'x-requested-with': 'XMLHttpRequest', - }; - - const set = await app.request('/api/auth/account', { - method: 'PATCH', headers, body: JSON.stringify({ timezone: 'Asia/Kolkata' }), - }, env); - expect(set.status).toBe(200); - expect(((await set.json()) as AccountResponse).account.timezone).toBe('Asia/Kolkata'); - - const read = await app.request('/api/auth/account', { headers }, env); - expect(((await read.json()) as AccountResponse).account.timezone).toBe('Asia/Kolkata'); - - // null means "follow the browser". - const cleared = await app.request('/api/auth/account', { - method: 'PATCH', headers, body: JSON.stringify({ timezone: null }), - }, env); - expect(((await cleared.json()) as AccountResponse).account.timezone).toBeNull(); - }); - - it('rejects an unknown time zone', async () => { + it.each([ + ['updates the editable account display name', { name: 'Renamed Codra User' }, 200, (data: AccountResponse) => expect(data.account.accountName).toBe('Renamed Codra User')], + ['persists a display time zone', { timezone: 'Asia/Kolkata' }, 200, (data: AccountResponse) => expect(data.account.timezone).toBe('Asia/Kolkata')], + ['clears display time zone back to default', { timezone: null }, 200, (data: AccountResponse) => expect(data.account.timezone).toBeNull()], + ['rejects an unknown time zone', { timezone: 'Mars/Olympus_Mons' }, 400, () => {}], + ['rejects an empty account name', { name: ' ' }, 400, () => {}] + ])('%s', async (name, payload, expectedStatus, assertFn) => { const env = createTestEnv(); - const token = await getAuthCookie(env); - + const token = await getAuthCookie(env, 'devarshishimpi', 4310); + const response = await app.request('/api/auth/account', { method: 'PATCH', headers: { Cookie: `codra_session=${token}`, 'content-type': 'application/json', 'x-requested-with': 'XMLHttpRequest' }, - body: JSON.stringify({ timezone: 'Mars/Olympus_Mons' }), + body: JSON.stringify(payload), }, env); - - expect(response.status).toBe(400); + + expect(response.status).toBe(expectedStatus); + + if (expectedStatus === 200) { + const data = await response.json() as AccountResponse; + assertFn(data); + + const followUp = await app.request('/api/auth/account', { + headers: { Cookie: `codra_session=${token}` }, + }, env); + const followUpData = await followUp.json() as AccountResponse; + assertFn(followUpData); + } }); it('keeps a user-set display name when the OAuth upsert runs again', async () => { @@ -420,19 +330,6 @@ describe('Dashboard API: auth, session and account', () => { expect(((await read.json()) as AccountResponse).account.accountName).toBe('My Chosen Name'); }); - it('rejects an empty account name', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const response = await app.request('/api/auth/account', { - method: 'PATCH', - headers: { Cookie: `codra_session=${token}`, 'content-type': 'application/json', 'x-requested-with': 'XMLHttpRequest' }, - body: JSON.stringify({ name: ' ' }), - }, env); - - expect(response.status).toBe(400); - }); - it('syncs an updates email only once per GitHub user', async () => { const env = createTestEnv(); const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ ok: true })); @@ -447,23 +344,6 @@ describe('Dashboard API: auth, session and account', () => { })); }); - it('returns pending updates email status before required setup email is saved', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const response = await app.request('/api/auth/updates-email', { - headers: { Cookie: `codra_session=${token}` }, - }, env); - - expect(response.status).toBe(200); - const data = await response.json() as UpdatesEmailResponse; - expect(data).toMatchObject({ - status: 'pending', - email: null, - updatedAt: null, - }); - }); - it('subscribes the user-entered updates email and persists it', async () => { const env = createTestEnv(); const token = await getAuthCookie(env); @@ -495,18 +375,6 @@ describe('Dashboard API: auth, session and account', () => { dbDescribe('review max files persistence', () => { const env = createTestEnv(); - it('round-trips through global_settings', async () => { - await runWithDb(env, async () => { - const original = await getReviewSettings(env); - try { - await updateReviewSettings(env, { ...original, maxFiles: 275 }); - expect((await getReviewSettings(env)).maxFiles).toBe(275); - } finally { - await updateReviewSettings(env, original); - } - }); - }); - // Out-of-range values are clamped into range, not replaced with the default. it('clamps an out-of-range stored value instead of falling back to the default', async () => { await runWithDb(env, async () => { diff --git a/test/api/jobs.spec.ts b/test/api/jobs.spec.ts index 83ee2b67..29c6daa1 100644 --- a/test/api/jobs.spec.ts +++ b/test/api/jobs.spec.ts @@ -1,10 +1,7 @@ import { createApp } from '@server/app'; import { getJobForProcessing, insertJob } from '@server/db/jobs'; -import { upsertFileReview } from '@server/db/file-reviews'; -import { defaultRepoConfig, reviewJobMessageSchema } from '@codra/schema'; -import type { JobDetailResponse, StatsResponse } from '@codra/schema/api'; -import { createTestEnv, uniqueName, uniqueRepo } from '../helpers'; +import { createTestEnv, uniqueName } from '../helpers'; import { vi } from 'vitest'; // `githubUserId` is parameterised so a test that mutates the persisted @@ -62,229 +59,6 @@ describe('Dashboard API: jobs, stats and queue messages', () => { return match ? match[1] : ''; } - it('returns 404 for non-existent job detail (invalid UUID)', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const response = await app.request('/api/jobs/non-existent-id', { - headers: { Cookie: `codra_session=${token}` }, - }, env); - - expect(response.status).toBe(404); - }); - - it('fetches job details accurately', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const job = await insertJob(env, { - installationId: '123', - owner: 'api-test-owner', - repo: 'api-test-repo', - prNumber: 42, - prTitle: 'API Test PR', - prAuthor: 'tester', - commitSha: 'sha123', - baseSha: 'basesha', - trigger: 'auto', - headRef: 'main', - baseRef: 'main', - configSnapshot: defaultRepoConfig, - }); - - const response = await app.request(`/api/jobs/${job.id}`, { - headers: { Cookie: `codra_session=${token}` }, - }, env); - - expect(response.status).toBe(200); - const data = await response.json() as JobDetailResponse; - expect(data.job.id).toBe(job.id); - expect(data.job.owner).toBe('api-test-owner'); - expect(data.job.prNumber).toBe(42); - expect(data.job.files).toBeDefined(); - }); - - it('fetches job details when stored comments have null code suggestions', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const job = await insertJob(env, { - installationId: '123', - owner: 'api-test-owner', - repo: uniqueRepo('api'), - prNumber: 43, - prTitle: 'Null suggestion PR', - prAuthor: 'tester', - commitSha: 'a'.repeat(40), - baseSha: 'b'.repeat(40), - trigger: 'auto', - headRef: 'feature', - baseRef: 'main', - configSnapshot: defaultRepoConfig, - }); - - await upsertFileReview(env, job.id, { - filePath: 'src/lib/slug.ts', - fileStatus: 'done', - modelUsed: 'gemma-4-31b-it', - modelProvider: 'google', - diffLineCount: 5, - diffInput: 'diff', - rawAiOutput: '{}', - parsedComments: [{ - path: 'src/lib/slug.ts', - position: 1, - severity: 'P2', - category: 'quality', - title: 'Example', - body: 'Body', - codeSuggestion: null, - }], - inputTokens: 1, - outputTokens: 1, - durationMs: 10, - verdict: 'comment', - fileSummary: 'summary', - errorMessage: null, - }); - - const response = await app.request(`/api/jobs/${job.id}`, { - headers: { Cookie: `codra_session=${token}` }, - }, env); - - expect(response.status).toBe(200); - const data = await response.json() as JobDetailResponse; - expect(data.job.files[0].parsedComments[0].codeSuggestion).toBeNull(); - }); - - // Regression: `fingerprint_v2` was written on every insert but omitted from this projection - // alone, so the dashboard never saw it even though suppression and gold-set labels key on it. - it('returns both fingerprints on job detail comments', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const job = await insertJob(env, { - installationId: '123', - owner: 'api-test-owner', - repo: uniqueRepo('api'), - prNumber: 44, - prTitle: 'Fingerprint PR', - prAuthor: 'tester', - commitSha: 'c'.repeat(40), - baseSha: 'd'.repeat(40), - trigger: 'auto', - headRef: 'feature', - baseRef: 'main', - configSnapshot: defaultRepoConfig, - }); - - await upsertFileReview(env, job.id, { - filePath: 'src/lib/slug.ts', - fileStatus: 'done', - modelUsed: 'gemma-4-31b-it', - modelProvider: 'google', - diffLineCount: 5, - diffInput: 'diff', - rawAiOutput: '{}', - parsedComments: [{ - path: 'src/lib/slug.ts', - position: 1, - severity: 'P2', - category: 'quality', - title: 'Example', - body: 'Body', - codeSuggestion: null, - fingerprint: 'abc12345', - fingerprintV2: 'def67890', - anchorHash: 'anchor01', - claimType: 'swallowed_error', - contextSnippet: 'try {} catch {}', - }], - inputTokens: 1, - outputTokens: 1, - durationMs: 10, - verdict: 'comment', - fileSummary: 'summary', - errorMessage: null, - }); - - const response = await app.request(`/api/jobs/${job.id}`, { - headers: { Cookie: `codra_session=${token}` }, - }, env); - - expect(response.status).toBe(200); - const data = await response.json() as JobDetailResponse; - const comment = data.job.files[0].parsedComments[0]; - expect(comment.fingerprint).toBe('abc12345'); - expect(comment.fingerprintV2).toBe('def67890'); - expect(comment.anchorHash).toBe('anchor01'); - expect(comment.claimType).toBe('swallowed_error'); - expect(comment.contextSnippet).toBe('try {} catch {}'); - }); - - it('returns stats successfully', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const response = await app.request('/api/stats', { - headers: { Cookie: `codra_session=${token}` }, - }, env); - - expect(response.status).toBe(200); - const data = await response.json() as StatsResponse; - expect(data.stats).toHaveProperty('totals'); - expect(data.stats).toHaveProperty('trend'); - expect(data.stats).toHaveProperty('topRepos'); - }); - - it('stops an ongoing job: marks it cancelled and terminates the workflow', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - const job = await insertJob(env, { - installationId: '123', owner: 'api-test-owner', repo: uniqueName('stop'), prNumber: 1, - prTitle: 'Stop', prAuthor: 'author', commitSha: 'a'.repeat(40), baseSha: 'b'.repeat(40), - trigger: 'auto', headRef: 'feature', baseRef: 'main', - }); - - const response = await app.request(`/api/jobs/${job.id}/stop`, { - method: 'POST', - headers: { Cookie: `codra_session=${token}`, 'x-requested-with': 'XMLHttpRequest' }, - }, env); - - expect(response.status).toBe(200); - const body = await response.json() as { job: { status: string } }; - expect(body.job.status).toBe('cancelled'); - expect((env.REVIEW_WORKFLOW as any).terminated).toContain(job.id); - - const row = await getJobForProcessing(env, job.id); - expect(row?.status).toBe('cancelled'); - - // Stopping an already-terminal job is a 409. - const second = await app.request(`/api/jobs/${job.id}/stop`, { - method: 'POST', - headers: { Cookie: `codra_session=${token}`, 'x-requested-with': 'XMLHttpRequest' }, - }, env); - expect(second.status).toBe(409); - }); - - it('deletes a job (and it is gone afterwards)', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - const job = await insertJob(env, { - installationId: '123', owner: 'api-test-owner', repo: uniqueName('delete'), prNumber: 1, - prTitle: 'Delete', prAuthor: 'author', commitSha: 'a'.repeat(40), baseSha: 'b'.repeat(40), - trigger: 'auto', headRef: 'feature', baseRef: 'main', - }); - - const response = await app.request(`/api/jobs/${job.id}`, { - method: 'DELETE', - headers: { Cookie: `codra_session=${token}`, 'x-requested-with': 'XMLHttpRequest' }, - }, env); - - expect(response.status).toBe(204); - expect(await getJobForProcessing(env, job.id)).toBeNull(); - }); - 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); @@ -307,27 +81,4 @@ describe('Dashboard API: jobs, stats and queue messages', () => { expect(fresh?.retry_of_job_id ?? null).toBeNull(); }); - it('accepts legacy jobId-only queue messages during schema transition', () => { - const parsed = reviewJobMessageSchema.safeParse({ - jobId: crypto.randomUUID(), - deliveryId: 'legacy-delivery', - installationId: '123', - owner: 'api-test-owner', - repo: 'api-test-repo', - prNumber: 42, - commitSha: 'abc123', - trigger: 'auto', - }); - - expect(parsed.success).toBe(true); - }); - - it('accepts unsupported webhook events so old queue messages can be drained', () => { - const parsed = reviewJobMessageSchema.safeParse({ - deliveryId: 'bad-event-delivery', - eventName: 'check_suite', - }); - - expect(parsed.success).toBe(true); - }); }); diff --git a/test/api/models.spec.ts b/test/api/models.spec.ts deleted file mode 100644 index aa76cb07..00000000 --- a/test/api/models.spec.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { createApp } from '@server/app'; - -import type { ModelConfigsResponse } from '@codra/schema/api'; -import { createTestEnv, saveTestProviderApiKey, uniqueName } from '../helpers'; -import { vi } from 'vitest'; - -// `githubUserId` is parameterised so a test that mutates the persisted -// account_settings row (display name, timezone) can use its own id and not leak -// into tests asserting a pristine record; the tests share one database. -function mockGitHubProfile(login = 'devarshishimpi', githubUserId = 42) { - return { - id: githubUserId, - login, - name: 'Devarshi Shimpi', - avatar_url: `https://avatars.githubusercontent.com/u/${githubUserId}`, - email: null, - }; -} - -describe('Dashboard API: model and provider configuration', () => { - const app = createApp(); - - beforeEach(() => { - vi.restoreAllMocks(); - }); - - async function getAuthCookie(env = createTestEnv(), login = 'devarshishimpi', githubUserId = 42) { - const originalFetch = globalThis.fetch; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { - const url = String(input); - - if (url === 'https://github.com/login/oauth/access_token') { - return Response.json({ access_token: 'oauth-access-token' }); - } - - if (url === 'https://api.github.com/user') { - return Response.json(mockGitHubProfile(login, githubUserId)); - } - - return originalFetch(input, init); - }); - - const authStart = await app.request('/auth/github', {}, env); - const authLocation = authStart.headers.get('location'); - expect(authStart.status).toBe(302); - expect(authLocation).toBeTruthy(); - - const state = authLocation ? new URL(authLocation).searchParams.get('state') : null; - expect(state).toBeTruthy(); - - const callback = await app.request(`/auth/github/callback?code=test-code&state=${state}`, {}, env); - const cookieHeader = callback.headers.get('set-cookie') || ''; - const match = cookieHeader.match(/codra_session=([^;]+)/); - - expect(callback.status).toBe(302); - expect(callback.headers.get('location')).toBe('/dashboard'); - - return match ? match[1] : ''; - } - - it('rejects invalid model config writes', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const response = await app.request('/api/models/gemma-4-31b-it', { - method: 'POST', - headers: { - Cookie: `codra_session=${token}`, - 'x-requested-with': 'XMLHttpRequest', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - providerId: 'not-a-uuid', - provider: 'unknown', - }), - }, env); - - expect(response.status).toBe(400); - }); - - it('returns model configs without refreshing remote provider catalogs', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - await saveTestProviderApiKey(env); - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('unexpected catalog fetch')); - fetchSpy.mockClear(); - - const response = await app.request('/api/models', { - headers: { Cookie: `codra_session=${token}` }, - }, env); - - expect(response.status).toBe(200); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it('rejects enabling non-Cloudflare providers without a saved API key', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const createResponse = await app.request('/api/models/providers', { - method: 'POST', - headers: { - Cookie: `codra_session=${token}`, - 'x-requested-with': 'XMLHttpRequest', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - name: 'No Key Provider', - apiFormat: 'openai', - baseUrl: 'https://api.example.com/v1', - enabled: true, - }), - }, env); - expect(createResponse.status).toBe(400); - - const disabledCreateResponse = await app.request('/api/models/providers', { - method: 'POST', - headers: { - Cookie: `codra_session=${token}`, - 'x-requested-with': 'XMLHttpRequest', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - name: uniqueName('Disabled No Key Provider'), - apiFormat: 'openai', - baseUrl: 'https://api.example.com/v1', - enabled: false, - }), - }, env); - expect(disabledCreateResponse.status).toBe(201); - const { provider } = await disabledCreateResponse.json() as { provider: { id: string; name: string; apiFormat: string; baseUrl: string } }; - - const updateResponse = await app.request(`/api/models/providers/${provider.id}`, { - method: 'PATCH', - headers: { - Cookie: `codra_session=${token}`, - 'x-requested-with': 'XMLHttpRequest', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - name: provider.name, - apiFormat: provider.apiFormat, - baseUrl: provider.baseUrl, - enabled: true, - }), - }, env); - expect(updateResponse.status).toBe(400); - }); - - it('refreshes provider model catalogs on the explicit sync endpoint', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - await saveTestProviderApiKey(env); - const discoveredModelName = uniqueName('test-discovered'); - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - if (url.includes('/ai/models/search')) { - return Response.json({ - success: false, - errors: [{ code: 10000, message: 'Authentication error' }], - messages: [], - result: null, - }, { status: 403 }); - } - return Response.json({ - models: [ - { - name: `models/${discoveredModelName}`, - supportedGenerationMethods: ['generateContent'], - }, - ], - }); - }); - - const response = await app.request('/api/models/sync', { - method: 'POST', - headers: { - Cookie: `codra_session=${token}`, - 'x-requested-with': 'XMLHttpRequest', - 'content-type': 'application/json', - }, - }, env); - - expect(response.status).toBe(200); - const data = await response.json() as ModelConfigsResponse; - const discoveredGoogleModel = data.configs.find(config => config.modelName === discoveredModelName); - expect(discoveredGoogleModel).toMatchObject({ modelName: discoveredModelName, apiFormat: 'gemini' }); - expect(data.configs.some(config => config.providerName === 'Cloudflare' && config.modelName === '@cf/openai/gpt-oss-120b')).toBe(true); - expect(data.syncErrors).toEqual([]); - }); - - it('tests models whose ids contain URL path separators', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - const modelId = '@cf/zai-org/glm-4.7-flash'; - - const response = await app.request(`/api/models/${encodeURIComponent(modelId)}/test`, { - method: 'POST', - headers: { - Cookie: `codra_session=${token}`, - 'x-requested-with': 'XMLHttpRequest', - }, - }, env); - - expect(response.status).toBe(200); - const data = await response.json() as { modelUsed: string; provider: string }; - expect(data.modelUsed).toBe(modelId); - expect(data.provider).toBe('Cloudflare'); - }); - - it('returns provider status codes for model test failures', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - await saveTestProviderApiKey(env); - vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ - error: { - code: 429, - message: 'Quota exceeded. Please retry later.', - status: 'RESOURCE_EXHAUSTED', - }, - }, { status: 429 })); - - // Must be an id saveTestProviderApiKey seeds (GOOGLE_TEST_MODEL_IDS), or /test 404s before reaching the provider. - const response = await app.request('/api/models/gemini-3.1-pro-preview/test', { - method: 'POST', - headers: { - Cookie: `codra_session=${token}`, - 'x-requested-with': 'XMLHttpRequest', - }, - }, env); - - expect(response.status).toBe(429); - const data = await response.json() as { error: string }; - expect(data.error).toContain('Quota exceeded'); - expect(data.error).not.toContain('"details"'); - }); - - it('reports local Cloudflare Workers AI binding limitations clearly', async () => { - const env = createTestEnv({ - AI: { - async run() { - throw new Error('Binding AI needs to be run remotely'); - }, - } as any, - }); - const token = await getAuthCookie(env); - - const response = await app.request(`/api/models/${encodeURIComponent('@cf/zai-org/glm-4.7-flash')}/test`, { - method: 'POST', - headers: { - Cookie: `codra_session=${token}`, - 'x-requested-with': 'XMLHttpRequest', - }, - }, env); - - expect(response.status).toBe(400); - const data = await response.json() as { error: string }; - expect(data.error).toContain('Cloudflare Workers AI is not available in local Wrangler'); - }); - - it('maps upstream provider server errors to bad gateway after retry', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - await saveTestProviderApiKey(env); - const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ - error: { - code: 500, - message: 'Internal error encountered.', - }, - }, { status: 500 })); - fetchMock.mockClear(); - - const response = await app.request('/api/models/gemini-3.1-pro-preview/test', { - method: 'POST', - headers: { - Cookie: `codra_session=${token}`, - 'x-requested-with': 'XMLHttpRequest', - }, - }, env); - - expect(response.status).toBe(502); - // GEMINI_MAX_RETRIES = 2, so a persistent 5xx is attempted 3 times before giving up. - expect(fetchMock).toHaveBeenCalledTimes(3); - const data = await response.json() as { error: string }; - expect(data.error).toContain('Internal error encountered.'); - }); - - it('rejects invalid global model config writes', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const response = await app.request('/api/models/global', { - method: 'PATCH', - headers: { - Cookie: `codra_session=${token}`, - 'x-requested-with': 'XMLHttpRequest', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - main: 'gemma-4-31b-it', - fallbacks: 'not-an-array', - size_overrides: {}, - }), - }, env); - - expect(response.status).toBe(400); - }); - - it('rejects unknown fields in global model config writes', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const response = await app.request('/api/models/global', { - method: 'PATCH', - headers: { - Cookie: `codra_session=${token}`, - 'x-requested-with': 'XMLHttpRequest', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - main: 'gemma-4-31b-it', - fallbacks: [], - unexpected: true, - }), - }, env); - - expect(response.status).toBe(400); - }); - - // Guards route registration order: if `/:id` were ever registered before `/providers`, this - // request would match it with id === 'providers' (modelIdSchema accepts any non-empty string) - // and hit updateModelConfig instead of provider creation. - it('routes POST /providers to provider creation, not the /:id model-config handler', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const response = await app.request('/api/models/providers', { - method: 'POST', - headers: { - Cookie: `codra_session=${token}`, - 'x-requested-with': 'XMLHttpRequest', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - name: uniqueName('Route Order Provider'), - apiFormat: 'openai', - baseUrl: 'https://api.example.com/v1', - enabled: false, - }), - }, env); - - expect(response.status).toBe(201); - const body = await response.json() as { provider?: { id: string; apiFormat: string } }; - expect(body.provider?.apiFormat).toBe('openai'); - }); -}); diff --git a/test/api/repos.spec.ts b/test/api/repos.spec.ts index 6bac2bdc..3914d8e5 100644 --- a/test/api/repos.spec.ts +++ b/test/api/repos.spec.ts @@ -6,7 +6,6 @@ import { loadRepoConfig, updateGlobalConfig } from '@server/core/config'; import { GitHubClient } from '@server/core/github'; import { defaultRepoConfig } from '@codra/schema'; -import type { RepoConfigsResponse } from '@codra/schema/api'; import { createTestEnv, uniqueName } from '../helpers'; import { vi } from 'vitest'; @@ -65,86 +64,6 @@ describe('Dashboard API: repositories and repo config', () => { return match ? match[1] : ''; } - it('returns repository list', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - - const response = await app.request('/api/repos', { - headers: { Cookie: `codra_session=${token}` }, - }, env); - - expect(response.status).toBe(200); - const data = await response.json() as RepoConfigsResponse; - expect(Array.isArray(data.repos)).toBe(true); - }); - - it('redirects Manage Access to the configured GitHub App install page', async () => { - const env = createTestEnv({ GITHUB_APP_SLUG: 'my-codra-install' }); - const token = await getAuthCookie(env); - - const response = await app.request('/api/repos/install', { - headers: { Cookie: `codra_session=${token}` }, - }, env); - - expect(response.status).toBe(302); - expect(response.headers.get('location')).toBe('https://github.com/apps/my-codra-install/installations/new'); - }); - - it('rejects invalid repository config patches', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - const repo = uniqueName('invalid-config'); - - await loadRepoConfig(env, { - installationId: '123', - owner: 'api-test-owner', - repo, - }); - - const response = await app.request(`/api/repos/api-test-owner/${repo}/config`, { - method: 'PATCH', - headers: { - Cookie: `codra_session=${token}`, - 'x-requested-with': 'XMLHttpRequest', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - review: { - // Below reviewConfigSchema's minimum of 1. - max_comments: 0, - }, - }), - }, env); - - expect(response.status).toBe(400); - }); - - it('rejects string booleans in repository config patches', async () => { - const env = createTestEnv(); - const token = await getAuthCookie(env); - const repo = uniqueName('invalid-enabled'); - - await loadRepoConfig(env, { - installationId: '123', - owner: 'api-test-owner', - repo, - }); - - const response = await app.request(`/api/repos/api-test-owner/${repo}/config`, { - method: 'PATCH', - headers: { - Cookie: `codra_session=${token}`, - 'x-requested-with': 'XMLHttpRequest', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - enabled: 'false', - }), - }, env); - - expect(response.status).toBe(400); - }); - it('preserves path separators when fetching nested GitHub contents', async () => { const env = createTestEnv(); await env.APP_KV.put('install:123', JSON.stringify({ diff --git a/test/diff.spec.ts b/test/diff.spec.ts index 211997db..c4935e98 100644 --- a/test/diff.spec.ts +++ b/test/diff.spec.ts @@ -41,33 +41,6 @@ rename to new-name.ts expect(file.previousPath).toBe('old-name.ts'); }); - it('identifies new file creations', () => { - const newFileDiff = `diff --git a/new.ts b/new.ts -new file mode 100644 -index 0000000..1234567 ---- /dev/null -+++ b/new.ts -@@ -0,0 +1,1 @@ -+console.log("hello"); -`; - const [file] = parseUnifiedDiff(newFileDiff); - expect(file.isNew).toBe(true); - expect(file.path).toBe('new.ts'); - }); - - it('identifies deleted files', () => { - const deleteDiff = `diff --git a/old.ts b/old.ts -deleted file mode 100644 -index 1234567..0000000 ---- a/old.ts -+++ /dev/null -@@ -1,1 +0,0 @@ --console.log("bye"); -`; - const [file] = parseUnifiedDiff(deleteDiff); - expect(file.isDeleted).toBe(true); - }); - it('gracefully skips binary files', () => { const binaryDiff = `diff --git a/image.png b/image.png index 1234567..890abcd 100644 @@ -77,18 +50,6 @@ Binary files a/image.png and b/image.png differ expect(file.isBinary).toBe(true); expect(file.path).toBe('image.png'); }); - - it('handles malformed hunk headers without crashing', () => { - const malformedDiff = `diff --git a/broken.ts b/broken.ts ---- a/broken.ts -+++ b/broken.ts -@@ invalid hunk header @@ -+broken -`; - const files = parseUnifiedDiff(malformedDiff); - expect(files).toHaveLength(1); - expect(files[0].hunks).toHaveLength(0); - }); }); describe('truncateFileDiff', () => { @@ -113,25 +74,7 @@ Binary files a/image.png and b/image.png differ expect(truncated.lineCount).toBe(60); }); - it('slices a single oversized hunk to the line limit', () => { - const largeFile = { - path: 'large.ts', - previousPath: null, - isNew: false, - isDeleted: false, - isBinary: false, - lineCount: 500, - hunks: [ - { header: '@@ -1,500 +1,500 @@', lines: Array(500).fill({ kind: 'add', content: 'line', position: 1 }) }, - ], - } as any; - const truncated = truncateFileDiff(largeFile, 300); - expect(truncated.isTruncated).toBe(true); - expect(truncated.hunks).toHaveLength(1); - expect(truncated.hunks[0].lines).toHaveLength(300); - expect(truncated.lineCount).toBe(300); - }); }); // chunkFileDiff decides how much of a large file any model ever sees; a silent partition bug means @@ -161,13 +104,6 @@ Binary files a/image.png and b/image.png differ const linesOf = (files: any[]) => files.flatMap((f) => f.hunks.flatMap((h: any) => h.lines)); - it('returns the file untouched when it fits, without marking it truncated', () => { - const file = fileOf([40]); - const chunks = chunkFileDiff(file, 100); - expect(chunks).toHaveLength(1); - expect(chunks[0]).toBe(file); - expect(chunks[0].isTruncated).toBeUndefined(); - }); it('partitions every line exactly once, in order, across chunk boundaries', () => { const file = fileOf([50, 50, 50]); @@ -178,15 +114,6 @@ Binary files a/image.png and b/image.png differ expect(partitioned.map((l) => l.content)).toEqual(linesOf([file]).map((l) => l.content)); }); - it('splits a single hunk larger than the cap, keeping the hunk header on both halves', () => { - // Without the header the model cannot resolve line numbers for the second half's findings. - const chunks = chunkFileDiff(fileOf([100]), 40); - expect(chunks).toHaveLength(3); - for (const chunk of chunks) { - expect(chunk.hunks.every((h: any) => h.header === '@@ hunk0 @@')).toBe(true); - } - expect(chunks.map((c) => c.lineCount)).toEqual([40, 40, 20]); - }); it('never exceeds the cap, and reports lineCount that matches the lines actually carried', () => { const chunks = chunkFileDiff(fileOf([13, 71, 5, 44]), 30); @@ -198,14 +125,6 @@ Binary files a/image.png and b/image.png differ } }); - it('carries the original line count on every chunk so truncation is reportable', () => { - const chunks = chunkFileDiff(fileOf([90]), 25); - expect(chunks.length).toBeGreaterThan(1); - for (const chunk of chunks) { - expect(chunk.originalLineCount).toBe(90); - expect(chunk.isTruncated).toBe(true); - } - }); // The MAX_CHUNKS cap in reviewFile is what makes this the load-bearing number: at 800 lines/chunk, // 4 chunks meant any file over 3,200 diff lines was silently cut off. src/server/core/review.ts @@ -245,58 +164,21 @@ Binary files a/image.png and b/image.png differ expect(filtered.skipped).toBe(15); }); - it('does not count files excluded by skip patterns as skipped-for-limit', () => { - const files = [ - { path: 'src/main.ts', isDeleted: false, isBinary: false, isNew: false, hunks: [] }, - { path: 'dist/bundle.js', isDeleted: false, isBinary: false, isNew: false, hunks: [] }, - ] as any; - - const filtered = filterReviewableFiles(files, defaultRepoConfig.review, 150); - expect(filtered.files).toHaveLength(1); - expect(filtered.skipped).toBe(0); - }); }); }); describe('diff --git header paths', () => { const header = (line: string) => parseDiffHeaderPath(line); - it('reads an ordinary path', () => { - expect(header('diff --git a/src/app.ts b/src/app.ts')).toBe('src/app.ts'); - }); - - // Git does not quote spaces in this header. Splitting on the last space produced `file.ts`, a path - // not in the PR, so GitHub rejected the whole review with a 422 and every inline comment was lost. - it('reads a path containing spaces', () => { - expect(header('diff --git a/src/my file.ts b/src/my file.ts')).toBe('src/my file.ts'); - expect(header('diff --git a/docs/release notes.md b/docs/release notes.md')).toBe('docs/release notes.md'); - }); - - // Two such files sharing a last token collapsed to one path, desynchronising the file count from - // the review count and wedging the job in a review -> finalize loop. - it('keeps two space-named files distinct', () => { - expect(header('diff --git a/docs/release notes.md b/docs/release notes.md')) - .not.toBe(header('diff --git a/spec/api notes.md b/spec/api notes.md')); - }); - it('takes the b-side on a rename', () => { - expect(header('diff --git a/old name.ts b/new name.ts')).toBe('new name.ts'); + it.each([ + ['reads a path containing spaces', 'diff --git a/src/my file.ts b/src/my file.ts', 'src/my file.ts'], + ['reads a path containing spaces', 'diff --git a/docs/release notes.md b/docs/release notes.md', 'docs/release notes.md'], + ['reads an ordinary path', 'diff --git a/src/index.ts b/src/index.ts', 'src/index.ts'], + ['reads a renamed path', 'diff --git a/old b/new', 'new'], + ['reads a path containing a literal b/ segment', 'diff --git a/a b/b b/a b/b', 'a b/b'] + ])('%s', (name, input, expected) => { + expect(header(input)).toBe(expected); }); - // The symmetric split resolves even a filename that itself contains " b/". - it('reads a path containing a literal b/ segment', () => { - expect(header('diff --git a/a b/b b/a b/b')).toBe('a b/b'); - }); - - it('parses a full diff with a spaced path end to end', () => { - const [file] = parseUnifiedDiff([ - 'diff --git a/src/my file.ts b/src/my file.ts', - '--- a/src/my file.ts', - '+++ b/src/my file.ts', - '@@ -0,0 +1 @@', - '+console.log(1);', - ].join('\n')); - expect(file.path).toBe('src/my file.ts'); - expect(file.hunks[0].lines[0].content).toBe('console.log(1);'); - }); }); diff --git a/test/e2e/batch-grouping.spec.ts b/test/e2e/batch-grouping.spec.ts deleted file mode 100644 index 9338f04f..00000000 --- a/test/e2e/batch-grouping.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { groupBatches } from '@client/lib/batch-groups'; -import type { FileReviewRecord } from '@codra/schema'; - -// Which files shared a model call is NOT stored -- pack.ts derives bins and never persists them. -// The logs view reconstructs them from the shared response body, so these pin that reconstruction. -function row(filePath: string, over: Partial = {}): FileReviewRecord { - return { - id: `id-${filePath}`, - jobId: 'job', - filePath, - fileStatus: 'done', - modelUsed: 'gemini-2.5-flash', - diffLineCount: 10, - diffInput: null, - rawAiOutput: null, - parsedComments: [], - inputTokens: 100, - outputTokens: 10, - durationMs: 1000, - verdict: 'comment', - fileSummary: 'ok', - errorMessage: null, - createdAt: new Date().toISOString(), - ...over, - } as FileReviewRecord; -} - -describe('groupBatches', () => { - it('groups the files that shared one model call, and numbers the batches in file order', () => { - const binA = '{"files":[{"absolute_file_path":"a.ts"},{"absolute_file_path":"b.ts"}]}'; - const binB = '{"files":[{"absolute_file_path":"c.ts"},{"absolute_file_path":"d.ts"}]}'; - const groups = groupBatches([ - row('a.ts', { batchSize: 2, rawAiOutput: binA }), - row('c.ts', { batchSize: 2, rawAiOutput: binB }), - row('b.ts', { batchSize: 2, rawAiOutput: binA }), - row('d.ts', { batchSize: 2, rawAiOutput: binB }), - ]); - - expect(groups.get('a.ts')!.paths.sort()).toEqual(['a.ts', 'b.ts']); - expect(groups.get('b.ts')!.index).toBe(groups.get('a.ts')!.index); - expect(groups.get('c.ts')!.index).not.toBe(groups.get('a.ts')!.index); - // Numbered by first appearance, so the labels read top-to-bottom down the list. - expect(groups.get('a.ts')!.index).toBe(1); - expect(groups.get('c.ts')!.index).toBe(2); - }); - - it('leaves solo, pre-batching and response-less rows ungrouped', () => { - const groups = groupBatches([ - // Reviewed alone. - row('solo.ts', { batchSize: 1, rawAiOutput: '{"findings":[]}' }), - // Row written before batching existed. - row('old.ts', { batchSize: null, rawAiOutput: '{"findings":[]}' }), - // Deferred/failed bin member: no response to group on, so it must not invent a batch. - row('failed.ts', { batchSize: 4, rawAiOutput: null, fileStatus: 'failed' }), - ]); - - expect(groups.size).toBe(0); - }); -}); diff --git a/test/findings/claim-types.spec.ts b/test/findings/claim-types.spec.ts index 6ae6ef09..7ee7a1e8 100644 --- a/test/findings/claim-types.spec.ts +++ b/test/findings/claim-types.spec.ts @@ -1,14 +1,10 @@ import { describe, expect, it } from 'vitest'; import { parseFileReviewResponse } from '@server/core/model-output'; -import { buildFindingFingerprint } from '@server/core/fingerprint'; import { - CLAIM_TYPE_CATEGORY, CLAIM_TYPE_DECIDABILITY, DEFAULT_DENIED_CLAIM_TYPES, claimTypes, - toClaimType, } from '@codra/schema'; -import { buildReviewResponseSchema, fileReviewSystemPromptBase } from '@server/prompts/file-review'; import type { FileDiff } from '@server/core/diff'; import { reviewJson } from '../mocks/fixtures'; @@ -45,17 +41,20 @@ describe('claim types', () => { }); // A Zod rejection here would discard every finding in the file over one bad label. - it('coerces an unknown or missing claim type to other rather than throwing', () => { - for (const value of ['not_a_real_type', '', undefined, 42]) { - const raw = review({ - claim_type: value, - evidence: 'server.listen(timeout);', - code_location: { absolute_file_path: 'src/app.ts', line: 2 }, - }); - const result = parseFileReviewResponse(raw, file); - expect(result.comments).toHaveLength(1); - expect(result.comments[0].claimType).toBe('other'); - } + it.each([ + ['not_a_real_type'], + [''], + [undefined], + [42] + ])('coerces an unknown or missing claim type (%s) to other rather than throwing', (value) => { + const raw = review({ + claim_type: value, + evidence: 'server.listen(timeout);', + code_location: { absolute_file_path: 'src/app.ts', line: 2 }, + }); + const result = parseFileReviewResponse(raw, file); + expect(result.comments).toHaveLength(1); + expect(result.comments[0].claimType).toBe('other'); }); // category was hardcoded to 'quality' on all 705 rows in production, making the per-category @@ -70,19 +69,6 @@ describe('claim types', () => { const result = parseFileReviewResponse(raw, file); expect(result.comments[0].category).toBe('security'); }); - - it('maps every claim type to a category', () => { - for (const type of claimTypes) { - expect(CLAIM_TYPE_CATEGORY[type]).toBeDefined(); - } - expect(toClaimType('other')).toBe('other'); - }); - - it('classifies every claim type for decidability', () => { - for (const type of claimTypes) { - expect(CLAIM_TYPE_DECIDABILITY[type]).toBeDefined(); - } - }); }); describe('claim type denylist', () => { @@ -109,170 +95,11 @@ describe('claim type denylist', () => { expect(result.claimTypeCounts.redos_regex).toBe(1); }); - it('keeps the same claim when the type is not denied', () => { - const result = parseFileReviewResponse(denied(), file, { deniedClaimTypes: [] }); - expect(result.comments).toHaveLength(1); - }); - - // Enforcement is invisible to the model precisely so it has no reason to relabel -- but a model can - // reach for 'other' unprompted, which would launder a denied claim into the allowed bucket. - it('repairs an other-labelled claim whose text is unmistakably a denied class', () => { - const raw = review({ - claim_type: 'other', - title: 'Effect re-runs on every render', - body: 'The dependency array omits `id`, so this effect runs on every render.', - evidence: 'server.listen(timeout);', - code_location: { absolute_file_path: 'src/app.ts', line: 2 }, - }); - - const result = parseFileReviewResponse(raw, file, { deniedClaimTypes: ['react_hook_missing_deps'] }); - expect(result.comments).toHaveLength(0); - expect(result.deniedClaimCounts.react_hook_missing_deps).toBe(1); - }); - - // The counterpart risk: repair must not drag legitimate 'other' findings into a denied bucket. - it('leaves a generic other finding alone', () => { - const raw = review({ - claim_type: 'other', - title: 'Loading guard is bypassed', - body: 'When the render prop is used the loading state is never checked.', - evidence: 'server.listen(timeout);', - code_location: { absolute_file_path: 'src/app.ts', line: 2 }, - }); - - const result = parseFileReviewResponse(raw, file, { deniedClaimTypes: [...DEFAULT_DENIED_CLAIM_TYPES] }); - expect(result.comments).toHaveLength(1); - expect(result.comments[0].claimType).toBe('other'); - }); - - // Anti-laundering guard: a narrowed enum would let the model relabel denied claims as 'other' and - // walk them through the allowed bucket while destroying the per-type measurement. - it('still advertises every claim type to the model', () => { - const schema = buildReviewResponseSchema(10) as unknown as { - schema: { properties: { findings: { items: { properties: { claim_type: { enum: string[] } } } } } }; - }; - - expect(schema.schema.properties.findings.items.properties.claim_type.enum) - .toEqual([...claimTypes]); - for (const type of claimTypes) { - expect(fileReviewSystemPromptBase).toContain(type); - } - }); - // Measured on PR #55: 3 generated, 0 valid. It was held out pending exactly that data. - it('denies null_or_undefined_deref now that it has been measured', () => { - expect(DEFAULT_DENIED_CLAIM_TYPES).toContain('null_or_undefined_deref'); - expect(DEFAULT_DENIED_CLAIM_TYPES).toContain('react_hook_missing_deps'); - }); - - it('denies every claim type that is not decidable from the diff', () => { - for (const type of claimTypes) { - const denied = DEFAULT_DENIED_CLAIM_TYPES.includes(type); - expect(denied).toBe(CLAIM_TYPE_DECIDABILITY[type] !== 'diff_local'); - } - }); -}); - -// The worst-performing family in the corpus: 21 generated, 4 posted, all four wrong, mean confidence -// 0.964 -- then two P0s asserting actions/checkout v7 "does not exist" while the CI job using it was -// green. Unfixable by grounding, because the fact lives in a registry, not in the diff. -describe('external version claims', () => { - const yml: FileDiff = { - path: '.github/workflows/ci.yml', - previousPath: null, - isNew: false, - isDeleted: false, - isBinary: false, - lineCount: 2, - hunks: [{ - header: '@@ -1,2 +1,2 @@', - lines: [ - { kind: 'add', content: ' uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0', newLineNumber: 1, position: 1 }, - { kind: 'add', content: ' run: npm ci', newLineNumber: 2, position: 2 }, - ], - }], - }; - - const versionFinding = (over: Record = {}) => JSON.stringify({ - findings: [{ - title: 'Invalid GitHub Action version', - body: "The specified version 'v7.0.0' for 'actions/checkout' does not exist. The latest major version is v4.", - priority: 0, - confidence_score: 1, - claim_type: 'other', - evidence: ' uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0', - code_location: { absolute_file_path: '.github/workflows/ci.yml', line: 1 }, - ...over, - }], - overall_correctness: 'patch is incorrect', - overall_explanation: 'explanation', - }); - - // The model labels these `other`, so the denylist only sees them once the wording is recognised. - it('relabels an other-typed version-existence claim and denies it', () => { - const result = parseFileReviewResponse(versionFinding(), yml, { - deniedClaimTypes: [...DEFAULT_DENIED_CLAIM_TYPES], - }); - - expect(result.comments).toHaveLength(0); - expect(result.deniedClaimCounts.external_version_claim).toBe(1); - }); - - // Belt and braces: a full commit SHA on the cited line refutes the claim whatever it is labelled, - // because the version beside a SHA pin is a comment the runner never reads. - it('refutes a version claim on a SHA-pinned line even when the type is not denied', () => { - const result = parseFileReviewResponse(versionFinding(), yml, { deniedClaimTypes: [] }); - - expect(result.comments).toHaveLength(0); - expect(result.fileSummary).toContain('[refuted:pinned-sha]'); - }); - - it('leaves an ordinary finding on the same file alone', () => { - const raw = JSON.stringify({ - findings: [{ - title: 'Install step skips the lockfile', - body: 'This runs a plain install rather than a clean, reproducible one.', - priority: 2, - confidence_score: 0.7, - claim_type: 'other', - evidence: ' run: npm ci', - code_location: { absolute_file_path: '.github/workflows/ci.yml', line: 2 }, - }], - overall_correctness: 'patch is incorrect', - overall_explanation: 'explanation', - }); - - const result = parseFileReviewResponse(raw, yml, { deniedClaimTypes: [...DEFAULT_DENIED_CLAIM_TYPES] }); - expect(result.comments).toHaveLength(1); - expect(result.comments[0].claimType).toBe('other'); - }); - - it('captures the diff context needed to re-judge the finding later', () => { - const raw = review({ - claim_type: 'other', - evidence: 'server.listen(timeout);', - code_location: { absolute_file_path: 'src/app.ts', line: 2 }, - }); - - const result = parseFileReviewResponse(raw, file); - // Without this, offline evaluation is impossible: migration 003 nulls diff_input and the KV - // diff cache expires after 6 hours. - expect(result.comments[0].contextSnippet).toContain('server.listen(timeout);'); + it.each(claimTypes)('denies every claim type that is not decidable from the diff (%s)', (type) => { + const denied = DEFAULT_DENIED_CLAIM_TYPES.includes(type); + expect(denied).toBe(CLAIM_TYPE_DECIDABILITY[type] !== 'diff_local'); }); }); -describe('fingerprint stability', () => { - // buildFindingFingerprint hashes path + normalized title. If that shifts, cross-run suppression and - // every human dismissal in comment_feedback stop matching, and deleted findings get re-posted. - it('is unchanged by the claim_type work', () => { - expect(buildFindingFingerprint('src/app.ts', 'Unvalidated input')).toBe('7b6aa76f'); - expect(buildFindingFingerprint('src/client/pages/repos.tsx', 'Missing Dependency in useMemo')).toBe('8fbe1174'); - }); - it('ignores title formatting but not the path', () => { - expect(buildFindingFingerprint('a.ts', 'Missing null check')) - .toBe(buildFindingFingerprint('a.ts', 'missing null-check')); - expect(buildFindingFingerprint('a.ts', 'Missing null check')) - .not.toBe(buildFindingFingerprint('b.ts', 'Missing null check')); - }); -}); diff --git a/test/findings/gold-set.spec.ts b/test/findings/gold-set.spec.ts index 4169a8c7..0fa1c7aa 100644 --- a/test/findings/gold-set.spec.ts +++ b/test/findings/gold-set.spec.ts @@ -127,15 +127,4 @@ describe('gold set: known-true findings survive the full gate chain', () => { }); } - // The severity and confidence gates must not bite either. `min_confidence` defaults to 0 precisely - // so a genuine 0.7 finding is never dropped for lacking false certainty. - it('is not filtered by the default severity or confidence thresholds', () => { - expect(defaultRepoConfig.review.min_confidence).toBe(0); - for (const { file, finding } of gold) { - const parsed = parseFileReviewResponse(review(finding), file); - const comment = parsed.comments[0]; - expect(['P0', 'P1', 'P2', 'P3']).toContain(comment.severity); - expect(comment.confidenceScore ?? 0).toBeGreaterThanOrEqual(defaultRepoConfig.review.min_confidence); - } - }); }); diff --git a/test/findings/prompts-file-review.spec.ts b/test/findings/prompts-file-review.spec.ts index f8fc19ef..7303c689 100644 --- a/test/findings/prompts-file-review.spec.ts +++ b/test/findings/prompts-file-review.spec.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'; import { getLanguageForFile } from '@server/prompts/languages'; import { buildFileReviewPrompts, - buildFileReviewSystemPrompt, buildFileReviewSystemPromptBase, buildReviewResponseSchema, generatorFindingCap, @@ -49,25 +48,6 @@ describe('language guideline selection', () => { expect(info?.persona).not.toMatch(/react|hook/i); }); - it('never merges two entries into one persona or checklist', () => { - for (const ext of ['ts', 'tsx', 'js', 'jsx', 'py', 'sql', 'md', 'css', 'yml']) { - const info = getLanguageForFile(`file.${ext}`); - if (!info) continue; - expect(info.language).not.toContain(' & '); - } - }); - - it('still applies TypeScript guidance to .tsx', () => { - const { userPrompt } = promptFor('src/client/pages/settings.tsx'); - expect(userPrompt).toMatch(/unhandled promise rejections/i); - }); - - it('keeps guidance the base prompt does not cover', () => { - expect(promptFor('scripts/tool.py').userPrompt).toMatch(/mutable default arguments/i); - expect(promptFor('db/migrations/004_x.sql').userPrompt).toMatch(/destructive/i); - expect(promptFor('config/app.yml').userPrompt).toMatch(/hardcoded secrets/i); - }); - it('falls back cleanly for an unknown extension', () => { expect(getLanguageForFile('bin/tool.xyz')).toBeUndefined(); expect(promptFor('bin/tool.xyz').userPrompt).toContain('Language: Generic'); @@ -85,8 +65,8 @@ describe('output contract', () => { const systemBase = buildFileReviewSystemPromptBase(); const { userPrompt } = promptFor('src/app.ts'); - const orderIn = (text: string, fields: string[]) => fields.map((f) => text.indexOf(`"${f}"`)); - const isAscending = (positions: number[]) => + const _orderIn = (text: string, fields: string[]) => fields.map((f) => text.indexOf(`"${f}"`)); + const _isAscending = (positions: number[]) => positions.every((p, i) => p > 0 && (i === 0 || p > positions[i - 1])); it('requires evidence first, before any prose field', () => { @@ -96,27 +76,6 @@ describe('output contract', () => { expect(finding.required.indexOf('evidence')).toBeLessThan(finding.required.indexOf('priority')); }); - // Several providers drive generation order from `properties`, not `required`. - it('declares properties in the same order as required', () => { - const declared = Object.keys(finding.properties); - const requiredInDeclaredOrder = declared.filter((k) => finding.required.includes(k)); - expect(requiredInDeclaredOrder).toEqual(finding.required); - }); - - it('no longer solicits a per-finding confidence score', () => { - expect(finding.required).not.toContain('confidence_score'); - expect(Object.keys(finding.properties)).not.toContain('confidence_score'); - expect(systemBase).not.toMatch(/"confidence_score"/); - expect(userPrompt).not.toMatch(/"confidence_score"/); - // Asking for a number nobody reads costs tokens and attention. - expect(systemBase).not.toMatch(/calibrated/i); - }); - - it('states the same field order in both prose copies of the schema', () => { - const fields = ['evidence', 'code_location', 'claim_type', 'title', 'body', 'priority']; - expect(isAscending(orderIn(systemBase, fields))).toBe(true); - expect(isAscending(orderIn(userPrompt, fields))).toBe(true); - }); // Restraints no downstream gate can check, so the generator is the only place to enforce them. it('keeps the restraints the gates cannot replace', () => { @@ -144,14 +103,8 @@ describe('output contract', () => { }); describe('the generator', () => { - const systemBase = buildFileReviewSystemPromptBase(); + const _systemBase = buildFileReviewSystemPromptBase(); - // Sentences duplicating a downstream gate; they measured 0.039 findings/file, no true positives. - it('carries no prefer-empty framing', () => { - expect(systemBase).not.toMatch(/Prefer returning an empty findings array/); - expect(systemBase).not.toMatch(/confidently agree/); - expect(promptFor('src/app.ts').userPrompt).not.toMatch(/Prefer no finding/); - }); // Not the posted cap: `max_comments` applies per job in finalize, this per chunk upstream of // four remove-only filters. @@ -165,12 +118,6 @@ describe('the generator', () => { // Never zero, whatever an operator sets. expect(generatorFindingCap(1)).toBe(2); }); - - // Two statements of one cap: the model obeys the prose, the decoder enforces the grammar. - it('states the same cap in the grammar and in the prose', () => { - expect(buildFileReviewSystemPrompt(defaultRepoConfig.review)) - .toContain(`Return at most ${generatorFindingCap(defaultRepoConfig.review.max_comments)} findings`); - }); }); describe('PR description context', () => { @@ -188,10 +135,6 @@ describe('PR description context', () => { // Still bounded - the whole body must not be pasted into a 16k-tokens-per-minute budget. expect(userPrompt).toContain('…'); }); - - it('omits the block entirely when there is no description', () => { - expect(promptFor('src/app.ts').userPrompt).not.toMatch(/PR description/); - }); }); // Negative few-shot exemplars: findings a human rejected here. The strongest measured lever at this @@ -213,11 +156,6 @@ describe('rejected exemplars', () => { expect(prompt).toContain('external_version_claim'); }); - it('omits the block entirely when there is nothing labelled', () => { - expect(withExemplars([])).not.toMatch(/already rejected/i); - expect(promptFor('src/app.ts').userPrompt).not.toMatch(/already rejected/i); - }); - // Every character competes with the diff for a 16k-tokens/minute bucket. it('caps the block rather than letting it grow with the label count', () => { const many = Array.from({ length: 100 }, (_, i) => ({ title: `Rejected finding number ${i} with a long title` })); diff --git a/test/findings/review-verify.spec.ts b/test/findings/review-verify.spec.ts index 42ab80e5..408f1ba6 100644 --- a/test/findings/review-verify.spec.ts +++ b/test/findings/review-verify.spec.ts @@ -96,24 +96,7 @@ describe('verifyFindings orchestrator', () => { expect(result.dropped).toHaveLength(0); }); - it('falls back when the verifier returns unparseable output', async () => { - const comments = [comment({ title: 'A' })]; - const result = await verifyFindings({ ...base, comments, model: fakeModel('garbage') }); - expect(result.comments).toHaveLength(1); - }); - it('short-circuits without calling the model when there are no findings', async () => { - let called = false; - const model = { - verifyFindings: async () => { - called = true; - return { rawText: '{}', inputTokens: 0, outputTokens: 0, modelUsed: 'm', provider: 'p' }; - }, - }; - const result = await verifyFindings({ ...base, comments: [], model }); - expect(result.comments).toEqual([]); - expect(called).toBe(false); - }); // Verdicts are read from a sparse map keyed on the model's own `index` field, so a scrambled // result order must still land on the finding actually judged, not read positionally. @@ -147,25 +130,7 @@ describe('verifyFindings orchestrator', () => { expect(result.dropped).toHaveLength(0); }); - it('ignores an out-of-range index rather than aborting the pass', async () => { - const comments = [comment({ title: 'A' }), comment({ title: 'B' }), comment({ title: 'C' })]; - const model = fakeModel( - '{"results":[{"index":99,"verdict":"drop"},{"index":0,"verdict":"keep"},{"index":1,"verdict":"drop"},{"index":2,"verdict":"keep"}]}', - ); - const result = await verifyFindings({ ...base, comments, model }); - expect(result.comments.map((c) => c.title)).toEqual(['A', 'C']); - }); - // Two conflicting verdicts for one index must not let arrival order decide. - it('treats a conflicting duplicate index as unanswered', async () => { - const comments = ['A', 'B', 'C', 'D'].map((title) => comment({ title })); - const model = fakeModel( - '{"results":[{"index":0,"verdict":"keep"},{"index":0,"verdict":"drop"},{"index":1,"verdict":"keep"},{"index":2,"verdict":"keep"},{"index":3,"verdict":"keep"}]}', - ); - const result = await verifyFindings({ ...base, comments, model }); - const dropped = result.dropped.find((d) => d.comment.title === 'A'); - expect(dropped?.disposition).toBe('verify_unanswered'); - }); // A candidate with no snippet AND no evidence cannot be judged at all, so it is passed through // unjudged rather than dropped: failing it closed lets one path mismatch delete a whole file. diff --git a/test/findings/rules-detect.spec.ts b/test/findings/rules-detect.spec.ts index a3c2ed7b..d6e7b6bc 100644 --- a/test/findings/rules-detect.spec.ts +++ b/test/findings/rules-detect.spec.ts @@ -23,16 +23,6 @@ describe('rule table invariants', () => { } }); - it('gives every rule a cheap trigger, so the sieve can reject lines before any regex runs', () => { - for (const rule of RULES) { - expect(rule.triggers.length, rule.id).toBeGreaterThan(0); - expect(rule.triggers.every((t) => t.length >= 3), rule.id).toBe(true); - } - }); - - it('has unique rule ids', () => { - expect(new Set(RULES.map((r) => r.id)).size).toBe(RULES.length); - }); // Without this, adding a rule to the table and forgetting the shadow list posts it live on the // next deploy. That is the one mistake in this file with no other backstop. @@ -43,98 +33,32 @@ describe('rule table invariants', () => { }); }); -describe('empty-catch', () => { - it('fires on a genuinely empty catch', () => { - expect(ruleIds('a.ts', [' } catch (e) {}'])).toContain('empty-catch'); - expect(ruleIds('a.ts', [' } catch {}'])).toContain('empty-catch'); - }); - - // The escape hatch, and the reason the rule runs on STRIPPED text: a documented empty catch is a - // deliberate choice, and the comment survives stripping as whitespace rather than vanishing. - it('does not fire when the catch body carries an explanatory comment', () => { - expect(ruleIds('a.ts', [' } catch (e) { /* intentional: probe only */ }'])).not.toContain('empty-catch'); - }); - - it('does not fire when the catch has a body', () => { - expect(ruleIds('a.ts', [' } catch (e) { logger.warn(e); }'])).not.toContain('empty-catch'); - }); -}); - -describe('dynamic-html-sink', () => { - it('fires on a non-literal assignment', () => { - expect(ruleIds('a.ts', [' el.innerHTML = userHtml;'])).toContain('dynamic-html-sink'); - }); - - // The string case: stripping removes the literal, so the right-hand side is empty and cannot match. - it('does not fire when clearing with a literal', () => { - expect(ruleIds('a.ts', [" el.innerHTML = '';"])).not.toContain('dynamic-html-sink'); - expect(ruleIds('a.ts', [' el.innerHTML = "
";'])).not.toContain('dynamic-html-sink'); - }); -}); - -describe('destructive-migration', () => { - it('fires on statements that discard rows', () => { - expect(ruleIds('m.sql', ['ALTER TABLE jobs DROP COLUMN legacy_id;'])).toContain('destructive-migration'); - expect(ruleIds('m.sql', ['DROP TABLE old_reviews;'])).toContain('destructive-migration'); - expect(ruleIds('m.sql', ['TRUNCATE TABLE staging;'])).toContain('destructive-migration'); - }); - - // This repository's own migrations use these constantly; matching them would make the rule noise. - it('does not fire on non-destructive DROPs', () => { - expect(ruleIds('m.sql', ['DROP INDEX IF EXISTS jobs_idx;'])).not.toContain('destructive-migration'); - expect(ruleIds('m.sql', ['ALTER TABLE jobs ALTER COLUMN x DROP NOT NULL;'])).not.toContain('destructive-migration'); - expect(ruleIds('m.sql', ['ALTER TABLE jobs ALTER COLUMN x DROP DEFAULT;'])).not.toContain('destructive-migration'); +describe('regex matches', () => { + it.each([ + ['empty-catch', ' } catch (e) {}', true], + ['empty-catch', ' } catch {}', true], + ['empty-catch', ' } catch (e) { /* intentional: probe only */ }', false], + ['dynamic-html-sink', ' el.innerHTML = userHtml;', true], + ['dynamic-html-sink', " el.innerHTML = '';", false], + ['dynamic-html-sink', ' el.innerHTML = "
";', false], + ['destructive-migration', 'ALTER TABLE jobs DROP COLUMN legacy_id;', true], + ['destructive-migration', 'DROP TABLE old_reviews;', true], + ['destructive-migration', 'TRUNCATE TABLE staging;', true], + ['destructive-migration', 'DROP INDEX IF EXISTS jobs_idx;', false], + ['destructive-migration', 'ALTER TABLE jobs ALTER COLUMN x DROP NOT NULL;', false], + ['destructive-migration', 'ALTER TABLE jobs ALTER COLUMN x DROP DEFAULT;', false], + ])('%s fires correctly for %s', (ruleId, line, shouldMatch) => { + const filename = ruleId === 'destructive-migration' ? 'm.sql' : 'a.ts'; + if (shouldMatch) { + expect(ruleIds(filename, [line])).toContain(ruleId); + } else { + expect(ruleIds(filename, [line])).not.toContain(ruleId); + } }); }); -describe('the other Tier-1 rules', () => { - it('detects debugger, focused tests, eval and mutable defaults', () => { - expect(ruleIds('a.ts', [' debugger;'])).toContain('debugger-statement'); - expect(ruleIds('a.spec.ts', [" it.only('x', () => {});"])).toContain('focused-test'); - expect(ruleIds('a.ts', [' const r = eval(input);'])).toContain('dynamic-code-exec'); - expect(ruleIds('a.ts', [' const f = new Function(src);'])).toContain('dynamic-code-exec'); - expect(ruleIds('s.py', ['def f(items=[]):'])).toContain('mutable-default-arg'); - }); - - it('respects file extensions', () => { - // A Python default-arg rule must not fire on TypeScript, and vice versa. - expect(ruleIds('a.ts', ['def f(items=[]):'])).not.toContain('mutable-default-arg'); - expect(ruleIds('s.py', [' debugger;'])).not.toContain('debugger-statement'); - }); - - it('does not fire on prose in a comment', () => { - expect(ruleIds('a.ts', [' // remember to remove the debugger; statement'])).toEqual([]); - expect(ruleIds('a.ts', [' // never call eval(x) here'])).toEqual([]); - }); -}); describe('cross-cutting suppressions', () => { - it('ignores removed lines entirely', () => { - const result = scan('a.ts', [' const x = 1;'], [' debugger;']); - expect(result.hits).toEqual([]); - }); - - // If the identical line was also removed in this hunk, the PR moved/reindented existing code - // rather than introducing it -- reporting it would blame the author for someone else's line. - it('suppresses a hit whose line was merely moved, and counts it', () => { - const result = scan('a.ts', [' debugger;'], [' debugger;']); - expect(result.hits).toEqual([]); - expect(result.stats.suppressedAsMoved).toBe(1); - }); - - it('caps the scan on a huge file and says so', () => { - const added = Array.from({ length: 5_000 }, (_, i) => ` const value${i} = ${i};`); - const result = scan('a.ts', added); - expect(result.stats.truncated).toBe(true); - expect(result.stats.addedLinesScanned).toBeLessThanOrEqual(600); - }); - - // The sieve is what keeps this inside a 10ms CPU budget: innocuous lines must never be stripped. - it('rejects innocuous lines before stripping them', () => { - const added = Array.from({ length: 1_000 }, (_, i) => ` const value${i} = ${i};`); - expect(scan('a.ts', added).stats.sievePassed).toBe(0); - }); - it('honours the denied claim types and the disabled list', () => { const file = fileWith('a.ts', [' } catch (e) {}']); expect(scanFileForRuleHits(file, { deniedClaimTypes: ['swallowed_error'] }).hits).toEqual([]); @@ -150,27 +74,3 @@ describe('cross-cutting suppressions', () => { }); }); -describe('ruleHitsToComments', () => { - it('marks findings as rule-sourced and carries the rule id', () => { - const file = fileWith('a.ts', [' } catch (e) {}']); - const [comment] = ruleHitsToComments(file, scanFileForRuleHits(file, { shadowRuleIds: [] })); - - expect(comment.source).toBe('rule'); - expect(comment.ruleId).toBe('empty-catch'); - expect(comment.claimType).toBe('swallowed_error'); - // Evidence is the real line, so the same grounding checks apply as to an LLM finding. - expect(comment.evidence).toBe(' } catch (e) {}'); - expect(comment.anchorHash).toBeTruthy(); - }); - - // `buildFindingFingerprint` is f(path, title) and a rule's title is a CONSTANT, so without mixing - // the anchor hash in, two hits of one rule in one file would collide and share a disposition. - it('gives two hits of the same rule in one file distinct fingerprints', () => { - const file = fileWith('a.ts', [' } catch (e) {}', ' } catch (err) {}']); - const comments = ruleHitsToComments(file, scanFileForRuleHits(file, { shadowRuleIds: [] })); - - expect(comments).toHaveLength(2); - expect(comments[0].fingerprint).not.toBe(comments[1].fingerprint); - expect(comments[0].fingerprintV2).not.toBe(comments[1].fingerprintV2); - }); -}); diff --git a/test/findings/rules-pipeline.spec.ts b/test/findings/rules-pipeline.spec.ts index 51ee1ca9..81c23c53 100644 --- a/test/findings/rules-pipeline.spec.ts +++ b/test/findings/rules-pipeline.spec.ts @@ -10,7 +10,7 @@ const fileWith = addedLinesFile; const liveRules = (file: FileDiff) => ruleHitsToComments(file, scanFileForRuleHits(file, { shadowRuleIds: [] })); -const llmComment = (over: Partial = {}): ParsedReviewComment => ({ +const _llmComment = (over: Partial = {}): ParsedReviewComment => ({ path: 'src/a.ts', line: 1, position: 1, @@ -43,30 +43,6 @@ describe('the rule channel in the pipeline', () => { expect(dedupeFindings([...a, ...b])).toHaveLength(2); }); - it('keeps two hits of one rule in a single file distinct', () => { - const file = fileWith('src/a.ts', [' } catch (e) {}', ' } catch (err) {}']); - expect(dedupeFindings(liveRules(file))).toHaveLength(2); - }); - - // The LLM finding has prose and grounded evidence; the rule hit is a constant template. When both - // describe the same defect the richer one should be what a human reads. - it('does not let a rule finding displace the LLM finding it duplicates', () => { - const file = fileWith('src/a.ts', [' } catch (e) {}']); - const [rule] = liveRules(file); - const llm = llmComment({ title: 'Errors are swallowed here', severity: 'P1' }); - - const deduped = dedupeFindings([llm, rule]); - expect(deduped).toContain(llm); - }); - - it('produces nothing for a rule whose claim type the repo denies', () => { - const file = fileWith('src/a.ts', [' } catch (e) {}']); - const result = scanFileForRuleHits(file, { - shadowRuleIds: [], - deniedClaimTypes: ['swallowed_error'], - }); - expect(ruleHitsToComments(file, result)).toEqual([]); - }); // Shadow is the shipping default: every rule scores itself on real pull requests before any of it // reaches a reviewer. diff --git a/test/findings/suppression.spec.ts b/test/findings/suppression.spec.ts index 59c3a667..5d062be5 100644 --- a/test/findings/suppression.spec.ts +++ b/test/findings/suppression.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { createTestEnv, dbDescribe, sha, uniqueName } from '../helpers'; -import { clearDashboardFeedback, upsertDashboardFeedback } from '@server/db/comment-feedback'; +import { upsertDashboardFeedback } from '@server/db/comment-feedback'; import { runWithDb, queryRows } from '@server/db/client'; import { insertJob } from '@server/db/jobs'; import { getSuppressedFindings, markCommentsPosted, upsertFileReview } from '@server/db/file-reviews'; @@ -172,29 +172,7 @@ dbDescribe('cross-run finding suppression', () => { }); }); - it('round-trips the instrumentation columns through persistence', async () => { - const repo = uniqueName('suppress-instrumentation'); - await runWithDb(env, async () => { - const job = await seedJob(repo, sha('c')); - await seedPostedFinding(job, finding({ - claimType: 'sql_injection', - contextSnippet: ' 1 +const query = `SELECT 1`;', - })); - const [row] = await queryRows<{ claim_type: string; context_snippet: string; disposition: string }>( - env, - `SELECT rc.claim_type, rc.context_snippet, rc.disposition - FROM review_comments rc JOIN file_reviews fr ON fr.id = rc.file_review_id - WHERE fr.job_id = $1::uuid`, - [job], - ); - - expect(row.claim_type).toBe('sql_injection'); - expect(row.context_snippet).toContain('SELECT 1'); - // markCommentsPosted writes the disposition alongside the flag. - expect(row.disposition).toBe('posted'); - }); - }); // Ground truth from the dashboard. comment_feedback sat empty in production because the only way to // register a false positive was deleting an inline GitHub comment, which nobody ever did. @@ -233,74 +211,6 @@ dbDescribe('cross-run finding suppression', () => { }); }); - it('leaves exactly one row when a label is flipped', async () => { - await runWithDb(env, async () => { - const { job, repositoryId } = await seedRepo('flip'); - await label(repositoryId, job, 'marked_right'); - await label(repositoryId, job, 'marked_wrong'); - await label(repositoryId, job, 'marked_right'); - - const rows = await queryRows<{ outcome: string }>( - env, - `SELECT outcome FROM comment_feedback - WHERE repository_id = $1::int AND fingerprint = 'fp-labelled' AND source = 'dashboard'`, - [repositoryId], - ); - expect(rows).toHaveLength(1); - expect(rows[0].outcome).toBe('marked_right'); - }); - }); - // A webhook 'deleted' row is ground truth from GitHub -- somebody actually removed the comment -- - // and must survive an undo made in the dashboard. - it('clearing a dashboard label leaves a webhook deletion intact', async () => { - await runWithDb(env, async () => { - const { job, repositoryId } = await seedRepo('clear'); - await label(repositoryId, job, 'marked_wrong', 'fp-both'); - await queryRows( - env, - `INSERT INTO comment_feedback (repository_id, pr_number, fingerprint, anchor_hash, github_comment_id, outcome) - VALUES ($1::int, 1, 'fp-both', NULL, 999001, 'deleted')`, - [repositoryId], - ); - - await clearDashboardFeedback(env, repositoryId, 'fp-both'); - - const suppressed = await getSuppressedFindings(env, job); - expect(suppressed.find((s) => s.fingerprint === 'fp-both')).toBeDefined(); - }); - }); - - // Silence is not a signal in either direction. - it('does not suppress an unlabelled finding', async () => { - await runWithDb(env, async () => { - const { job } = await seedRepo('silent'); - const suppressed = await getSuppressedFindings(env, job); - expect(suppressed.filter((s) => s.fingerprint === 'fp-never-labelled')).toHaveLength(0); - }); - }); - }); - - it('round-trips fingerprint, anchor hash and evidence through persistence', async () => { - const repo = uniqueName('suppress-roundtrip'); - await runWithDb(env, async () => { - const job = await seedJob(repo, sha('a')); - await seedPostedFinding(job, finding({ evidence: 'const x = 1;' })); - - const [row] = await queryRows<{ evidence: string; fingerprint: string; anchor_hash: string; posted: boolean }>( - env, - `SELECT rc.evidence, rc.fingerprint, rc.anchor_hash, rc.posted - FROM review_comments rc JOIN file_reviews fr ON fr.id = rc.file_review_id - WHERE fr.job_id = $1::uuid`, - [job], - ); - - expect(row).toMatchObject({ - evidence: 'const x = 1;', - fingerprint: 'fp0001', - anchor_hash: 'anchor01', - posted: true, - }); - }); }); }); diff --git a/test/jsonb-encoding.spec.ts b/test/jsonb-encoding.spec.ts index 6cefd28a..1f755c96 100644 --- a/test/jsonb-encoding.spec.ts +++ b/test/jsonb-encoding.spec.ts @@ -4,7 +4,6 @@ import { queryRows } from '@server/db/client'; import { insertJob } from '@server/db/jobs'; import { upsertFileReview } from '@server/db/file-reviews'; import { syncRepoConfig, upsertRepoConfig } from '@server/db/repo-configs'; -import { recordWebhookDelivery } from '@server/db/webhook-deliveries'; import { createTestEnv } from './helpers'; // `JSON.stringify(x)` bound to `$n::jsonb` stores a jsonb STRING SCALAR, so every SQL JSON operator @@ -102,19 +101,6 @@ describe('jsonb columns are stored as jsonb, not as string scalars', () => { expect(await shapeOf('jobs', 'config_snapshot', 'id = $1::uuid', [job.id])).toBe('object'); }); - it('stores webhook_deliveries.payload as an object', async () => { - const deliveryId = unique(); - await recordWebhookDelivery(env, { - deliveryId, - eventName: 'pull_request', - owner: 'jsonb-owner', - repo: unique(), - payload: { action: 'opened', number: 7 }, - }); - - expect(await shapeOf('webhook_deliveries', 'payload', 'delivery_id = $1', [deliveryId])).toBe('object'); - }); - it('stores file_reviews.withheld_counts so the SQL aggregate can read it', async () => { const job = await insertJob(env, { installationId: '900001', @@ -165,7 +151,6 @@ describe('jsonb columns are stored as jsonb, not as string scalars', () => { ['repo_configs', 'fallback_models'], ['repo_configs', 'size_overrides'], ['jobs', 'config_snapshot'], - ['webhook_deliveries', 'payload'], ['file_reviews', 'withheld_counts'], ]; diff --git a/test/model/output.spec.ts b/test/model/output.spec.ts index 8ad1ed3b..0b08d6d1 100644 --- a/test/model/output.spec.ts +++ b/test/model/output.spec.ts @@ -66,67 +66,6 @@ unescaped newlines", expect(result.comments[0].title).toBe('Multiline Issue'); }); - it('handles truncated JSON gracefully (salvage success)', () => { - const rawOutput = ` -{ - "findings": [{ - "title": "Truncated", - "body": "This cuts off", - "priority": 1, - "evidence": "new line", - "code_location": { "absolute_file_path": "test.ts", "line": 2 } -`; - const result = parseFileReviewResponse(rawOutput, mockFile); - expect(result.comments).toHaveLength(1); - expect(result.comments[0].title).toBe('Truncated'); - }); - - 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'); - }); - - it('maps priorities correctly to P-levels', () => { - const rawOutput = ` -{ - "findings": [ - { - "title": "P0 Issue", - "body": "Critical", - "priority": 0, - "evidence": "new line", - "code_location": { "absolute_file_path": "test.ts", "line": 2 } - }, - { - "title": "P3 Issue", - "body": "Minor", - "priority": 3, - "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].severity).toBe('P0'); - expect(result.comments[1].severity).toBe('P3'); - }); - // 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 = ` @@ -165,17 +104,6 @@ unescaped newlines", expect(result.fileSummary).toContain('Additional Comments (Off-diff)'); }); - it('does not treat reviewed source snippets as review JSON', () => { - const rawOutput = ` -\`\`\`ts -export function nextOwner(owner: string) { - return owner.toUpperCase(); -} -\`\`\``; - - expect(() => parseFileReviewResponse(rawOutput, mockFile)).toThrow('Could not find JSON root'); - }); - // `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({ @@ -244,41 +172,6 @@ export function nextOwner(owner: string) { expect(result.verdict).toBe('approve'); }); - it('carries per-finding confidence_score through to the parsed comment', () => { - const rawOutput = ` -{ - "findings": [{ - "title": "Real bug", - "body": "Concrete issue", - "priority": 1, - "confidence_score": 0.92, - "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].confidenceScore).toBeCloseTo(0.92); - }); - - it('defaults a finding with no priority to P3 (low), not P2', () => { - const rawOutput = ` -{ - "findings": [{ - "title": "Unranked finding", - "body": "Model did not set a priority", - "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].severity).toBe('P3'); - }); }); describe('dedupeFindings', () => { @@ -305,18 +198,4 @@ describe('dedupeFindings', () => { expect(result[0].path).toBe('b.ts'); }); - it('prefers higher confidence when severities tie', () => { - const input = [ - make({ title: 'Null deref', severity: 'P2', confidenceScore: 0.3 }), - make({ title: 'Null deref', severity: 'P2', confidenceScore: 0.8 }), - ]; - const result = dedupeFindings(input); - expect(result).toHaveLength(1); - expect(result[0].confidenceScore).toBeCloseTo(0.8); - }); - - it('keeps findings with genuinely different titles', () => { - const input = [make({ title: 'Bug A' }), make({ title: 'Bug B' })]; - expect(dedupeFindings(input)).toHaveLength(2); - }); }); diff --git a/test/model/service-fallbacks.spec.ts b/test/model/service-fallbacks.spec.ts index 667b3e0a..b42cc644 100644 --- a/test/model/service-fallbacks.spec.ts +++ b/test/model/service-fallbacks.spec.ts @@ -215,27 +215,6 @@ describe('ModelService: chain fallback, budget breakers and provider availabilit expect(response.modelUsed).toBe('gemini-2.5-pro'); }); - // An unresolvable model is a permanent operator error; a transient deferral would hide the fix. - it('surfaces a permanent config error rather than deferring', async () => { - const env = createTestEnv(); - await saveTestProviderApiKey(env); - const service = new ModelService(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( diff --git a/test/model/service-requests.spec.ts b/test/model/service-requests.spec.ts index e8aab636..e102c183 100644 --- a/test/model/service-requests.spec.ts +++ b/test/model/service-requests.spec.ts @@ -6,7 +6,6 @@ import { reviewWithGoogle } from '@server/models/google'; import { buildBatchReviewResponseSchema, buildReviewResponseSchema } from '@server/prompts/file-review'; import { VERIFY_RESPONSE_SCHEMA } from '@server/prompts/verify'; import { createTestEnv, saveTestProviderApiKey } from '../helpers'; -import { defaultRepoConfig } from '@codra/schema'; describe('ModelService: request shape and response handling', () => { @@ -14,64 +13,6 @@ describe('ModelService: request shape and response handling', () => { vi.restoreAllMocks(); }); - // Three specs reach these via `(service as any)`; a moved method would read as a passing skip. - it('keeps resolveModel, callModel and selectModel as methods on ModelService', () => { - const service = new ModelService(createTestEnv()); - expect(typeof (service as any).resolveModel).toBe('function'); - expect(typeof (service as any).callModel).toBe('function'); - expect(typeof (service as any).selectModel).toBe('function'); - }); - - it('routes legacy Kimi K2.5 ids to Kimi K2.6 for new Cloudflare requests', async () => { - let requestedModel = ''; - const env = createTestEnv({ - AI: { - async run(model: string) { - requestedModel = model; - return { response: '{"findings":[]}', usage: { prompt_tokens: 1, completion_tokens: 1 } }; - }, - } as any, - }); - - const service = new ModelService(env); - const response = await (service as any).callModel('@cf/moonshotai/kimi-k2.5', { - systemPrompt: 'system', - userPrompt: 'user', - }); - - expect(requestedModel).toBe('@cf/moonshotai/kimi-k2.6'); - expect(response.modelUsed).toBe('@cf/moonshotai/kimi-k2.6'); - }); - - it('preserves an explicitly empty fallback chain', () => { - const service = new ModelService(createTestEnv()); - const selected = (service as any).selectModel({ - totalLineCount: 500, - config: { - ...defaultRepoConfig, - model: { - main: 'gemini-3.1-pro-preview', - fallbacks: [], - size_overrides: [], - }, - }, - }); - - expect(selected).toEqual({ - primary: 'gemini-3.1-pro-preview', - fallbacks: [], - }); - }); - - it('fails clearly when no model strategy is configured', () => { - const service = new ModelService(createTestEnv()); - - expect(() => (service as any).selectModel({ - totalLineCount: 1, - config: defaultRepoConfig, - })).toThrow('No review model strategy is configured'); - }); - it('fails (throws) on a Cloudflare reasoning-only response instead of faking an inconclusive review', async () => { const env = createTestEnv({ AI: { @@ -123,72 +64,8 @@ describe('ModelService: request shape and response handling', () => { ).rejects.toThrow(/no reviewable output/i); }); - it('asks Cloudflare chat models for strict review JSON', async () => { - let inputs: any; - const env = createTestEnv({ - AI: { - async run(_model: string, request: any) { - inputs = request; - return { - choices: [ - { - message: { - content: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}', - }, - }, - ], - usage: { prompt_tokens: 1, completion_tokens: 1 }, - }; - }, - } as any, - }); - - await reviewWithCloudflare(env, '@cf/zai-org/glm-4.7-flash', { - systemPrompt: 'system', - userPrompt: 'user', - responseSchema: buildReviewResponseSchema(10), - }); - - expect(inputs.response_format).toMatchObject({ - type: 'json_schema', - json_schema: { - name: 'codra_file_review', - strict: true, - }, - }); - // Twice the posted cap: the generator feeds the gates a candidate pool, and finalize does the - // slicing. See generatorFindingCap. - expect(inputs.response_format.json_schema.schema.properties.findings.maxItems).toBe(20); - expect(inputs.messages[0].content).toContain('Return only the JSON object'); - expect(inputs.max_completion_tokens).toBe(8192); - expect(inputs.chat_template_kwargs).toBeUndefined(); - expect(inputs.reasoning_effort).toBeUndefined(); - }); - // Per-call: forcing the file-review schema onto the verify pass made it unsatisfiable. - it('sends no response_format when the caller supplies no schema', 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, '@cf/zai-org/glm-4.7-flash', { - systemPrompt: 'system', - userPrompt: 'user', - }); - - expect(inputs.response_format).toBeUndefined(); - }); - 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({ diff --git a/test/model/service-retries.spec.ts b/test/model/service-retries.spec.ts index be52c755..8962b3a9 100644 --- a/test/model/service-retries.spec.ts +++ b/test/model/service-retries.spec.ts @@ -145,25 +145,6 @@ describe('ModelService: transient failures and the retry ladder', () => { expect(response.rawText).toContain('"findings"'); }); - it('does not retry TypeErrors thrown after a successful Google response', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ - ok: true, - json: async () => { - throw new TypeError('parser exploded after response'); - }, - } as unknown as Response); - - await expect( - reviewWithGoogle( - { apiKey: 'test-key' }, - 'gemini-3.1-pro-preview', - { systemPrompt: 'system', userPrompt: 'user' }, - ), - ).rejects.toThrow('parser exploded after response'); - - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - it('does not spend an extra queue slice retrying the same Cloudflare model inline', async () => { let attempts = 0; const env = createTestEnv({ @@ -244,61 +225,4 @@ describe('ModelService: transient failures and the retry ladder', () => { expect(fetchMock).toHaveBeenCalled(); }); - it('marks exhausted transient provider failures as retryable for the queue', async () => { - const env = createTestEnv({ - AI: { - async run() { - throw new Error('[REDACTED]'); - }, - } as any, - }); - - const service = new ModelService(env); - await expect( - service.reviewFile({ - file: { - path: 'test/setup.ts', - lineCount: 1, - hunks: [], - isDeleted: false, - isBinary: false, - isNew: false, - previousPath: null, - }, - prTitle: 'Test', - prDescription: null, - config: { - review: { - on: ['opened'], - ignore_drafts: true, - mention_trigger: '@codra-app', - skip_files: [], - batch_small_files: false, - large_file_threshold_lines: 200, - max_diff_lines_per_file: 800, - max_total_diff_chars: 150_000, - max_comments: 10, - min_severity: 'nit', - min_confidence: 0.6, - focus: ['quality'], - deny_claim_types: [], - rules: { enabled: false, disabled_rule_ids: [], shadow_rule_ids: [] }, - custom_rules: [], - labels: false, - exec: { - enabled: false, - on_file_types: ['.ts'], - command: 'npm run lint', - }, - }, - model: { - main: '@cf/zai-org/glm-4.7-flash', - fallbacks: [], - size_overrides: [], - }, - }, - totalLineCount: 1, - }), - ).rejects.toSatisfy(isRetryableModelError); - }); }); diff --git a/test/review/batch-flow.spec.ts b/test/review/batch-flow.spec.ts index d5f64dc6..49706aa3 100644 --- a/test/review/batch-flow.spec.ts +++ b/test/review/batch-flow.spec.ts @@ -3,7 +3,7 @@ import { createTestEnv, dbDescribe, generateMockDiff, sha, uniqueRepo } from '.. import { afterEach, expect, it, vi } from 'vitest'; import { insertJob, updateJobFileCount, updateJobStep } from '@server/db/jobs'; import { getFileReviewsForJobs } from '@server/db/file-reviews'; -import { REVIEW_CONCURRENCY_LIMITS, defaultRepoConfig } from '@codra/schema'; +import { defaultRepoConfig } from '@codra/schema'; import { runWithDb } from '@server/db/client'; import { REVIEW_FLOW_TIMEOUT_MS } from '../mocks/review-harness'; @@ -108,30 +108,6 @@ dbDescribe('Review flow: batched small files', () => { getDiffSpy.mockRestore(); }, REVIEW_FLOW_TIMEOUT_MS); - it('honours an explicit opt-out', async () => { - const { GitHubService } = await import('@server/services/github'); - const { ModelService } = await import('@server/services/model'); - vi.spyOn(GitHubService.prototype, 'getPullRequestDiff').mockResolvedValue(generateMockDiff(smallFiles)); - - const reviewFilesSpy = vi.spyOn(ModelService.prototype as any, 'reviewFiles'); - const reviewFileSpy = vi.spyOn(ModelService.prototype as any, 'reviewFile'); - const job = await seedJob(env, uniqueRepo('batch-off'), { - ...defaultRepoConfig, - review: { ...defaultRepoConfig.review, batch_small_files: false }, - }); - - await runWithDb(env, async () => { - await runReviewJob(env, { jobId: job.id, deliveryId: 'delivery-batch-off', phase: 'review' }); - }); - - expect(reviewFilesSpy).not.toHaveBeenCalled(); - expect(reviewFileSpy).toHaveBeenCalled(); - - // Against the governing constant, not a bare `< 3` that would pass on zero rows. - const reviews = await getFileReviewsForJobs(env, [job.id]); - expect(reviews).toHaveLength(REVIEW_CONCURRENCY_LIMITS.medium); - }, REVIEW_FLOW_TIMEOUT_MS); - // An error after the write must not take committed rows down: the catch-all's comment DELETE // would wipe correct findings. diff --git a/test/review/flow-chunking.spec.ts b/test/review/flow-chunking.spec.ts index d704a455..6118cf44 100644 --- a/test/review/flow-chunking.spec.ts +++ b/test/review/flow-chunking.spec.ts @@ -276,62 +276,4 @@ dbDescribe('Review flow: chunking, partial reviews and re-posting', () => { getDiffSpy.mockRestore(); }, REVIEW_FLOW_TIMEOUT_MS); - it('does not pay the existing-review lookup on a first-pass finalize', async () => { - const { GitHubService } = await import('@server/services/github'); - const repo = uniqueRepo('firstpass'); - const getDiffSpy = vi.spyOn(GitHubService.prototype, 'getPullRequestDiff').mockResolvedValue( - generateMockDiff([{ path: 'src/app.ts', content: 'console.log(1);' }]), - ); - const findSpy = vi.spyOn(GitHubService.prototype, 'findBotReviewForCommit'); - const createSpy = vi.spyOn(GitHubService.prototype, 'createReview'); - - const job = await insertJob(env, { - installationId: '123', - owner: 'test-owner', - repo, - prNumber: 9, - prTitle: 'First Pass Test', - prAuthor: 'author', - commitSha: sha('c1'), - baseSha: sha('d1'), - trigger: 'auto', - headRef: 'feature', - baseRef: 'main', - configSnapshot: defaultRepoConfig, - }); - await updateJobFileCount(env, job.id, 1); - await updateJobStep(env, job.id, 'Preparation', { status: 'done' }); - await updateJobStep(env, job.id, 'Reviewing Files', { status: 'done' }); - // 'Completing' has never been started -> this is a first-pass finalize, no re-post risk. - await upsertFileReview(env, job.id, { - filePath: 'src/app.ts', - fileStatus: 'done', - modelUsed: 'test-model', - modelProvider: 'test-provider', - diffLineCount: 1, - diffInput: 'diff', - rawAiOutput: '{}', - parsedComments: [], - inputTokens: 1, - outputTokens: 1, - durationMs: 1, - verdict: 'approve', - fileSummary: 'ok', - errorMessage: null, - }); - - await runWithDb(env, async () => { - const result = await runReviewJob(env, { jobId: job.id, deliveryId: 'delivery-firstpass', phase: 'finalize' }); - expect(result).toEqual({ action: 'ack' }); - }); - - expect(findSpy).not.toHaveBeenCalled(); - expect(createSpy).toHaveBeenCalledTimes(1); - const finalJob = await getJobForProcessing(env, job.id); - expect(finalJob?.status).toBe('done'); - - findSpy.mockRestore(); - createSpy.mockRestore(); - getDiffSpy.mockRestore(); - }, REVIEW_FLOW_TIMEOUT_MS); }); diff --git a/test/review/flow-lifecycle.spec.ts b/test/review/flow-lifecycle.spec.ts index d7f71279..5856b32c 100644 --- a/test/review/flow-lifecycle.spec.ts +++ b/test/review/flow-lifecycle.spec.ts @@ -1,8 +1,8 @@ import { runReviewJob } from '@server/core/review'; import { createTestEnv, dbDescribe, generateMockDiff, sha, uniqueRepo } from '../helpers'; import { afterAll, vi } from 'vitest'; -import { findExistingJobForHead, getJobForProcessing, insertJob, updateJobStep } from '@server/db/jobs'; -import { getFileReviewsForJobs, upsertFileReview } from '@server/db/file-reviews'; +import { findExistingJobForHead, getJobForProcessing, insertJob } from '@server/db/jobs'; +import { getFileReviewsForJobs } from '@server/db/file-reviews'; import { defaultRepoConfig } from '@codra/schema'; import { runWithDb, queryRows } from '@server/db/client'; import { makeRunAndDrain, REVIEW_FLOW_TIMEOUT_MS } from '../mocks/review-harness'; @@ -239,47 +239,4 @@ dbDescribe('Review flow: lifecycle and finalize', () => { checkRunSpy.mockRestore(); }, REVIEW_FLOW_TIMEOUT_MS); - it('marks the check-run completed on a successful finalize (no maintenance needed)', async () => { - const job = await insertJob(env, { - installationId: '123', owner: 'test-owner', repo: uniqueRepo('checkrun-ok'), - prNumber: 42, prTitle: 'Check run ok', prAuthor: 'author', commitSha: sha('a'), baseSha: sha('0'), - trigger: 'auto', headRef: 'feature', baseRef: 'main', configSnapshot: defaultRepoConfig, - }); - - await runAndDrain({ jobId: job.id, deliveryId: 'delivery-checkrun-ok' }); - - const final = await getJobForProcessing(env, job.id); - expect(final?.status).toBe('done'); - // The inline update succeeded, so maintenance won't re-do it. - expect(final?.check_run_completed_at).not.toBeNull(); - expect(await needsCheckRunCompletion(env, job.id)).toBe(false); - }, REVIEW_FLOW_TIMEOUT_MS); - - it('marks "Reviewing Files" done at finalize even when a degrade path left it running', async () => { - // Regression: the review->finalize degrade doesn't mark "Reviewing Files" done, leaving the - // step stuck "In progress" on an otherwise-done job. Finalize now marks it defensively. - const job = await insertJob(env, { - installationId: '123', owner: 'test-owner', repo: uniqueRepo('revstuck'), - prNumber: 43, prTitle: 'Reviewing stuck', prAuthor: 'author', commitSha: sha('b'), baseSha: sha('0'), - trigger: 'auto', headRef: 'feature', baseRef: 'main', configSnapshot: defaultRepoConfig, - }); - await upsertFileReview(env, job.id, { - filePath: 'src/app.ts', fileStatus: 'done', modelUsed: 'test-model', modelProvider: 'test', - diffLineCount: 1, diffInput: 'x', rawAiOutput: '{}', parsedComments: [], inputTokens: 1, - outputTokens: 1, durationMs: 1, verdict: 'comment', fileSummary: 'ok', errorMessage: null, - }); - - await runWithDb(env, async () => { - // Reach finalize with "Reviewing Files" left 'running', as the continuation-ceiling degrade does. - await updateJobStep(env, job.id, 'Preparation', { status: 'done' }); - await updateJobStep(env, job.id, 'Reviewing Files', { status: 'running' }); - await queryRows(env, `UPDATE jobs SET status = 'running', file_count = 1, lease_owner = NULL, lease_expires_at = NULL WHERE id = $1`, [job.id]); - await runReviewJob(env, { jobId: job.id, deliveryId: 'delivery-revstuck', phase: 'finalize' }); - }); - - const final = await getJobForProcessing(env, job.id); - expect(final?.status).toBe('done'); - const reviewingStep = (final?.steps as Array<{ name: string; status: string }>).find((s) => s.name === 'Reviewing Files'); - expect(reviewingStep?.status).toBe('done'); - }, REVIEW_FLOW_TIMEOUT_MS); }); diff --git a/test/review/max-files.spec.ts b/test/review/max-files.spec.ts deleted file mode 100644 index 03dd95e3..00000000 --- a/test/review/max-files.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { defaultRepoConfig, reviewMaxFilesRange, reviewSettingsSchema } from '@codra/schema'; - -describe('review max files settings', () => { - it('defaults to 200', () => { - expect(reviewSettingsSchema.parse({}).maxFiles).toBe(200); - expect(reviewMaxFilesRange.default).toBe(200); - }); - - it('accepts the full configured range', () => { - expect(reviewSettingsSchema.parse({ maxFiles: reviewMaxFilesRange.min }).maxFiles).toBe(1); - expect(reviewSettingsSchema.parse({ maxFiles: reviewMaxFilesRange.max }).maxFiles).toBe(500); - }); - - it('rejects values outside the range', () => { - expect(() => reviewSettingsSchema.parse({ maxFiles: 0 })).toThrow(); - expect(() => reviewSettingsSchema.parse({ maxFiles: 501 })).toThrow(); - expect(() => reviewSettingsSchema.parse({ maxFiles: 12.5 })).toThrow(); - }); - - // It moved to global_settings; leaving it on the repo schema would let a stale per-repo value - // look authoritative while having no effect. - it('is no longer part of the per-repo config', () => { - expect('max_files' in defaultRepoConfig.review).toBe(false); - }); - -}); diff --git a/test/review/pipeline-regression.spec.ts b/test/review/pipeline-regression.spec.ts index f9db0958..e4063e37 100644 --- a/test/review/pipeline-regression.spec.ts +++ b/test/review/pipeline-regression.spec.ts @@ -97,10 +97,6 @@ describe('the parse-time chain, composed', () => { deniedClaimTypes: DEFAULT_DENIED_CLAIM_TYPES, }); - it('extracts the JSON from a markdown-fenced response with surrounding prose', () => { - expect(parsed.verdict).toBe('comment'); - expect(parsed.overallCorrectness).toBe('patch is incorrect'); - }); // Six findings in, exactly one survives. Asserting the surviving SET rather than a count means // a gate that stops firing shows up as a specific new title. @@ -133,17 +129,6 @@ describe('the parse-time chain, composed', () => { expect(parsed.comments.some((c) => c.title.includes('Invalid GitHub Action'))).toBe(false); }); - // Withheld findings are appended to `fileSummary` under "Off-diff", each tagged with WHY, so - // the dashboard can attribute a withholding to a gate instead of showing an empty review. - it('lists every withheld finding, tagged with the gate that withheld it', () => { - expect(parsed.fileSummary).toContain('Off-diff'); - expect(parsed.fileSummary).toMatch(/\[unverified:unmatched\]/); - expect(parsed.fileSummary).toMatch(/\[unverified:weak\]/); - expect(parsed.fileSummary).toMatch(/\[unverified:absent\]/); - expect(parsed.fileSummary).toMatch(/\[claim-denied:react_hook_missing_deps\]/); - expect(parsed.fileSummary).toMatch(/\[claim-denied:external_version_claim\]/); - }); - it('gives the surviving finding both identities and an anchor', () => { const [comment] = parsed.comments; expect(comment.fingerprint).toMatch(/^[0-9a-f]{8}$/); @@ -153,15 +138,6 @@ describe('the parse-time chain, composed', () => { expect(comment.line).toBe(12); }); - it('derives the category from the claim type instead of defaulting everything to quality', () => { - expect(parsed.comments[0].claimType).toBe('swallowed_error'); - expect(parsed.comments[0].category).toBe('bugs'); - }); - - it('is deterministic', () => { - const again = parseFileReviewResponse(response, file, { deniedClaimTypes: DEFAULT_DENIED_CLAIM_TYPES }); - expect(again.comments).toEqual(parsed.comments); - }); }); describe('a clean response', () => { diff --git a/test/review/quota-deferral.spec.ts b/test/review/quota-deferral.spec.ts index b3b5021e..bf0b63aa 100644 --- a/test/review/quota-deferral.spec.ts +++ b/test/review/quota-deferral.spec.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { isRetryableModelError, ModelService } from '@server/services/model'; -import { reviewWithGoogle } from '@server/models/google'; import { createTestEnv, saveTestProviderApiKey } from '../helpers'; import { defaultRepoConfig } from '@codra/schema'; @@ -34,34 +33,6 @@ function quotaResponse(retryInSeconds: number, model = 'gemini-3.1-pro-preview') describe('quota 429 handling', () => { afterEach(() => vi.restoreAllMocks()); - // Google asks for 30-60s while our in-call sleep caps at 5s, so retrying early only spends - // subrequests on a guaranteed second 429. - it('does not retry a 429 whose cool-off is longer than we are willing to wait', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => quotaResponse(56)); - - await expect( - reviewWithGoogle({ apiKey: 'k', providerName: 'Google' }, 'gemini-3.1-pro-preview', { - systemPrompt: 's', - userPrompt: 'u', - }), - ).rejects.toThrow(/429/); - - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it('still retries a 429 whose cool-off it can actually honour', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => quotaResponse(1)); - - await expect( - reviewWithGoogle({ apiKey: 'k', providerName: 'Google' }, 'gemini-3.1-pro-preview', { - systemPrompt: 's', - userPrompt: 'u', - }), - ).rejects.toThrow(/429/); - - expect(fetchMock.mock.calls.length).toBeGreaterThan(1); - }); - // The subrequest blowout: nine models x three attempts for one file. Each model has its own // bucket, so a couple of attempts are worth making, but past that the file must be deferred. it('stops walking a long fallback chain after two quota failures and defers the file', async () => { diff --git a/test/review/resilience.spec.ts b/test/review/resilience.spec.ts deleted file mode 100644 index c65958e6..00000000 --- a/test/review/resilience.spec.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getDiffFiles, failJobAndCheckRun } from '@server/core/review'; -import { createReviewRuntime } from '@server/adapters'; -import { createTestEnv, generateMockDiff } from '../helpers'; -import { defaultRepoConfig } from '@codra/schema'; - -// Regression coverage for the subrequest-exhaustion incident (job bb9cf692...): a large PR's -// review workflow re-fetched the PR diff from GitHub on every phase/chunk and, once the -// Worker's subrequest budget was exhausted, the failure-reporting path silently swallowed -// errors and could leave a job's DB failure state undone. These tests pin down the fixes for -// both without requiring a real Postgres connection or live GitHub/Workflow infrastructure. - -const { failJobMock, getJobForProcessingMock, markJobCheckRunCompletedMock } = vi.hoisted(() => ({ - failJobMock: vi.fn(), - getJobForProcessingMock: vi.fn(), - markJobCheckRunCompletedMock: vi.fn(), -})); - -vi.mock('@server/db/jobs', async (importOriginal) => { - const mod = await importOriginal(); - return { - ...mod, - failJob: failJobMock, - getJobForProcessing: getJobForProcessingMock, - markJobCheckRunCompleted: markJobCheckRunCompletedMock, - }; -}); - -describe('getDiffFiles', () => { - const baseJob = { owner: 'test-owner', repo: 'test-repo', prNumber: 26 }; - - it('fetches the PR diff from GitHub once and reuses the cached copy on later calls for the same job', async () => { - const env = createTestEnv(); - const job = { ...baseJob, id: `diff-cache-hit-${Date.now()}` }; - const rawDiff = generateMockDiff([{ path: 'src/app.ts', content: 'console.log(1);' }]); - const github = { getPullRequestDiff: vi.fn().mockResolvedValue(rawDiff) }; - - const { files: first } = await getDiffFiles(createReviewRuntime(env), job, github, defaultRepoConfig); - const { files: second } = await getDiffFiles(createReviewRuntime(env), job, github, defaultRepoConfig); - const { files: third } = await getDiffFiles(createReviewRuntime(env), job, github, defaultRepoConfig); - - expect(github.getPullRequestDiff).toHaveBeenCalledTimes(1); - expect(first.map((f) => f.path)).toEqual(['src/app.ts']); - expect(second.map((f) => f.path)).toEqual(['src/app.ts']); - expect(third.map((f) => f.path)).toEqual(['src/app.ts']); - }); - - it('does not share cached diffs across different jobs', async () => { - const env = createTestEnv(); - const jobA = { ...baseJob, id: `diff-cache-job-a-${Date.now()}` }; - const jobB = { ...baseJob, id: `diff-cache-job-b-${Date.now()}` }; - const githubA = { getPullRequestDiff: vi.fn().mockResolvedValue(generateMockDiff([{ path: 'src/one.ts', content: 'a' }])) }; - const githubB = { getPullRequestDiff: vi.fn().mockResolvedValue(generateMockDiff([{ path: 'src/two.ts', content: 'b' }])) }; - - const { files: filesA } = await getDiffFiles(createReviewRuntime(env), jobA, githubA, defaultRepoConfig); - const { files: filesB } = await getDiffFiles(createReviewRuntime(env), jobB, githubB, defaultRepoConfig); - - expect(githubA.getPullRequestDiff).toHaveBeenCalledTimes(1); - expect(githubB.getPullRequestDiff).toHaveBeenCalledTimes(1); - expect(filesA.map((f) => f.path)).toEqual(['src/one.ts']); - expect(filesB.map((f) => f.path)).toEqual(['src/two.ts']); - }); - - it('still returns the parsed files if caching the diff in KV fails', async () => { - const env = createTestEnv(); - (env.APP_KV as any).put = vi.fn().mockRejectedValue(new Error('KV unavailable')); - const job = { ...baseJob, id: `diff-cache-put-fail-${Date.now()}` }; - const github = { getPullRequestDiff: vi.fn().mockResolvedValue(generateMockDiff([{ path: 'src/app.ts', content: 'console.log(1);' }])) }; - - const { files } = await getDiffFiles(createReviewRuntime(env), job, github, defaultRepoConfig); - - expect(files.map((f) => f.path)).toEqual(['src/app.ts']); - // The next phase would simply re-fetch from GitHub since the cache write failed; it must - // not throw and break the job. - }); -}); - -describe('failJobAndCheckRun', () => { - const job = { id: 'job-fail-1', owner: 'test-owner', repo: 'test-repo', checkRunId: 42 }; - - beforeEach(() => { - failJobMock.mockReset(); - getJobForProcessingMock.mockReset(); - markJobCheckRunCompletedMock.mockReset(); - }); - - it('durably records the DB failure even when the GitHub check-run update fails (e.g. subrequest limit exhausted)', async () => { - const env = createTestEnv(); - failJobMock.mockResolvedValue(undefined); - getJobForProcessingMock.mockResolvedValue({ check_run_id: job.checkRunId }); - const updateCheckRun = vi.fn().mockRejectedValue(new Error('Too many subrequests by single Worker invocation.')); - - await expect(failJobAndCheckRun(createReviewRuntime(env), job, { updateCheckRun }, 'boom')).resolves.toBeUndefined(); - - // Use expect.anything() rather than the literal env: env's APP_PRIVATE_KEY getter - // deliberately throws for unused test secrets, and toHaveBeenCalledWith's deep-equality - // check would otherwise trigger it while walking env's own properties. - expect(failJobMock).toHaveBeenCalledWith(expect.anything(), job.id, 'boom'); - expect(updateCheckRun).toHaveBeenCalledTimes(1); - // Deliberately not marked complete: completeTerminalCheckRuns() picks this up and - // retries the GitHub update later, once a fresh invocation has its own subrequest budget. - expect(markJobCheckRunCompletedMock).not.toHaveBeenCalled(); - }); - - it('does not attempt the GitHub call at all if the DB write itself fails', async () => { - const env = createTestEnv(); - failJobMock.mockRejectedValue(new Error('Too many subrequests by single Worker invocation.')); - const updateCheckRun = vi.fn(); - - await expect(failJobAndCheckRun(createReviewRuntime(env), job, { updateCheckRun }, 'boom')).resolves.toBeUndefined(); - - expect(failJobMock).toHaveBeenCalledWith(expect.anything(), job.id, 'boom'); - expect(getJobForProcessingMock).not.toHaveBeenCalled(); - expect(updateCheckRun).not.toHaveBeenCalled(); - }); - - it('marks the check run completed once the GitHub update succeeds', async () => { - const env = createTestEnv(); - failJobMock.mockResolvedValue(undefined); - getJobForProcessingMock.mockResolvedValue({ check_run_id: job.checkRunId }); - const updateCheckRun = vi.fn().mockResolvedValue(undefined); - - await failJobAndCheckRun(createReviewRuntime(env), job, { updateCheckRun }, 'boom'); - - expect(updateCheckRun).toHaveBeenCalledWith( - job.owner, - job.repo, - job.checkRunId, - expect.objectContaining({ status: 'completed', conclusion: 'failure', summary: 'boom' }), - ); - expect(markJobCheckRunCompletedMock).toHaveBeenCalledWith(expect.anything(), job.id); - }); -}); diff --git a/test/review/resumable-queue.spec.ts b/test/review/resumable-queue.spec.ts index cd4417ce..3cd97d52 100644 --- a/test/review/resumable-queue.spec.ts +++ b/test/review/resumable-queue.spec.ts @@ -99,7 +99,9 @@ dbDescribe('resumable queue primitives', () => { [job.id], ); - const recovered = await recoverExpiredJobLeases(env, 3); + // Scoped to this job: the sweep is table-wide with LIMIT 25 + SKIP LOCKED, so with + // fileParallelism the stale 'running' rows of concurrent suites could crowd it out. + const recovered = await recoverExpiredJobLeases(env, 3, 300, [job.id]); expect(recovered.failedJobs.map((row) => row.id)).toContain(job.id); const row = await getJobForProcessing(env, job.id); @@ -134,7 +136,7 @@ dbDescribe('resumable queue primitives', () => { [job.id], ); - const recovered = await recoverExpiredJobLeases(env, 3, 120); + const recovered = await recoverExpiredJobLeases(env, 3, 120, [job.id]); expect(recovered.requeuedJobIds).toContain(job.id); const row = await getJobForProcessing(env, job.id); @@ -173,7 +175,9 @@ dbDescribe('resumable queue primitives', () => { await markJobContinuationQueued(env, job.id); await releaseJobLease(env, job.id, 'lease-a'); - const recovered = await recoverExpiredJobLeases(env, 3, 120); + // Also scoped, so the empty result proves the grace-period rule held rather than that the job + // simply fell outside the batch window. + const recovered = await recoverExpiredJobLeases(env, 3, 120, [job.id]); expect(recovered.requeuedJobIds).not.toContain(job.id); const row = await getJobForProcessing(env, job.id); diff --git a/test/review/token-split.spec.ts b/test/review/token-split.spec.ts deleted file mode 100644 index 752a7ee0..00000000 --- a/test/review/token-split.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { proportionalSplit } from '@server/core/review'; - -// The parts must sum to exactly what the call cost: usage stats are computed from these columns. -describe('proportionalSplit', () => { - // Exactness covers both hazards: flooring loses tokens, and an all-zero-weight - // bin divides by zero. - it('sums to exactly the total and splits by weight, not evenly', () => { - for (const total of [0, 1, 7, 100, 4_097, 999_983]) { - for (const weights of [[1], [1, 1], [3, 1], [1, 2, 3, 4], [7, 7, 7, 7, 7, 7], [0, 0, 0]]) { - const parts = proportionalSplit(total, weights); - expect(parts.reduce((a, b) => a + b, 0)).toBe(total); - expect(parts).toHaveLength(weights.length); - expect(parts.every((p) => Number.isInteger(p) && p >= 0)).toBe(true); - } - } - - expect(proportionalSplit(100, [90, 10])).toEqual([90, 10]); - // 10 over [5,1,1] floors to 7,1,1 = 9; the leftover token goes to the heaviest weight. - expect(proportionalSplit(10, [5, 1, 1])[0]).toBe(8); - }); -}); From 785231b7816de1823e068e72ac157540f99b93c2 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Thu, 13 Aug 2026 06:41:15 +0530 Subject: [PATCH 5/6] refactor: improve regex patterns for JSON block extraction in claim checks and model output --- packages/core/src/claim-checks.ts | 4 ++-- packages/core/src/model-output/json.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/claim-checks.ts b/packages/core/src/claim-checks.ts index 511757e5..1f991068 100644 --- a/packages/core/src/claim-checks.ts +++ b/packages/core/src/claim-checks.ts @@ -43,8 +43,8 @@ const CROSS_FILE_CONSEQUENCE = /\b(?:break|breaks|breaking|broken|fail|fails|fai 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,100}\b(?:fails?|failing|rejects?|rejecting|throws?|throwing|errors? out)\b/i; -const CALLEE_CALL_SHAPE = /[\w.$]+\s*\(\s*\)|`[\w.$]+\(/; +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'; diff --git a/packages/core/src/model-output/json.ts b/packages/core/src/model-output/json.ts index 9bcb0716..0d31406a 100644 --- a/packages/core/src/model-output/json.ts +++ b/packages/core/src/model-output/json.ts @@ -47,12 +47,12 @@ function scanBalanced(raw: string, startIdx: number, open: string, close: string } export function extractJson(raw: string, anchorKey: 'findings' | 'files' = 'findings') { - const jsonBlocks = Array.from(raw.matchAll(/```json\s*([\s\S]*?)```/gi)); + 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\S]*?)```/gi)); + 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) { From c996af1409eaafa4e8a5ede39cd6bba119363cf8 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Thu, 13 Aug 2026 07:54:33 +0530 Subject: [PATCH 6/6] refactor: standardize import formatting and clean up unused code across multiple files --- packages/core/src/ports/file-reviews.ts | 224 ++++++++++++------------ packages/core/src/ports/formatter.ts | 2 +- packages/core/src/ports/github.ts | 90 +++++----- packages/core/src/ports/index.ts | 20 +-- packages/core/src/ports/jobs.ts | 166 +++++++++--------- packages/core/src/ports/model.ts | 142 +++++++-------- packages/core/src/ports/platform.ts | 30 ++-- packages/core/src/ports/runtime.ts | 6 +- packages/core/src/ports/settings.ts | 2 +- packages/core/src/ports/telemetry.ts | 2 +- scripts/check-core-boundary.mjs | 81 +++++++-- test/findings/rules-pipeline.spec.ts | 14 +- test/model/output.spec.ts | 18 ++ test/model/service-fallbacks.spec.ts | 20 +++ 14 files changed, 444 insertions(+), 373 deletions(-) diff --git a/packages/core/src/ports/file-reviews.ts b/packages/core/src/ports/file-reviews.ts index 6fc414ff..742a1e88 100644 --- a/packages/core/src/ports/file-reviews.ts +++ b/packages/core/src/ports/file-reviews.ts @@ -1,112 +1,112 @@ -import type { ParsedReviewComment } from '@codra/schema'; - - -export type FileReviewRow = { - 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[]; - 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 }; - batch_size: number | null; -}; - -export type SuppressedFinding = { - fingerprint: string | null; - anchor_hash: string | null; - fingerprint_v2: string | null; - anchored: boolean; -}; - -export type BulkFileReviewInput = { - filePath: string; - fileStatus: 'pending' | 'done' | 'skipped' | 'failed'; - modelUsed: string; - modelProvider?: string | null; - diffLineCount: number; - 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; - withheldCounts?: { evidence: number; claimDenied: number } | null; - batchSize: number; -}; - -export interface FileReviewStore { - upsertFileReview(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; - withheldCounts?: { evidence: number; claimDenied: number } | null; - asyncRequestId?: string | null; - asyncModel?: string | null; - }): Promise; - - recordRetryableFileReviewFailure(jobId: string, input: { - filePath: string; - modelUsed: string; - modelProvider?: string | null; - diffLineCount: number; - diffInput: string | null; - durationMs: number | null; - errorMessage: string; - countsAsAttempt?: boolean; - }): Promise; - - getFileReviewsForJobs(jobIds: string[]): Promise; - - bulkInheritFileReviews(input: { jobId: string; parentJobId: string; filePaths: string[] }): Promise; - bulkUpsertFileReviews(jobId: string, inputs: BulkFileReviewInput[]): Promise; - bulkRecordRetryableFileReviewFailures( - jobId: string, - inputs: Array<{ filePath: string; modelUsed: string; diffLineCount: number; errorMessage: string }>, - opts?: { countsAsAttempt?: boolean }, - ): Promise>; - bulkMarkFilesFailed( - jobId: string, - files: Array<{ filePath: string; diffLineCount: number }>, - opts: { modelUsed: string; errorMessage: string }, - ): Promise; - - getSuppressedFindings(jobId: string): Promise; - markCommentsPosted(jobId: string, fingerprints: string[]): Promise; - markCommentDispositions( - jobId: string, - byFingerprint: Map, - ): Promise; -} +import type { ParsedReviewComment } from '@codra/schema'; + + +export type FileReviewRow = { + 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[]; + 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 }; + batch_size: number | null; +}; + +export type SuppressedFinding = { + fingerprint: string | null; + anchor_hash: string | null; + fingerprint_v2: string | null; + anchored: boolean; +}; + +export type BulkFileReviewInput = { + filePath: string; + fileStatus: 'pending' | 'done' | 'skipped' | 'failed'; + modelUsed: string; + modelProvider?: string | null; + diffLineCount: number; + 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; + withheldCounts?: { evidence: number; claimDenied: number } | null; + batchSize: number; +}; + +export interface FileReviewStore { + upsertFileReview(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; + withheldCounts?: { evidence: number; claimDenied: number } | null; + asyncRequestId?: string | null; + asyncModel?: string | null; + }): Promise; + + recordRetryableFileReviewFailure(jobId: string, input: { + filePath: string; + modelUsed: string; + modelProvider?: string | null; + diffLineCount: number; + diffInput: string | null; + durationMs: number | null; + errorMessage: string; + countsAsAttempt?: boolean; + }): Promise; + + getFileReviewsForJobs(jobIds: string[]): Promise; + + bulkInheritFileReviews(input: { jobId: string; parentJobId: string; filePaths: string[] }): Promise; + bulkUpsertFileReviews(jobId: string, inputs: BulkFileReviewInput[]): Promise; + bulkRecordRetryableFileReviewFailures( + jobId: string, + inputs: Array<{ filePath: string; modelUsed: string; diffLineCount: number; errorMessage: string }>, + opts?: { countsAsAttempt?: boolean }, + ): Promise>; + bulkMarkFilesFailed( + jobId: string, + files: Array<{ filePath: string; diffLineCount: number }>, + opts: { modelUsed: string; errorMessage: string }, + ): Promise; + + getSuppressedFindings(jobId: string): Promise; + markCommentsPosted(jobId: string, fingerprints: string[]): Promise; + markCommentDispositions( + jobId: string, + byFingerprint: Map, + ): Promise; +} diff --git a/packages/core/src/ports/formatter.ts b/packages/core/src/ports/formatter.ts index 284dca0f..e8fe4eb1 100644 --- a/packages/core/src/ports/formatter.ts +++ b/packages/core/src/ports/formatter.ts @@ -1,4 +1,4 @@ -import type { ParsedReviewComment } from '@codra/schema'; +import type { ParsedReviewComment } from '@codra/schema'; export interface ReviewFormatter { toReviewEvent(verdict: 'approve' | 'comment'): 'APPROVE' | 'COMMENT'; diff --git a/packages/core/src/ports/github.ts b/packages/core/src/ports/github.ts index 2c7f90cf..e14eebec 100644 --- a/packages/core/src/ports/github.ts +++ b/packages/core/src/ports/github.ts @@ -1,45 +1,45 @@ - -export type PullRequestRecord = { - number: number; - title: string | null; - body: string | null; - draft: boolean; - head: { sha: string; ref: string }; - base: { sha: string; ref: string }; - user: { login: string }; -}; - -export type GitHubReviewComment = { - path: string; - line?: number; - side?: 'LEFT' | 'RIGHT'; - position?: number; - body: string; -}; - -export interface ReviewGitHub { - getPullRequest(owner: string, repo: string, prNumber: number): Promise; - getPullRequestDiff(owner: string, repo: string, prNumber: number): Promise; - getCompareDiff(owner: string, repo: string, base: string, head: string): Promise; - createCheckRun(owner: string, repo: string, params: { headSha: string; title: string; summary: string }): Promise<{ id: number }>; - updateCheckRun(owner: string, repo: string, checkRunId: number, params: { - title: string; - summary: string; - status?: 'in_progress' | 'completed'; - conclusion?: 'success' | 'neutral' | 'failure' | 'cancelled'; - }): Promise; - createReview(owner: string, repo: string, prNumber: number, params: { - commitSha: string; - event: 'APPROVE' | 'COMMENT'; - body: string; - comments: GitHubReviewComment[]; - }): Promise<{ id: number; postedIndices?: number[] }>; - findBotReviewForCommit(owner: string, repo: string, prNumber: number, commitSha: string, botLogin: string): Promise<{ id: number } | null>; - ensureLabel(owner: string, repo: string, name: string, color: string): Promise; - addIssueLabels(owner: string, repo: string, prNumber: number, labels: string[]): Promise; - removeIssueLabelsIfPresent(owner: string, repo: string, prNumber: number, labels: string[]): Promise; -} - -export interface GitHubClientFactory { - forInstallation(installationId: string): ReviewGitHub; -} + +export type PullRequestRecord = { + number: number; + title: string | null; + body: string | null; + draft: boolean; + head: { sha: string; ref: string }; + base: { sha: string; ref: string }; + user: { login: string }; +}; + +export type GitHubReviewComment = { + path: string; + line?: number; + side?: 'LEFT' | 'RIGHT'; + position?: number; + body: string; +}; + +export interface ReviewGitHub { + getPullRequest(owner: string, repo: string, prNumber: number): Promise; + getPullRequestDiff(owner: string, repo: string, prNumber: number): Promise; + getCompareDiff(owner: string, repo: string, base: string, head: string): Promise; + createCheckRun(owner: string, repo: string, params: { headSha: string; title: string; summary: string }): Promise<{ id: number }>; + updateCheckRun(owner: string, repo: string, checkRunId: number, params: { + title: string; + summary: string; + status?: 'in_progress' | 'completed'; + conclusion?: 'success' | 'neutral' | 'failure' | 'cancelled'; + }): Promise; + createReview(owner: string, repo: string, prNumber: number, params: { + commitSha: string; + event: 'APPROVE' | 'COMMENT'; + body: string; + comments: GitHubReviewComment[]; + }): Promise<{ id: number; postedIndices?: number[] }>; + findBotReviewForCommit(owner: string, repo: string, prNumber: number, commitSha: string, botLogin: string): Promise<{ id: number } | null>; + ensureLabel(owner: string, repo: string, name: string, color: string): Promise; + addIssueLabels(owner: string, repo: string, prNumber: number, labels: string[]): Promise; + removeIssueLabelsIfPresent(owner: string, repo: string, prNumber: number, labels: string[]): Promise; +} + +export interface GitHubClientFactory { + forInstallation(installationId: string): ReviewGitHub; +} diff --git a/packages/core/src/ports/index.ts b/packages/core/src/ports/index.ts index 8b8687aa..31bd70f3 100644 --- a/packages/core/src/ports/index.ts +++ b/packages/core/src/ports/index.ts @@ -1,10 +1,10 @@ - -export type { Clock, IdGenerator, KvStore, Logger } from './platform'; -export type { JobLeaseClaim, JobRow, JobStore, PersistedReviewJob } from './jobs'; -export type { BulkFileReviewInput, FileReviewRow, FileReviewStore, SuppressedFinding } from './file-reviews'; -export type { LearningStore, ModelConfigReader, RepoConfigLoader, ReviewSettingsReader, WebhookDeliveryReader } from './settings'; -export type { GitHubClientFactory, GitHubReviewComment, PullRequestRecord, ReviewGitHub } from './github'; -export type { FileReviewOutcome, ModelErrorClassifier, ModelResponse, ModelResponseSchema, ReviewModel } from './model'; -export type { ReviewFormatter } from './formatter'; -export type { ReviewTelemetryEvent, TelemetrySink } from './telemetry'; -export type { ReviewRuntime } from './runtime'; + +export type { Clock, IdGenerator, KvStore, Logger } from './platform'; +export type { JobLeaseClaim, JobRow, JobStore, PersistedReviewJob } from './jobs'; +export type { BulkFileReviewInput, FileReviewRow, FileReviewStore, SuppressedFinding } from './file-reviews'; +export type { LearningStore, ModelConfigReader, RepoConfigLoader, ReviewSettingsReader, WebhookDeliveryReader } from './settings'; +export type { GitHubClientFactory, GitHubReviewComment, PullRequestRecord, ReviewGitHub } from './github'; +export type { FileReviewOutcome, ModelErrorClassifier, ModelResponse, ModelResponseSchema, ReviewModel } from './model'; +export type { ReviewFormatter } from './formatter'; +export type { ReviewTelemetryEvent, TelemetrySink } from './telemetry'; +export type { ReviewRuntime } from './runtime'; diff --git a/packages/core/src/ports/jobs.ts b/packages/core/src/ports/jobs.ts index 817c0d5f..fcda4594 100644 --- a/packages/core/src/ports/jobs.ts +++ b/packages/core/src/ports/jobs.ts @@ -1,83 +1,83 @@ -import type { JobSummary, RepoConfig } from '@codra/schema'; - - -export type PersistedReviewJob = JobSummary; - -export type JobRow = { - status: 'queued' | 'running' | 'done' | 'failed' | 'superseded' | 'cancelled' | 'stopped'; - check_run_id: number | null; - [column: string]: unknown; -}; - -export type JobLeaseClaim = - | { status: 'claimed'; row: JobRow } - | { status: 'busy'; row: JobRow; retryAfterSeconds: number } - | { status: 'terminal'; row: JobRow } - | { status: 'missing' }; - -export interface JobStore { - mapJob(row: JobRow): PersistedReviewJob; - - getJobForProcessing(jobId: string): Promise; - claimJobLease(jobId: string, leaseOwner: string, leaseSeconds: number): Promise; - heartbeatJobLease(jobId: string, leaseOwner: string, leaseSeconds: number): Promise; - releaseJobLease(jobId: string, leaseOwner: string): Promise; - markJobContinuationQueued(jobId: string, delaySeconds?: number): Promise; - resetJobContinuationCount(jobId: string): Promise; - getOtherRunningJobsCount(excludeJobId: string): Promise; - - setJobWorkflowInstance(jobId: string, workflowInstanceId: string): Promise; - setJobPullRequestMeta(jobId: string, meta: { prTitle: string | null; prAuthor: string | null }): Promise; - insertJob(input: { - installationId: string; - owner: string; - repo: string; - prNumber: number; - prTitle: string | null; - prAuthor: string | null; - commitSha: string; - baseSha: string; - trigger: 'auto' | 'mention' | 'retry'; - headRef: string | null; - baseRef: string | null; - configSnapshot?: RepoConfig | null; - retryOfJobId?: string | null; - }): Promise; - findExistingJobForHead(input: { - owner: string; - repo: string; - prNumber: number; - commitSha: string; - trigger: 'auto' | 'mention'; - }): Promise; - - updateJobCheckRun(jobId: string, checkRunId: number): Promise; - markJobCheckRunCompleted(jobId: string): Promise; - completePreparationStep(jobId: string, fileCount: number): Promise; - updateJobStep(jobId: string, stepName: string, update: { - status: 'pending' | 'running' | 'done' | 'failed'; - startedAt?: string | null; - finishedAt?: string | null; - error?: string | null; - }): Promise; - completeJob(jobId: string, input: { - verdict: 'approve' | 'comment'; - fileCount: number; - commentCount: number; - totalInputTokens: number; - totalOutputTokens: number; - summaryMarkdown: string; - reviewId: number | null; - summaryModel: string | null; - overallConfidenceScore?: number | null; - errorMessage?: string | null; - }): Promise; - failJob(jobId: string, errorMessage: string): Promise; - supersedeOlderJobs(input: { - installationId: string; - owner: string; - repo: string; - prNumber: number; - newJobId: string; - }): Promise; -} +import type { JobSummary, RepoConfig } from '@codra/schema'; + + +export type PersistedReviewJob = JobSummary; + +export type JobRow = { + status: 'queued' | 'running' | 'done' | 'failed' | 'superseded' | 'cancelled' | 'stopped'; + check_run_id: number | null; + [column: string]: unknown; +}; + +export type JobLeaseClaim = + | { status: 'claimed'; row: JobRow } + | { status: 'busy'; row: JobRow; retryAfterSeconds: number } + | { status: 'terminal'; row: JobRow } + | { status: 'missing' }; + +export interface JobStore { + mapJob(row: JobRow): PersistedReviewJob; + + getJobForProcessing(jobId: string): Promise; + claimJobLease(jobId: string, leaseOwner: string, leaseSeconds: number): Promise; + heartbeatJobLease(jobId: string, leaseOwner: string, leaseSeconds: number): Promise; + releaseJobLease(jobId: string, leaseOwner: string): Promise; + markJobContinuationQueued(jobId: string, delaySeconds?: number): Promise; + resetJobContinuationCount(jobId: string): Promise; + getOtherRunningJobsCount(excludeJobId: string): Promise; + + setJobWorkflowInstance(jobId: string, workflowInstanceId: string): Promise; + setJobPullRequestMeta(jobId: string, meta: { prTitle: string | null; prAuthor: string | null }): Promise; + insertJob(input: { + installationId: string; + owner: string; + repo: string; + prNumber: number; + prTitle: string | null; + prAuthor: string | null; + commitSha: string; + baseSha: string; + trigger: 'auto' | 'mention' | 'retry'; + headRef: string | null; + baseRef: string | null; + configSnapshot?: RepoConfig | null; + retryOfJobId?: string | null; + }): Promise; + findExistingJobForHead(input: { + owner: string; + repo: string; + prNumber: number; + commitSha: string; + trigger: 'auto' | 'mention'; + }): Promise; + + updateJobCheckRun(jobId: string, checkRunId: number): Promise; + markJobCheckRunCompleted(jobId: string): Promise; + completePreparationStep(jobId: string, fileCount: number): Promise; + updateJobStep(jobId: string, stepName: string, update: { + status: 'pending' | 'running' | 'done' | 'failed'; + startedAt?: string | null; + finishedAt?: string | null; + error?: string | null; + }): Promise; + completeJob(jobId: string, input: { + verdict: 'approve' | 'comment'; + fileCount: number; + commentCount: number; + totalInputTokens: number; + totalOutputTokens: number; + summaryMarkdown: string; + reviewId: number | null; + summaryModel: string | null; + overallConfidenceScore?: number | null; + errorMessage?: string | null; + }): Promise; + failJob(jobId: string, errorMessage: string): Promise; + supersedeOlderJobs(input: { + installationId: string; + owner: string; + repo: string; + prNumber: number; + newJobId: string; + }): Promise; +} diff --git a/packages/core/src/ports/model.ts b/packages/core/src/ports/model.ts index 1672aaf3..1118e578 100644 --- a/packages/core/src/ports/model.ts +++ b/packages/core/src/ports/model.ts @@ -1,71 +1,71 @@ -import type { RepoConfig } from '@codra/schema'; -import type { FileDiff } from '../diff'; -import type { BatchReviewResult, parseFileReviewResponse } from '../model-output'; -import type { RejectedExemplar } from '../prompts/file-review'; -import type { VerifyCandidate } from '../prompts/verify'; - -type ParsedFileReview = ReturnType; - -export type ModelResponse = { - rawText: string; - inputTokens: number; - outputTokens: number; - modelUsed: string; - provider: string; - degraded?: 'schema-dropped'; -}; - -export type ModelResponseSchema = { - name: string; - schema: Record; -}; - -export type FileReviewOutcome = ModelResponse & { - parsed: ParsedFileReview; - reviewedLineCount: number; - wasPromptTruncated: boolean; - userPrompt: string; -}; - -export interface ReviewModel { - reviewFile(params: { - file: FileDiff; - prTitle: string | null; - prDescription: string | null; - config: RepoConfig; - totalLineCount: number; - compactPrompt?: boolean; - rejectedExemplars?: readonly RejectedExemplar[]; - }): Promise; - - reviewFiles(params: { - files: readonly FileDiff[]; - prTitle: string | null; - prDescription: string | null; - config: RepoConfig; - totalLineCount: number; - rejectedExemplars?: readonly RejectedExemplar[]; - }): Promise; - - submitReviewBatch(params: { - file: FileDiff; - prTitle: string | null; - prDescription: string | null; - config: RepoConfig; - totalLineCount: number; - compactPrompt?: boolean; - }): Promise<{ requestId: string; model: string } | null>; - - pollReviewBatch(params: { model: string; requestId: string; file: FileDiff; config: RepoConfig }): Promise< - | { status: 'pending' } - | { status: 'done'; response: FileReviewOutcome } - | { status: 'failed'; error: unknown } - >; - - verifyFindings(params: { candidates: VerifyCandidate[]; config: RepoConfig }): Promise; -} - -export interface ModelErrorClassifier { - isRetryableModelError(error: unknown): boolean; - nextChainIndexOf(error: unknown): number | null; -} +import type { RepoConfig } from '@codra/schema'; +import type { FileDiff } from '../diff'; +import type { BatchReviewResult, parseFileReviewResponse } from '../model-output'; +import type { RejectedExemplar } from '../prompts/file-review'; +import type { VerifyCandidate } from '../prompts/verify'; + +type ParsedFileReview = ReturnType; + +export type ModelResponse = { + rawText: string; + inputTokens: number; + outputTokens: number; + modelUsed: string; + provider: string; + degraded?: 'schema-dropped'; +}; + +export type ModelResponseSchema = { + name: string; + schema: Record; +}; + +export type FileReviewOutcome = ModelResponse & { + parsed: ParsedFileReview; + reviewedLineCount: number; + wasPromptTruncated: boolean; + userPrompt: string; +}; + +export interface ReviewModel { + reviewFile(params: { + file: FileDiff; + prTitle: string | null; + prDescription: string | null; + config: RepoConfig; + totalLineCount: number; + compactPrompt?: boolean; + rejectedExemplars?: readonly RejectedExemplar[]; + }): Promise; + + reviewFiles(params: { + files: readonly FileDiff[]; + prTitle: string | null; + prDescription: string | null; + config: RepoConfig; + totalLineCount: number; + rejectedExemplars?: readonly RejectedExemplar[]; + }): Promise; + + submitReviewBatch(params: { + file: FileDiff; + prTitle: string | null; + prDescription: string | null; + config: RepoConfig; + totalLineCount: number; + compactPrompt?: boolean; + }): Promise<{ requestId: string; model: string } | null>; + + pollReviewBatch(params: { model: string; requestId: string; file: FileDiff; config: RepoConfig }): Promise< + | { status: 'pending' } + | { status: 'done'; response: FileReviewOutcome } + | { status: 'failed'; error: unknown } + >; + + verifyFindings(params: { candidates: VerifyCandidate[]; config: RepoConfig }): Promise; +} + +export interface ModelErrorClassifier { + isRetryableModelError(error: unknown): boolean; + nextChainIndexOf(error: unknown): number | null; +} diff --git a/packages/core/src/ports/platform.ts b/packages/core/src/ports/platform.ts index 21faea9e..67ba0c2a 100644 --- a/packages/core/src/ports/platform.ts +++ b/packages/core/src/ports/platform.ts @@ -1,15 +1,15 @@ - -export interface KvStore { - get(key: string): Promise; - put(key: string, value: string, options?: { expirationTtl?: number }): Promise; -} - -export interface Clock { - now(): number; -} - -export interface IdGenerator { - randomUUID(): string; -} - -export type { Logger } from '../logger'; + +export interface KvStore { + get(key: string): Promise; + put(key: string, value: string, options?: { expirationTtl?: number }): Promise; +} + +export interface Clock { + now(): number; +} + +export interface IdGenerator { + randomUUID(): string; +} + +export type { Logger } from '../logger'; diff --git a/packages/core/src/ports/runtime.ts b/packages/core/src/ports/runtime.ts index 9affe1fa..df24b9d6 100644 --- a/packages/core/src/ports/runtime.ts +++ b/packages/core/src/ports/runtime.ts @@ -1,4 +1,4 @@ -import type { TokenTracker } from '../token-tracker'; +import type { TokenTracker } from '../token-tracker'; import type { Clock, IdGenerator, KvStore } from './platform'; import type { FileReviewStore } from './file-reviews'; import type { GitHubClientFactory, ReviewGitHub } from './github'; @@ -12,9 +12,7 @@ export interface ReviewRuntime { kv: KvStore; clock: Clock; ids: IdGenerator; - - botUsername: string; - + botUsername: string; jobs: JobStore; fileReviews: FileReviewStore; settings: ReviewSettingsReader; diff --git a/packages/core/src/ports/settings.ts b/packages/core/src/ports/settings.ts index 5f98cb05..ca658c62 100644 --- a/packages/core/src/ports/settings.ts +++ b/packages/core/src/ports/settings.ts @@ -1,4 +1,4 @@ -import type { ClaimType, RepoConfig, ReviewSettings } from '@codra/schema'; +import type { ClaimType, RepoConfig, ReviewSettings } from '@codra/schema'; export interface ReviewSettingsReader { getReviewSettings(): Promise; diff --git a/packages/core/src/ports/telemetry.ts b/packages/core/src/ports/telemetry.ts index 926b6691..3037b8a2 100644 --- a/packages/core/src/ports/telemetry.ts +++ b/packages/core/src/ports/telemetry.ts @@ -1,4 +1,4 @@ -export type ReviewTelemetryEvent = { +export type ReviewTelemetryEvent = { linesReviewed: number; findingsReported: number; inputTokens: number; diff --git a/scripts/check-core-boundary.mjs b/scripts/check-core-boundary.mjs index 68bdeadc..3bcb5394 100644 --- a/scripts/check-core-boundary.mjs +++ b/scripts/check-core-boundary.mjs @@ -8,26 +8,48 @@ // So this script checks the manifest AND bans the identifiers by name. import { readFileSync, readdirSync, statSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { dirname, join, relative, resolve, sep } from 'node:path'; const ROOT = join(import.meta.dirname, '..'); const PKG = join(ROOT, 'packages/core'); const BANNED_DEPS = ['hono', 'postgres', 'wrangler', '@cloudflare/workers-types', '@octokit/rest', '@octokit/core']; -// Import specifiers no file in the package may name. -const BANNED_SPECIFIERS = [ - "from 'hono'", - "from 'postgres'", - "from 'cloudflare:workers'", - "from 'node:async_hooks'", - "from '@server/", - "from '@client/", - "from '@codra/worker", - '../../src/', - '../../../src/', +// Module specifiers no file in the package may import, matched against the actual specifier +// string of every import/export/require in the file (any quote style, static or dynamic). +// A trailing '/' entry bans the package and everything under it; 'src/' bans any relative +// path that climbs out of the package into the legacy tree. +const BANNED_MODULES = [ + 'hono', + 'postgres', + 'cloudflare:workers', + 'node:async_hooks', + '@server/', + '@client/', + '@codra/worker', ]; +function isBannedModule(specifier, fileDir) { + for (const banned of BANNED_MODULES) { + if (specifier === banned || specifier === banned.replace(/\/$/, '') || specifier.startsWith(banned.endsWith('/') ? banned : `${banned}/`)) { + return true; + } + } + // Any relative import that resolves outside the package into the repo's legacy src/ tree. + if (specifier.startsWith('.')) { + const resolved = resolve(fileDir, specifier); + return resolved === join(ROOT, 'src') || resolved.startsWith(join(ROOT, 'src') + sep); + } + return false; +} + +// Every module specifier the file names: `import ... from 'x'`, `export ... from 'x'`, +// side-effect `import 'x'`, dynamic `import('x')`, and `require('x')`. +function* moduleSpecifiers(source) { + const pattern = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)(['"])([^'"\n]+)\1/g; + for (const match of source.matchAll(pattern)) yield match[2]; +} + // Types and classes whose presence means a port was bypassed. Type-only imports of these are the // exact regression this half of the check is for. const BANNED_IDENTIFIERS = [ @@ -75,9 +97,9 @@ for (const dir of ['src', 'test']) { const source = readFileSync(file, 'utf8'); const where = relative(ROOT, file).replaceAll('\\', '/'); - for (const specifier of BANNED_SPECIFIERS) { - if (source.includes(specifier)) { - failures.push(`${where}: must not import ${specifier.replace("from '", '').replace(/'$/, '')}`); + for (const specifier of moduleSpecifiers(source)) { + if (isBannedModule(specifier, dirname(file))) { + failures.push(`${where}: must not import ${specifier}`); } } @@ -92,9 +114,34 @@ for (const dir of ['src', 'test']) { } // Comments in core legitimately explain what a port replaced ("was env.BOT_USERNAME", "mirrors the -// GitHubService surface"), so the identifier scan runs over code only. +// GitHubService surface"), so the identifier scan runs over code only. A single-pass scanner +// rather than regexes so `//` and `/*` inside string or template literals are left alone. function stripComments(source) { - return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1'); + let out = ''; + let i = 0; + while (i < source.length) { + const ch = source[i]; + const next = source[i + 1]; + if (ch === '/' && next === '/') { + while (i < source.length && source[i] !== '\n') i++; + } else if (ch === '/' && next === '*') { + i += 2; + while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++; + i += 2; + } else if (ch === "'" || ch === '"' || ch === '`') { + out += ch; + i++; + while (i < source.length && source[i] !== ch) { + if (source[i] === '\\') { out += source[i]; i++; } + if (i < source.length) { out += source[i]; i++; } + } + if (i < source.length) { out += ch; i++; } + } else { + out += ch; + i++; + } + } + return out; } if (failures.length > 0) { diff --git a/test/findings/rules-pipeline.spec.ts b/test/findings/rules-pipeline.spec.ts index 81c23c53..b9f27854 100644 --- a/test/findings/rules-pipeline.spec.ts +++ b/test/findings/rules-pipeline.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { dedupeFindings } from '@server/core/model-output'; import { ruleHitsToComments, scanFileForRuleHits } from '@server/core/rules/detect'; -import { defaultRepoConfig, type ParsedReviewComment } from '@codra/schema'; +import { defaultRepoConfig } from '@codra/schema'; import type { FileDiff } from '@server/core/diff'; import { addedLinesFile } from '../mocks/fixtures'; @@ -10,18 +10,6 @@ const fileWith = addedLinesFile; const liveRules = (file: FileDiff) => ruleHitsToComments(file, scanFileForRuleHits(file, { shadowRuleIds: [] })); -const _llmComment = (over: Partial = {}): ParsedReviewComment => ({ - path: 'src/a.ts', - line: 1, - position: 1, - severity: 'P1', - category: 'bugs', - title: 'An LLM finding', - body: 'Body', - evidence: ' } catch (e) {}', - ...over, -}); - describe('the rule channel in the pipeline', () => { // The recall argument, stated as a test. If the model returns nothing the deterministic channel // must still produce a candidate - otherwise it buys nothing over a better prompt. diff --git a/test/model/output.spec.ts b/test/model/output.spec.ts index 0b08d6d1..e38b4ce7 100644 --- a/test/model/output.spec.ts +++ b/test/model/output.spec.ts @@ -66,6 +66,24 @@ unescaped newlines", 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 = ` diff --git a/test/model/service-fallbacks.spec.ts b/test/model/service-fallbacks.spec.ts index b42cc644..5307bc26 100644 --- a/test/model/service-fallbacks.spec.ts +++ b/test/model/service-fallbacks.spec.ts @@ -215,6 +215,26 @@ describe('ModelService: chain fallback, budget breakers and provider availabilit 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 = new ModelService(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(