diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c52ff9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1c4d0b1..2e4b381 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,7 +3,7 @@ name: Publish to npm on: push: tags: - - "v*" + - 'v*' permissions: id-token: write diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..5427dc4 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,35 @@ +name: Quality + +on: + pull_request: + push: + branches: + - main + - develop + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - run: bun install --frozen-lockfile + + - run: bun run lint + + - run: bun run format:check + + - run: bun run typecheck + + - run: bun run build + + - run: bun run test + + - run: bunx publint packages/cli + + - run: bunx publint packages/contract diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..a2e643d --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,65 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "categories": { + "correctness": "error", + "perf": "error", + "suspicious": "error" + }, + "rules": { + "complexity": ["warn", 10], + "curly": ["error", "all"], + "eqeqeq": ["error", "always", { "null": "ignore" }], + "max-depth": ["warn", 3], + "max-lines": ["warn", { "max": 400, "skipBlankLines": true, "skipComments": true }], + "max-lines-per-function": ["warn", { "max": 60, "skipBlankLines": true, "skipComments": true }], + "max-nested-callbacks": ["warn", 3], + "max-params": ["error", { "max": 4 }], + "no-await-in-loop": "off", + "no-else-return": ["error", { "allowElseIf": false }], + "no-lonely-if": "error", + "no-param-reassign": "error", + "no-underscore-dangle": [ + "error", + { "allow": ["__CODESEMA_VERSION__", "__CODESEMA_LOCALE__", "__CODESEMA_FIX_TOKEN__"] } + ], + "oxc/no-map-spread": "off", + "typescript/consistent-type-definitions": ["error", "type"], + "typescript/no-explicit-any": "error", + "typescript/no-non-null-assertion": "error", + "typescript/no-require-imports": "error", + "unicorn/consistent-function-scoping": "warn", + "unicorn/filename-case": ["error", { "case": "kebabCase" }], + "unicorn/prefer-node-protocol": "error" + }, + "overrides": [ + { + "files": ["**/*.test.ts"], + "rules": { + "max-lines": "off", + "max-lines-per-function": "off", + "max-nested-callbacks": ["warn", 5], + "typescript/no-non-null-assertion": "off" + } + }, + { + "files": ["**/*.vue"], + "rules": { + "unicorn/filename-case": ["error", { "case": "pascalCase" }] + } + }, + { + "files": ["packages/web/src/composables/**"], + "rules": { + "unicorn/filename-case": ["error", { "case": "camelCase" }] + } + }, + { + "files": ["**/*.d.ts"], + "rules": { + "typescript/consistent-type-definitions": "off", + "unicorn/require-module-specifiers": "off" + } + } + ], + "ignorePatterns": ["node_modules", "dist", "web-dist", "coverage"] +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f72b2c3 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules +dist +web-dist +coverage +bun.lock +CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 64fdd25..3205a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to `codesema` (the npm package in `packages/cli`) are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org). +## [0.11.0] - 2026-08-02 + +### Added + +- Server context download: `codesema review` fetches `GET /api/cli/context` (conventions, learned rules, facts, last-scan freshness) for the current repo and hands it to the agent alongside the diff. The origin remote (`git remote get-url origin`) is sent as the `remote_url` query param the route requires to resolve the repo. Strictly best-effort and never blocking, same contract as auto-sync: no stored workspace credentials, no origin remote, offline, an unlinked workspace (403) or any malformed response all silently degrade to no server context, and the local review runs unchanged. `.codesema/RULES.md` stays local and always takes precedence. +- Staleness warning: when the server's last scan commit is not an ancestor of the current `HEAD`, the context is prefixed with an explicit warning naming the scan date, so the agent treats the conventions, learned rules and facts as advisory rather than ground truth about the current diff. + ## [0.10.0] - 2026-07-29 ### Added diff --git a/README.md b/README.md index d9d6995..8b93c8b 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,9 @@ npx -y codesema config Interactive: language → agent → model → effort, then where to save. Two levels, field by field: -| Level | File | When | -| ------ | -------------------------------- | -------------------------------------- | -| Global | `~/.config/codesema/config.json` | Your default, every repo (onboarding) | +| Level | File | When | +| ------ | -------------------------------- | --------------------------------------- | +| Global | `~/.config/codesema/config.json` | Your default, every repo (onboarding) | | Repo | `.codesema/config.json` | Team/project override, wins over global | CLI flags always win over both. `target`, `port`, `timeout` and `language` can also be set in either file. @@ -121,7 +121,7 @@ Then, in any repo, on your feature branch, ask your agent: `/codesema`. It uses ## Customize - `.codesema/PROMPT.md`: your team's review instructions, merged into the agent prompt. -- `.codesema/RULES.md`: your team's review rules, one per line, hunted first by the reviewer. Put the highest-yield rules on top; each line may extend the rule with optional `|`-separated segments the reviewer knows how to use: `(category) rule | Scope: where in the repo it applies | Where to look: files, imports or code shapes to inspect | Bad: literal rejected form | Good: literal expected form | Exceptions: tolerated legacy, never flagged`. Rules are cited as `[C1]`, `[C2]`, ... (file order) in convention findings. Telling the reviewer *where to look* is what makes a rule catch violations. +- `.codesema/RULES.md`: your team's review rules, one per line, hunted first by the reviewer. Put the highest-yield rules on top; each line may extend the rule with optional `|`-separated segments the reviewer knows how to use: `(category) rule | Scope: where in the repo it applies | Where to look: files, imports or code shapes to inspect | Bad: literal rejected form | Good: literal expected form | Exceptions: tolerated legacy, never flagged`. Rules are cited as `[C1]`, `[C2]`, ... (file order) in convention findings. Telling the reviewer _where to look_ is what makes a rule catch violations. - `.codesema-ignore`: glob patterns excluded from the diff (lockfiles, minified files and sourcemaps are excluded by default). ## Troubleshooting @@ -135,33 +135,33 @@ Then, in any repo, on your feature branch, ask your agent: `/codesema`. It uses ## Environment variables -| Variable | Effect | -| -------------------------- | --------------------------------------------------------------------------------- | -| `CODESEMA_CONFIG_DIR` | Override the global config directory (default `~/.config/codesema`). | -| `CODESEMA_NO_UPDATE_CHECK` | Set to `1` to skip the startup npm version check (also skipped when not a TTY). | -| `CODESEMA_SYNC_URL` | Point `sync`/`link` at a different codesema.com host (self-hosted or staging). | +| Variable | Effect | +| -------------------------- | ------------------------------------------------------------------------------- | +| `CODESEMA_CONFIG_DIR` | Override the global config directory (default `~/.config/codesema`). | +| `CODESEMA_NO_UPDATE_CHECK` | Set to `1` to skip the startup npm version check (also skipped when not a TTY). | +| `CODESEMA_SYNC_URL` | Point `sync`/`link` at a different codesema.com host (self-hosted or staging). | ## Files -| Path | Contents | -| --------------------------------- | ---------------------------------------------------------------------------- | -| `~/.config/codesema/config.json` | Global config (language, agent, model, effort, sync credentials), mode `0600`. | -| `.codesema/config.json` | Repo config, overrides the global one. | -| `.codesema/input.json` | The prepared MR diff handed to the agent (`prep`). | -| `.codesema/review.json` | The latest review written by the agent. | -| `.codesema/reviews/` | Archived reviews (5 kept per branch, used for incremental re-review). | -| `.codesema/PROMPT.md` | Your team's extra review instructions, merged into the prompt. | -| `.codesema/RULES.md` | Your team's review rules (one `[Cn]` grid line each), hunted first. | -| `.codesema-ignore` | Glob patterns excluded from the diff. | +| Path | Contents | +| -------------------------------- | ------------------------------------------------------------------------------ | +| `~/.config/codesema/config.json` | Global config (language, agent, model, effort, sync credentials), mode `0600`. | +| `.codesema/config.json` | Repo config, overrides the global one. | +| `.codesema/input.json` | The prepared MR diff handed to the agent (`prep`). | +| `.codesema/review.json` | The latest review written by the agent. | +| `.codesema/reviews/` | Archived reviews (5 kept per branch, used for incremental re-review). | +| `.codesema/PROMPT.md` | Your team's extra review instructions, merged into the prompt. | +| `.codesema/RULES.md` | Your team's review rules (one `[Cn]` grid line each), hunted first. | +| `.codesema-ignore` | Glob patterns excluded from the diff. | ## Exit codes -| Code | Meaning | -| ----- | ---------------------------------------------------------------------------------------------- | -| `0` | Success (review completed; with `--fail-on`, nothing tripped the gate). | -| `1` | Error (bad invocation, agent failure, unusable output, or a blocked secret sync). | +| Code | Meaning | +| ----- | ------------------------------------------------------------------------------------------------ | +| `0` | Success (review completed; with `--fail-on`, nothing tripped the gate). | +| `1` | Error (bad invocation, agent failure, unusable output, or a blocked secret sync). | | `2` | `review --fail-on ` gate tripped (a finding at or above the level, or changes requested). | -| `130` | Interrupted with Ctrl-C. | +| `130` | Interrupted with Ctrl-C. | ## Development diff --git a/bun.lock b/bun.lock index 1e50e0c..d66b2e6 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,17 @@ "": { "name": "codesema-monorepo", "devDependencies": { + "@ianvs/prettier-plugin-sort-imports": "4.7.1", "@types/bun": "^1.3.14", + "lefthook": "2.1.10", + "oxlint": "1.74.0", + "prettier": "3.9.5", + "publint": "0.3.21", }, }, "packages/cli": { "name": "codesema", - "version": "0.5.0", + "version": "0.8.0", "bin": { "codesema": "dist/index.mjs", }, @@ -23,7 +28,7 @@ }, "packages/contract": { "name": "@codesema/contract", - "version": "0.1.0", + "version": "0.3.0", "devDependencies": { "@types/node": "^26.1.1", "tsdown": "^0.22.5", @@ -45,12 +50,22 @@ }, }, "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@codesema/contract": ["@codesema/contract@workspace:packages/contract"], @@ -63,12 +78,60 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@ianvs/prettier-plugin-sort-imports": ["@ianvs/prettier-plugin-sort-imports@4.7.1", "", { "dependencies": { "@babel/generator": "^7.26.2", "@babel/parser": "^7.26.2", "@babel/traverse": "^7.25.9", "@babel/types": "^7.26.0", "semver": "^7.5.2" }, "peerDependencies": { "@prettier/plugin-oxc": "^0.0.4 || ^0.1.0", "@vue/compiler-sfc": "2.7.x || 3.x", "content-tag": "^4.0.0", "prettier": "2 || 3 || ^4.0.0-0", "prettier-plugin-ember-template-tag": "^2.1.0" }, "optionalPeers": ["@prettier/plugin-oxc", "@vue/compiler-sfc", "content-tag", "prettier-plugin-ember-template-tag"] }, "sha512-jmTNYGlg95tlsoG3JLCcuC4BrFELJtLirLAkQW/71lXSyOhVt/Xj7xWbbGcuVbNq1gwWgSyMrPjJc9Z30hynVw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.74.0", "", { "os": "android", "cpu": "arm" }, "sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.74.0", "", { "os": "android", "cpu": "arm64" }, "sha512-xjKdoMB+H+RCOByv/7l7nfIGW9mlOisqYdcyC75UqYuQecLpReAeEYUf2CNeDEI3KtmUgxpRw/+c63y4AeF/Bw=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.74.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iUK7wvc6sejMKsC+Pt67mntoF5weFcyEunhZfLJceU6gL419mexz5wBkSx/EnkFBExMLNtOi9fnDSc5xfK0IzQ=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.74.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ggKc/tn5SJ1u2yG2izC6VKODfYKV8MQ2AicJlNzOjuyrC29udvOef6/JzK2r32xqCnBDLFouR1VCkjzEI0/N9Q=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.74.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-u++dH/43jy9hTLbneaWlS0gla/Bp1JdwJ2zgevCl8nDFUh6qRCGMxcL0f0lb7By3A9p/LfFr+7cG4HU1hG856g=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.74.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Sj1zmtFDVTPeIbIz4ZfcXAbFHqCmKCXdCUlAJzvTF7I20NTH1RDpoF2PhkqNODutJzVhJYmm3oz0GwgY+tvE2g=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.74.0", "", { "os": "linux", "cpu": "arm" }, "sha512-//PKyQb/tQXcHArx2f7z+oVI/eMS2Jpv+edNuAtOrgIhWdGcpHxogveAxzmF2rpH1AIHp4Hq04RF/rgJdiICnQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.74.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/k1Me+aX2tjuH10K62mLS0y8cLkJBHX6Ce0xPK+eWeel4bSdEGZ8dv4+hYMzg0GrSmjwy4yAYsDPeEeKBft/2w=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.74.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-3tFSjBxc5D8/zvjEuLvOqcA8ZXKD0+6NuaVO/edeamNc49MoAsbfaC9s1UiwODwgF6slGaF8yJA2TPkukd77tg=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.74.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9QggtPkSPXOCTu8Szis7auOK/sC7KdQaN+/TujP7YVVhzCAOhgdRfgv8uEz0r2tk5xdgus5rLYUrCDoZNtiRUw=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.74.0", "", { "os": "linux", "cpu": "none" }, "sha512-VM5VPUJ4DJIWiK+AZn8FScUqMr6OFrCAYybMYjEEi7W13ParI64MByiXTkKMqZpBmvQ9zxl9Ebq2VUOiZRJYUg=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.74.0", "", { "os": "linux", "cpu": "none" }, "sha512-SaDY1gh9rOA592J54g+gu5hkOFFQBZsMmIYHs+NRHG+Uq0OxtuuCXMWQ3vu1830Eugv5uMXyjG+bv2Z9y4IXjw=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.74.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZATQeHZCyr6MbDveg0obD5sxLHFOghtOdC5jwVwYlvFWqtFOxctgFEG6Ef/64hYvZrWyhyCckB10AelqLopeDA=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.74.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+aIvJyrdeD7LwCQ2WYLMUWNmnbeDRSPb40aBYtPjD9+PTqUwgJnk+HK5yLfSMeqXrMrDhE9uTmtt2y50tvjhHw=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.74.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XyktaR8lhK2qWiCK0Tk8oYD+/cgn+oHA6ddRnxSSXUKkkojkV78CmShZUxQF+yrBFs0SuW+JBOPG6hecyc/iZg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.74.0", "", { "os": "none", "cpu": "arm64" }, "sha512-mzbjrPl4neaVUiJ1fUiEUxTGaSZBoiKtaoB6jmIpz9S+VOA2vDYmJpihQ82w6178V5jxziclTg8Cgj5yF6tTDg=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.74.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-vUAe9okpS2Oa5+lX67lqHMuNUvfkleRKwrUDJ/WJBsgmddvZ1mrsh2HVmuFDRzqFELhaJhFaCNOuR6a7L3rtIA=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.74.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-yyXXJyYYSXL4I8K8jAWjJs+J3fa9gH2JmEbo4f5adm+1tNC9itseicBNuwK7BDHvqQ5J534s+yDULu89vYL2ZQ=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.74.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA=="], + + "@publint/pack": ["@publint/pack@0.1.5", "", { "dependencies": { "tinyexec": "^1.2.4" } }, "sha512-edgyN2pP07uXiP4tJs0s8KVmU8M8i60YPbbI0/WDeok1mIJHRXz+CgD8I0nelwDkoCh3EWL/G5kGfbuHjsdbvw=="], + "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], @@ -195,6 +258,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -217,6 +282,32 @@ "import-without-cache": ["import-without-cache@0.4.0", "", {}, "sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "lefthook": ["lefthook@2.1.10", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.10", "lefthook-darwin-x64": "2.1.10", "lefthook-freebsd-arm64": "2.1.10", "lefthook-freebsd-x64": "2.1.10", "lefthook-linux-arm64": "2.1.10", "lefthook-linux-x64": "2.1.10", "lefthook-openbsd-arm64": "2.1.10", "lefthook-openbsd-x64": "2.1.10", "lefthook-windows-arm64": "2.1.10", "lefthook-windows-x64": "2.1.10" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-K7mM4WoqMwqfXYK11EHy+lSH1uW8XHni3Yn/bSqyerPkUPygGdf3xn18JoV5HyA06xuQL3ofGAOjG01QX9oJ4w=="], + + "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nw+X8wRNDoUUV6WSteyKBbcLySq+fsmZt5WV/s50ZJpysmsDKJOUMln6SllNfP+60dzUahAO7REco/2633BsLg=="], + + "lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-KQ/bHmvpkFdHMn4pZnUdTf+GuSC+aBBgBTxZT4GW+6cSf+qbErKZBhK7cH6BmILsvx43+VzEArvHYY7YOfRFOQ=="], + + "lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.10", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-8su6DwydP7+pv7kG0zCtjphqsw4ouOnfexRUErapy5GTxYBoUOhYz3RSHTSWNRsK6W4jva7FPUh2Lp5/PSn30w=="], + + "lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.1.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GeAJEFxko3Lk+AsnS3NleAFrpyMLFUKOlgJvPKuU0xHwVEI/z+ZoCcmuO0BX+4CS0NLbZhC/YQAvBASqDvvVdQ=="], + + "lefthook-linux-arm64": ["lefthook-linux-arm64@2.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-1sHTCmpTWjVMs+yKPBLRNT1kuuIr1yjietlk7rCB6wFPVOS6Ph3o2zPFH2AvW1UymHlqwyHXzBr9EtDpQ7j1mQ=="], + + "lefthook-linux-x64": ["lefthook-linux-x64@2.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-z/VlRB3bh6mBvW3r1rwnJ5vP8z+Krx5gJzkZ4veDXh+6FlRTx8wtd3g3fllOv/yZMxkgmL3fQoFXv05Esa7vBQ=="], + + "lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.1.10", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-430zL8sSIKw5P0YXGG6PB+eAhHa06n0PXuaERaAQE4Ss3odfqwnl5Mq9hQmkEnOS1EGiQEKkd0UHv/i4PtMNIQ=="], + + "lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.1.10", "", { "os": "openbsd", "cpu": "x64" }, "sha512-bgkO8PphGZVDhQgCJ524aYYPI5491pVmCiLPGjBIo1AvOSlIyw4N1Y+1C3QfqwEmechzw+Aq16SNc8pqv6UuXg=="], + + "lefthook-windows-arm64": ["lefthook-windows-arm64@2.1.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-5Q6etF0Fla2DDA4ilDySrdNgiR5+W7cJZwnZ69Je3kvWCaWm4wnkuc8FEdjp3kiL2x3ZXipdI00f5vpO8aWmog=="], + + "lefthook-windows-x64": ["lefthook-windows-x64@2.1.10", "", { "os": "win32", "cpu": "x64" }, "sha512-c/XH8YZtylG4XaxzqFfXluvq2LXq2W/p54Bnzn3+Z7E5X2Fk3JlFJAibulMbIt2+w8T7UI/r97ok5GqE4kGaeA=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -243,12 +334,20 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + "oxlint": ["oxlint@1.74.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.74.0", "@oxlint/binding-android-arm64": "1.74.0", "@oxlint/binding-darwin-arm64": "1.74.0", "@oxlint/binding-darwin-x64": "1.74.0", "@oxlint/binding-freebsd-x64": "1.74.0", "@oxlint/binding-linux-arm-gnueabihf": "1.74.0", "@oxlint/binding-linux-arm-musleabihf": "1.74.0", "@oxlint/binding-linux-arm64-gnu": "1.74.0", "@oxlint/binding-linux-arm64-musl": "1.74.0", "@oxlint/binding-linux-ppc64-gnu": "1.74.0", "@oxlint/binding-linux-riscv64-gnu": "1.74.0", "@oxlint/binding-linux-riscv64-musl": "1.74.0", "@oxlint/binding-linux-s390x-gnu": "1.74.0", "@oxlint/binding-linux-x64-gnu": "1.74.0", "@oxlint/binding-linux-x64-musl": "1.74.0", "@oxlint/binding-openharmony-arm64": "1.74.0", "@oxlint/binding-win32-arm64-msvc": "1.74.0", "@oxlint/binding-win32-ia32-msvc": "1.74.0", "@oxlint/binding-win32-x64-msvc": "1.74.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.24.0", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-odGl2s2x5IOJoj3A0v1k0PGBXVFBZeZ2+AK/+K2MJur7Ghi3bkyX5NuLUWHKqa4js1wjep3hJeuTQJOlr+4+dA=="], + + "package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -257,6 +356,10 @@ "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], + + "publint": ["publint@0.3.21", "", { "dependencies": { "@publint/pack": "^0.1.4", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "sade": "^1.8.1" }, "bin": { "publint": "src/cli.js" } }, "sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ=="], + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], @@ -265,6 +368,8 @@ "rolldown-plugin-dts": ["rolldown-plugin-dts@0.27.6", "", { "dependencies": { "dts-resolver": "^3.0.0", "get-tsconfig": "5.0.0-beta.5", "obug": "^2.1.3", "yuku-ast": "^0.1.7", "yuku-codegen": "^0.5.46", "yuku-parser": "^0.5.46" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20260325.1", "rolldown": "^1.0.0", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "vue-tsc": "~3.2.0 || ~3.3.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-LK/2xsCvFwpppMPlAYTmBSLcxqYXwPye/BSTgH0hpe1iEbs1j5bYHchRahADh1uHOqLzOOlgciRIJ201yPb0yQ=="], + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..84d74ac --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,16 @@ +pre-commit: + commands: + format: + glob: '*' + run: bunx prettier --write --ignore-unknown {staged_files} + stage_fixed: true + lint: + glob: '*.{ts,mjs,vue}' + run: bunx oxlint {staged_files} + +pre-push: + commands: + typecheck: + run: bun run typecheck + test: + run: bun run test diff --git a/package.json b/package.json index 1497e8e..d6adf79 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codesema-tools", - "version": "0.10.0", + "version": "0.11.0", "private": true, "type": "module", "workspaces": [ @@ -11,13 +11,22 @@ "build:contract": "bun run --cwd packages/contract build", "build:web": "bun run --cwd packages/web build", "build:cli": "bun run --cwd packages/cli build", + "lint": "oxlint", "typecheck": "bun run build:contract && bun run --cwd packages/contract typecheck && bun run --cwd packages/cli typecheck && bun run --cwd packages/web typecheck", - "test": "bun run build:contract && bun test packages/cli packages/web packages/contract" + "test": "bun run build:contract && bun test packages/cli packages/web packages/contract", + "prepare": "lefthook install", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "engines": { "node": ">=20" }, "devDependencies": { - "@types/bun": "^1.3.14" + "@ianvs/prettier-plugin-sort-imports": "4.7.1", + "@types/bun": "^1.3.14", + "lefthook": "2.1.10", + "oxlint": "1.74.0", + "prettier": "3.9.5", + "publint": "0.3.21" } } diff --git a/packages/cli/eval/fixtures/sql-injection.json b/packages/cli/eval/fixtures/sql-injection.json index 25dcc19..66f5c42 100644 --- a/packages/cli/eval/fixtures/sql-injection.json +++ b/packages/cli/eval/fixtures/sql-injection.json @@ -1,7 +1,11 @@ { "name": "sql-injection", "expected": [ - { "id": "sql-injection", "file": "src/users.js", "pattern": "injection|interpolat|parameteriz|escap|sanitiz" } + { + "id": "sql-injection", + "file": "src/users.js", + "pattern": "injection|interpolat|parameteriz|escap|sanitiz" + } ], "input": { "branch": "refactor/user-query", diff --git a/packages/cli/eval/run.ts b/packages/cli/eval/run.ts index 03d9afe..54673fe 100644 --- a/packages/cli/eval/run.ts +++ b/packages/cli/eval/run.ts @@ -2,8 +2,7 @@ import { readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { parseArgs } from 'node:util' import { agentEnv, hardenedReviewCommand, runAgent } from '../src/agent.js' -import type { SanitizedReview } from '../src/contract.js' -import { groundReview, sanitizeReview } from '../src/contract.js' +import { groundReview, sanitizeReview, type SanitizedReview } from '../src/contract.js' import { prosecutorInstructions } from '../src/dual.js' import { extractReviewJson, reviewInstructions } from '../src/review.js' import { scoreFindings, type ExpectedBug } from './score.js' @@ -25,7 +24,12 @@ type Fixture = { const LANES = { a: 'reviewer', b: 'prosecutor' } as const type Lane = keyof typeof LANES -async function runLane(agent: string, lane: Lane, fixture: Fixture, timeoutMs: number): Promise { +async function runLane( + agent: string, + lane: Lane, + fixture: Fixture, + timeoutMs: number, +): Promise { const instructions = lane === 'a' ? reviewInstructions() : prosecutorInstructions('English') const prompt = [ instructions, @@ -51,7 +55,9 @@ const { values } = parseArgs({ }) if (!values.agent || (values.lane !== 'both' && values.lane !== 'a' && values.lane !== 'b')) { - console.error('usage: bun eval/run.ts --agent "" [--lane a|b|both] [--timeout seconds]') + console.error( + 'usage: bun eval/run.ts --agent "" [--lane a|b|both] [--timeout seconds]', + ) process.exit(1) } @@ -59,7 +65,7 @@ const lanes: Lane[] = values.lane === 'both' ? ['a', 'b'] : [values.lane] const fixturesDir = join(import.meta.dir, 'fixtures') const fixtures = readdirSync(fixturesDir) .filter((name) => name.endsWith('.json')) - .sort() + .toSorted() .map((name) => JSON.parse(readFileSync(join(fixturesDir, name), 'utf8')) as Fixture) let totalExpected = 0 diff --git a/packages/cli/eval/score.test.ts b/packages/cli/eval/score.test.ts index f8161d2..c887848 100644 --- a/packages/cli/eval/score.test.ts +++ b/packages/cli/eval/score.test.ts @@ -28,14 +28,18 @@ describe('scoreFindings', () => { test('one finding cannot satisfy two expected bugs', () => { const twin: ExpectedBug = { ...bug, id: 'zero-div-2' } - const findings: Finding[] = [{ file: 'src/stats.js', severity: 'major', message: 'divide by zero' }] + const findings: Finding[] = [ + { file: 'src/stats.js', severity: 'major', message: 'divide by zero' }, + ] const score = scoreFindings([bug, twin], findings) expect(score.found).toHaveLength(1) expect(score.missed).toHaveLength(1) }) test('pattern also matches the finding title', () => { - const findings: Finding[] = [{ file: 'src/stats.js', severity: 'major', title: 'NaN on empty input', message: 'm' }] + const findings: Finding[] = [ + { file: 'src/stats.js', severity: 'major', title: 'NaN on empty input', message: 'm' }, + ] expect(scoreFindings([bug], findings).found).toHaveLength(1) }) }) diff --git a/packages/cli/eval/score.ts b/packages/cli/eval/score.ts index 96e2017..122c182 100644 --- a/packages/cli/eval/score.ts +++ b/packages/cli/eval/score.ts @@ -16,7 +16,9 @@ export function scoreFindings(expected: ExpectedBug[], findings: Finding[]): Fix const pattern = new RegExp(bug.pattern, 'i') const hit = findings.find( (finding) => - !claimed.has(finding) && finding.file === bug.file && pattern.test(`${finding.title ?? ''} ${finding.message}`), + !claimed.has(finding) && + finding.file === bug.file && + pattern.test(`${finding.title ?? ''} ${finding.message}`), ) if (hit) { claimed.add(hit) diff --git a/packages/cli/package.json b/packages/cli/package.json index fa58636..7123f05 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "codesema", - "version": "0.10.0", + "version": "0.11.0", "description": "Local merge request review, step by step. Your AI agent reviews, codesema displays.", "license": "MIT", "author": "Hasan TASKIN", diff --git a/packages/cli/scripts/embed-web.mjs b/packages/cli/scripts/embed-web.mjs index dbbdf59..a04de02 100644 --- a/packages/cli/scripts/embed-web.mjs +++ b/packages/cli/scripts/embed-web.mjs @@ -6,7 +6,9 @@ const webDist = fileURLToPath(new URL('../../web/dist', import.meta.url)) const target = fileURLToPath(new URL('../web-dist', import.meta.url)) if (!existsSync(webDist)) { - console.error('[embed-web] packages/web/dist not found — run the web build first (bun run build:web)') + console.error( + '[embed-web] packages/web/dist not found — run the web build first (bun run build:web)', + ) process.exit(1) } diff --git a/packages/cli/src/agent.test.ts b/packages/cli/src/agent.test.ts index 404c400..be66f62 100644 --- a/packages/cli/src/agent.test.ts +++ b/packages/cli/src/agent.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from 'bun:test' -import { agentEnv, claudeStreamCommand, createClaudeStreamParser, hardenedReviewCommand } from './agent.js' +import { + agentEnv, + claudeStreamCommand, + createClaudeStreamParser, + hardenedReviewCommand, +} from './agent.js' describe('claudeStreamCommand', () => { test('claude -p basic: stream flags added', () => { @@ -9,7 +14,9 @@ describe('claudeStreamCommand', () => { }) test('claude -p with model and effort', () => { - expect(claudeStreamCommand('claude -p --model opus --effort high')).toContain('--output-format stream-json') + expect(claudeStreamCommand('claude -p --model opus --effort high')).toContain( + '--output-format stream-json', + ) }) test('non-claude command: null', () => { @@ -69,7 +76,8 @@ describe('hardenedReviewCommand', () => { }) test('a flag name quoted inside another argument does not disable hardening', () => { - const command = 'claude -p --append-system-prompt "never mention --tools or --setting-sources here"' + const command = + 'claude -p --append-system-prompt "never mention --tools or --setting-sources here"' const hardened = hardenedReviewCommand(command) expect(hardened).toContain('--tools ""') expect(hardened).toContain('--setting-sources user') @@ -125,13 +133,21 @@ describe('agentEnv', () => { }) test('ALL_PROXY passes through for SOCKS proxies', () => { - const env = agentEnv('claude -p', { ...source, ALL_PROXY: 'socks5://proxy:1080', all_proxy: 'socks5://proxy:1080' }) + const env = agentEnv('claude -p', { + ...source, + ALL_PROXY: 'socks5://proxy:1080', + all_proxy: 'socks5://proxy:1080', + }) expect(env?.ALL_PROXY).toBe('socks5://proxy:1080') expect(env?.all_proxy).toBe('socks5://proxy:1080') }) test('CA bundle variables always pass through', () => { - const env = agentEnv('claude -p', { ...source, NODE_EXTRA_CA_CERTS: '/ca.pem', SSL_CERT_FILE: '/ca.pem' }) + const env = agentEnv('claude -p', { + ...source, + NODE_EXTRA_CA_CERTS: '/ca.pem', + SSL_CERT_FILE: '/ca.pem', + }) expect(env?.NODE_EXTRA_CA_CERTS).toBe('/ca.pem') expect(env?.SSL_CERT_FILE).toBe('/ca.pem') }) @@ -195,7 +211,9 @@ describe('createClaudeStreamParser', () => { test('complete assistant message resynchronizes the text', () => { const parser = createClaudeStreamParser() parser.push(delta('partial tex')) - parser.push(`${JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'full text' }] } })}\n`) + parser.push( + `${JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'full text' }] } })}\n`, + ) expect(parser.finalText()).toBe('full text') }) }) diff --git a/packages/cli/src/agent.ts b/packages/cli/src/agent.ts index 6263b92..7f455a0 100644 --- a/packages/cli/src/agent.ts +++ b/packages/cli/src/agent.ts @@ -39,22 +39,37 @@ export function hardenedReviewCommand(command: string): string { const agent = knownAgent(command) if (agent === 'claude') { const flags: string[] = [] - if (!flagPresent(command, '--tools')) flags.push('--tools ""') - if (!flagPresent(command, '--strict-mcp-config')) flags.push('--strict-mcp-config') - if (!flagPresent(command, '--setting-sources')) flags.push('--setting-sources user') + if (!flagPresent(command, '--tools')) { + flags.push('--tools ""') + } + if (!flagPresent(command, '--strict-mcp-config')) { + flags.push('--strict-mcp-config') + } + if (!flagPresent(command, '--setting-sources')) { + flags.push('--setting-sources user') + } return flags.length > 0 ? `${command} ${flags.join(' ')}` : command } if (agent === 'codex') { - if (flagPresent(command, '--dangerously-bypass-approvals-and-sandbox') || flagPresent(command, '--yolo')) { + if ( + flagPresent(command, '--dangerously-bypass-approvals-and-sandbox') || + flagPresent(command, '--yolo') + ) { return command } const flags: string[] = [] - if (!flagPresent(command, '--sandbox') && !flagPresent(command, '-s')) flags.push('--sandbox read-only') + if (!flagPresent(command, '--sandbox') && !flagPresent(command, '-s')) { + flags.push('--sandbox read-only') + } if (!flagPresent(command, '--ask-for-approval') && !flagPresent(command, '-a')) { flags.push('--ask-for-approval never') } - if (!flagPresent(command, 'project_doc_max_bytes')) flags.push('-c project_doc_max_bytes=0') - if (flags.length === 0) return command + if (!flagPresent(command, 'project_doc_max_bytes')) { + flags.push('-c project_doc_max_bytes=0') + } + if (flags.length === 0) { + return command + } const stdinMarker = /\s-$/.test(command) const base = stdinMarker ? command.slice(0, -2) : command return [base, ...flags, ...(stdinMarker ? ['-'] : [])].join(' ') @@ -112,15 +127,21 @@ export function agentEnv( ): NodeJS.ProcessEnv | undefined { // cmd.exe needs SystemRoot/ComSpec and Windows env names are case-insensitive: // narrowing there can break the spawn itself, so Windows inherits the full env. - if (platform === 'win32') return undefined + if (platform === 'win32') { + return undefined + } const agent = knownAgent(command) - if (!agent) return undefined + if (!agent) { + return undefined + } const prefixes = [...AGENT_ENV_PREFIXES[agent]] const names = new Set(BASE_ENV_VARS) // Claude Code on Bedrock/Vertex authenticates through the cloud SDK env, // not ANTHROPIC_*: widen only when those modes are switched on. if (agent === 'claude') { - if (source.CLAUDE_CODE_USE_BEDROCK) prefixes.push('AWS_') + if (source.CLAUDE_CODE_USE_BEDROCK) { + prefixes.push('AWS_') + } if (source.CLAUDE_CODE_USE_VERTEX) { prefixes.push('GOOGLE_', 'GCP_') names.add('CLOUD_ML_REGION') @@ -128,16 +149,26 @@ export function agentEnv( } const env: NodeJS.ProcessEnv = {} for (const [key, value] of Object.entries(source)) { - if (value === undefined) continue - if (names.has(key) || prefixes.some((prefix) => key.startsWith(prefix))) env[key] = value + if (value === undefined) { + continue + } + if (names.has(key) || prefixes.some((prefix) => key.startsWith(prefix))) { + env[key] = value + } } return env } export function claudeStreamCommand(command: string): string | null { - if (!/^claude(\s|$)/.test(command)) return null - if (!/(^|\s)(-p|--print)(\s|$)/.test(command)) return null - if (command.includes('--output-format') || command.includes('--input-format')) return null + if (!/^claude(\s|$)/.test(command)) { + return null + } + if (!/(^|\s)(-p|--print)(\s|$)/.test(command)) { + return null + } + if (command.includes('--output-format') || command.includes('--input-format')) { + return null + } return `${command} ${CLAUDE_STREAM_FLAGS}` } @@ -153,7 +184,9 @@ export function createClaudeStreamParser(onText?: (text: string) => void): Claud let resultText: string | null = null const handleLine = (line: string) => { - if (!line.trim()) return + if (!line.trim()) { + return + } let event: Record try { event = JSON.parse(line) as Record @@ -161,8 +194,15 @@ export function createClaudeStreamParser(onText?: (text: string) => void): Claud return } if (event.type === 'stream_event') { - const inner = (event.event ?? {}) as { type?: string; delta?: { type?: string; text?: string } } - if (inner.type === 'content_block_delta' && inner.delta?.type === 'text_delta' && inner.delta.text) { + const inner = (event.event ?? {}) as { + type?: string + delta?: { type?: string; text?: string } + } + if ( + inner.type === 'content_block_delta' && + inner.delta?.type === 'text_delta' && + inner.delta.text + ) { streamedText += inner.delta.text onText?.(streamedText) } @@ -190,7 +230,9 @@ export function createClaudeStreamParser(onText?: (text: string) => void): Claud lineBuffer += chunk for (;;) { const nl = lineBuffer.indexOf('\n') - if (nl < 0) break + if (nl < 0) { + break + } handleLine(lineBuffer.slice(0, nl)) lineBuffer = lineBuffer.slice(nl + 1) } @@ -211,7 +253,7 @@ export type AgentRunOptions = { cwd: string timeoutMs: number /** Environment for the subprocess; undefined inherits the full process env. */ - env?: NodeJS.ProcessEnv + env?: NodeJS.ProcessEnv | undefined /** Cumulative review text so far, called on every update from the agent. */ onText?: (text: string) => void } @@ -237,8 +279,11 @@ export function runAgent(opts: AgentRunOptions): Promise { const timer = setTimeout(() => { timedOut = true try { - if (detached && child.pid) process.kill(-child.pid, 'SIGTERM') - else child.kill('SIGTERM') + if (detached && child.pid) { + process.kill(-child.pid, 'SIGTERM') + } else { + child.kill('SIGTERM') + } } catch { // process group already gone } @@ -247,8 +292,11 @@ export function runAgent(opts: AgentRunOptions): Promise { child.stdout.on('data', (d: Buffer) => { const chunk = d.toString() out += chunk - if (parser) parser.push(chunk) - else opts.onText?.(out) + if (parser) { + parser.push(chunk) + } else { + opts.onText?.(out) + } }) child.on('error', (err) => { clearTimeout(timer) diff --git a/packages/cli/src/branches.ts b/packages/cli/src/branches.ts index 30b9475..ccae390 100644 --- a/packages/cli/src/branches.ts +++ b/packages/cli/src/branches.ts @@ -11,10 +11,17 @@ export type LocalBranch = { export function listLocalBranches(cwd: string): LocalBranch[] { const out = tryGit( - ['for-each-ref', 'refs/heads', '--sort=-committerdate', '--format=%(refname:short)%09%(committerdate:relative)%09%(subject)'], + [ + 'for-each-ref', + 'refs/heads', + '--sort=-committerdate', + '--format=%(refname:short)%09%(committerdate:relative)%09%(subject)', + ], cwd, ) - if (!out) return [] + if (!out) { + return [] + } const current = tryGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd) return out .split('\n') @@ -34,9 +41,14 @@ export function listLocalBranches(cwd: string): LocalBranch[] { /** Interactive branch picker (keyboard filter). Returns null if cancelled, the current branch if non-TTY or the list is empty. */ export async function pickBranch(cwd: string): Promise { const branches = listLocalBranches(cwd) - if (branches.length <= 1) return branches[0]?.name ?? currentBranch(cwd) + if (branches.length <= 1) { + return branches[0]?.name ?? currentBranch(cwd) + } - const initialIndex = Math.max(0, branches.findIndex((b) => b.isCurrent)) + const initialIndex = Math.max( + 0, + branches.findIndex((b) => b.isCurrent), + ) const picked = await select({ title: t('branches.pick'), options: branches.map((b) => ({ diff --git a/packages/cli/src/config.test.ts b/packages/cli/src/config.test.ts index 9567988..6888085 100644 --- a/packages/cli/src/config.test.ts +++ b/packages/cli/src/config.test.ts @@ -1,9 +1,8 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { - type CodesemaConfig, globalConfigPath, isRepoAgentTrusted, loadConfig, @@ -13,6 +12,7 @@ import { saveRepoConfig, trustRepoAgent, trustStorePath, + type CodesemaConfig, } from './config.js' describe('repo agent trust store', () => { @@ -25,8 +25,11 @@ describe('repo agent trust store', () => { }) afterEach(() => { - if (previousConfigDir === undefined) delete process.env.CODESEMA_CONFIG_DIR - else process.env.CODESEMA_CONFIG_DIR = previousConfigDir + if (previousConfigDir === undefined) { + delete process.env.CODESEMA_CONFIG_DIR + } else { + process.env.CODESEMA_CONFIG_DIR = previousConfigDir + } rmSync(configDir, { recursive: true, force: true }) }) @@ -64,13 +67,21 @@ describe('sync credentials round-trip', () => { }) afterEach(() => { - if (previousConfigDir === undefined) delete process.env.CODESEMA_CONFIG_DIR - else process.env.CODESEMA_CONFIG_DIR = previousConfigDir + if (previousConfigDir === undefined) { + delete process.env.CODESEMA_CONFIG_DIR + } else { + process.env.CODESEMA_CONFIG_DIR = previousConfigDir + } rmSync(configDir, { recursive: true, force: true }) }) test('sync fields survive save and load', () => { - saveGlobalConfig({ syncUrl: 'http://localhost:9080', syncWorkspaceId: 'ws-1', syncSecret: 's3cret', syncAutoPush: true }) + saveGlobalConfig({ + syncUrl: 'http://localhost:9080', + syncWorkspaceId: 'ws-1', + syncSecret: 's3cret', + syncAutoPush: true, + }) expect(loadGlobalConfig()).toEqual({ syncUrl: 'http://localhost:9080', syncWorkspaceId: 'ws-1', @@ -109,8 +120,11 @@ describe('sync fields are global-only', () => { }) afterEach(() => { - if (previousConfigDir === undefined) delete process.env.CODESEMA_CONFIG_DIR - else process.env.CODESEMA_CONFIG_DIR = previousConfigDir + if (previousConfigDir === undefined) { + delete process.env.CODESEMA_CONFIG_DIR + } else { + process.env.CODESEMA_CONFIG_DIR = previousConfigDir + } rmSync(configDir, { recursive: true, force: true }) rmSync(repoDir, { recursive: true, force: true }) }) diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index ef1f74d..646e445 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -5,28 +5,30 @@ import { isSupportedLanguage, type SupportedLanguage } from './i18n.js' export type CodesemaConfig = { /** Full headless agent shell command (e.g. "claude -p --model opus"). */ - agent?: string + agent?: string | undefined /** Wizard metadata, used to re-edit without starting over. */ - agentId?: string - model?: string - effort?: string - target?: string - port?: number - timeout?: number + agentId?: string | undefined + model?: string | undefined + effort?: string | undefined + target?: string | undefined + port?: number | undefined + timeout?: number | undefined /** UI and review language (ISO 639-1). */ - language?: SupportedLanguage + language?: SupportedLanguage | undefined /** Cloud sync (codesema.com): base URL override and workspace credentials. */ - syncUrl?: string - syncWorkspaceId?: string - syncSecret?: string + syncUrl?: string | undefined + syncWorkspaceId?: string | undefined + syncSecret?: string | undefined /** Explicit opt-in for pushing every completed review; credentials alone never auto-push. */ - syncAutoPush?: boolean + syncAutoPush?: boolean | undefined } type ConfigScope = 'global' | 'repo' function parseConfig(path: string, scope: ConfigScope): CodesemaConfig { - if (!existsSync(path)) return {} + if (!existsSync(path)) { + return {} + } try { const raw = JSON.parse(readFileSync(path, 'utf8')) as Record const str = (v: unknown) => (typeof v === 'string' && v ? v : undefined) @@ -40,9 +42,13 @@ function parseConfig(path: string, scope: ConfigScope): CodesemaConfig { // Sync fields are global-only: a cloned repo's .codesema/config.json must // never be able to redirect where reviews (diff included) are sent. ...(scope === 'global' && str(raw.syncUrl) ? { syncUrl: str(raw.syncUrl) } : {}), - ...(scope === 'global' && str(raw.syncWorkspaceId) ? { syncWorkspaceId: str(raw.syncWorkspaceId) } : {}), + ...(scope === 'global' && str(raw.syncWorkspaceId) + ? { syncWorkspaceId: str(raw.syncWorkspaceId) } + : {}), ...(scope === 'global' && str(raw.syncSecret) ? { syncSecret: str(raw.syncSecret) } : {}), - ...(scope === 'global' && typeof raw.syncAutoPush === 'boolean' ? { syncAutoPush: raw.syncAutoPush } : {}), + ...(scope === 'global' && typeof raw.syncAutoPush === 'boolean' + ? { syncAutoPush: raw.syncAutoPush } + : {}), ...(Number.isInteger(raw.port) ? { port: raw.port as number } : {}), ...(Number.isInteger(raw.timeout) ? { timeout: raw.timeout as number } : {}), } @@ -57,7 +63,9 @@ function writeConfig(path: string, config: CodesemaConfig, options?: { mode: num } export function globalConfigDir(): string { - if (process.env.CODESEMA_CONFIG_DIR) return process.env.CODESEMA_CONFIG_DIR + if (process.env.CODESEMA_CONFIG_DIR) { + return process.env.CODESEMA_CONFIG_DIR + } const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config') return join(base, 'codesema') } @@ -109,12 +117,16 @@ export function trustStorePath(): string { function readTrustStore(): Record { const path = trustStorePath() - if (!existsSync(path)) return {} + if (!existsSync(path)) { + return {} + } try { const raw = JSON.parse(readFileSync(path, 'utf8')) as Record const out: Record = {} for (const [key, value] of Object.entries(raw)) { - if (typeof value === 'string') out[key] = value + if (typeof value === 'string') { + out[key] = value + } } return out } catch { @@ -140,6 +152,8 @@ export function ensureWorkDir(repoRoot: string): string { const dir = join(repoRoot, '.codesema') mkdirSync(dir, { recursive: true }) const selfIgnore = join(dir, '.gitignore') - if (!existsSync(selfIgnore)) writeFileSync(selfIgnore, '*\n') + if (!existsSync(selfIgnore)) { + writeFileSync(selfIgnore, '*\n') + } return dir } diff --git a/packages/cli/src/dual.test.ts b/packages/cli/src/dual.test.ts index 739bf9e..bace531 100644 --- a/packages/cli/src/dual.test.ts +++ b/packages/cli/src/dual.test.ts @@ -11,13 +11,18 @@ import { worstVerdict, } from './dual.js' -function reviewWith(findings: Finding[], overrides: Partial = {}): SanitizedReview { +function reviewWith( + findings: Finding[], + overrides: Partial = {}, +): SanitizedReview { return { verdict: 'comment', summary: 'summary A', findings, narrative: null, ...overrides } } describe('judgeCommandFor', () => { test('claude: model swapped for sonnet', () => { - expect(judgeCommandFor('claude -p --model opus --effort high')).toBe('claude -p --model sonnet --effort high') + expect(judgeCommandFor('claude -p --model opus --effort high')).toBe( + 'claude -p --model sonnet --effort high', + ) }) test('claude without a model flag: sonnet appended', () => { @@ -25,7 +30,9 @@ describe('judgeCommandFor', () => { }) test('claude: --model=value form swapped too, never doubled', () => { - expect(judgeCommandFor('claude -p --model=opus --effort high')).toBe('claude -p --model sonnet --effort high') + expect(judgeCommandFor('claude -p --model=opus --effort high')).toBe( + 'claude -p --model sonnet --effort high', + ) expect(judgeCommandFor('codex exec -m=gpt-5.6-sol -')).toBe('codex exec -m gpt-5.5 -') }) @@ -134,10 +141,34 @@ describe('worstVerdict', () => { }) describe('assembleDualReview', () => { - const a0: Finding = { file: 'src/x.ts', line: 5, severity: 'major', kind: 'design', message: 'A0 issue' } - const a1: Finding = { file: 'src/x.ts', line: 9, severity: 'minor', kind: 'convention', message: 'A1 nit' } - const b0: Finding = { file: 'src/x.ts', line: 5, severity: 'critical', kind: 'design', message: 'B0 same as A0' } - const b1: Finding = { file: 'src/y.ts', line: 2, severity: 'major', kind: 'perf', message: 'B1 new issue' } + const a0: Finding = { + file: 'src/x.ts', + line: 5, + severity: 'major', + kind: 'design', + message: 'A0 issue', + } + const a1: Finding = { + file: 'src/x.ts', + line: 9, + severity: 'minor', + kind: 'convention', + message: 'A1 nit', + } + const b0: Finding = { + file: 'src/x.ts', + line: 5, + severity: 'critical', + kind: 'design', + message: 'B0 same as A0', + } + const b1: Finding = { + file: 'src/y.ts', + line: 2, + severity: 'major', + kind: 'perf', + message: 'B1 new issue', + } test('duplicates merge into the A finding with consensus and the highest severity', () => { const { review, stats } = assembleDualReview( @@ -163,18 +194,20 @@ describe('assembleDualReview', () => { }) test('rejected findings are dropped, except security ones', () => { - const secure: Finding = { file: 'src/x.ts', line: 5, severity: 'major', kind: 'security', message: 'injection' } - const { review, stats } = assembleDualReview( - reviewWith([a0, secure]), - reviewWith([b1]), - { - decisions: [ - { id: 'A0', action: 'reject', reason: 'not a real problem' }, - { id: 'A1', action: 'reject', reason: 'the judge cannot silence security' }, - { id: 'B0', action: 'reject', reason: 'noise' }, - ], - }, - ) + const secure: Finding = { + file: 'src/x.ts', + line: 5, + severity: 'major', + kind: 'security', + message: 'injection', + } + const { review, stats } = assembleDualReview(reviewWith([a0, secure]), reviewWith([b1]), { + decisions: [ + { id: 'A0', action: 'reject', reason: 'not a real problem' }, + { id: 'A1', action: 'reject', reason: 'the judge cannot silence security' }, + { id: 'B0', action: 'reject', reason: 'noise' }, + ], + }) expect(review.findings).toEqual([secure]) expect(stats.rejected).toBe(2) }) @@ -198,17 +231,13 @@ describe('assembleDualReview', () => { steps: [{ title: 'S', rationale: 'r', files: ['src/x.ts'], finding_refs: [0, 1] }], review_first: [], } - const { review } = assembleDualReview( - reviewWith([a0, a1], { narrative }), - reviewWith([b1]), - { - decisions: [ - { id: 'A0', action: 'reject', reason: 'noise' }, - { id: 'A1', action: 'keep' }, - { id: 'B0', action: 'keep' }, - ], - }, - ) + const { review } = assembleDualReview(reviewWith([a0, a1], { narrative }), reviewWith([b1]), { + decisions: [ + { id: 'A0', action: 'reject', reason: 'noise' }, + { id: 'A1', action: 'keep' }, + { id: 'B0', action: 'keep' }, + ], + }) expect(review.findings).toEqual([a1, b1]) expect(review.narrative?.steps[0]?.finding_refs).toEqual([0]) }) @@ -269,7 +298,13 @@ describe('assembleDualReview', () => { }) test('security member becomes the representative when the judge rejected the group root', () => { - const secure: Finding = { file: 'src/x.ts', line: 5, severity: 'major', kind: 'security', message: 'injection' } + const secure: Finding = { + file: 'src/x.ts', + line: 5, + severity: 'major', + kind: 'security', + message: 'injection', + } const { review } = assembleDualReview(reviewWith([a0]), reviewWith([secure]), { decisions: [ { id: 'A0', action: 'reject', reason: 'noise' }, @@ -332,11 +367,23 @@ describe('assembleDualReview', () => { }) describe('dedupeExactCrossLane', () => { - const anchored: Finding = { file: 'src/x.ts', line: 5, severity: 'major', kind: 'design', message: 'A side' } + const anchored: Finding = { + file: 'src/x.ts', + line: 5, + severity: 'major', + kind: 'design', + message: 'A side', + } test('exact file+line+kind duplicate: B copy removed, A copy tagged consensus with max severity', () => { const twin: Finding = { ...anchored, severity: 'critical', message: 'B side' } - const other: Finding = { file: 'src/y.ts', line: 2, severity: 'minor', kind: 'perf', message: 'B only' } + const other: Finding = { + file: 'src/y.ts', + line: 2, + severity: 'minor', + kind: 'perf', + message: 'B only', + } const { a, b, merged } = dedupeExactCrossLane(reviewWith([anchored]), reviewWith([twin, other])) expect(a.findings).toEqual([{ ...anchored, severity: 'critical', consensus: true }]) expect(b.findings).toEqual([other]) @@ -346,15 +393,26 @@ describe('dedupeExactCrossLane', () => { test('different line or kind: nothing merges', () => { const shifted: Finding = { ...anchored, line: 6 } const otherKind: Finding = { ...anchored, kind: 'perf' } - const { a, b, merged } = dedupeExactCrossLane(reviewWith([anchored]), reviewWith([shifted, otherKind])) + const { a, b, merged } = dedupeExactCrossLane( + reviewWith([anchored]), + reviewWith([shifted, otherKind]), + ) expect(a.findings).toEqual([anchored]) expect(b.findings).toEqual([shifted, otherKind]) expect(merged).toBe(0) }) test('unanchored findings never merge', () => { - const fileLevel: Finding = { file: 'src/x.ts', severity: 'major', kind: 'design', message: 'no line' } - const { a, b, merged } = dedupeExactCrossLane(reviewWith([fileLevel]), reviewWith([{ ...fileLevel }])) + const fileLevel: Finding = { + file: 'src/x.ts', + severity: 'major', + kind: 'design', + message: 'no line', + } + const { a, b, merged } = dedupeExactCrossLane( + reviewWith([fileLevel]), + reviewWith([{ ...fileLevel }]), + ) expect(a.findings).toEqual([fileLevel]) expect(b.findings).toEqual([{ ...fileLevel }]) expect(merged).toBe(0) @@ -392,7 +450,8 @@ describe('prompt hardening', () => { describe('parsePartialJudge', () => { test('complete decision objects extracted from a truncated stream', () => { - const text = '{"summary":"s","decisions":[{"id":"A0","action":"keep"},{"id":"B0","action":"reject","reason":"no"},{"id":"B1","act' + const text = + '{"summary":"s","decisions":[{"id":"A0","action":"keep"},{"id":"B0","action":"reject","reason":"no"},{"id":"B1","act' const partial = parsePartialJudge(text, 2, 2) expect(partial?.decisions).toEqual([ { id: 'A0', action: 'keep' }, diff --git a/packages/cli/src/dual.ts b/packages/cli/src/dual.ts index 729d5e1..78c6263 100644 --- a/packages/cli/src/dual.ts +++ b/packages/cli/src/dual.ts @@ -10,7 +10,7 @@ import type { import { repairTruncatedJson } from './partial.js' import { AGENT_DEFS } from './wizard.js' -const SEVERITIES: readonly FindingSeverity[] = ['critical', 'major', 'minor', 'info'] +const SEVERITIES: ReadonlySet = new Set(['critical', 'major', 'minor', 'info']) const SEVERITY_ORDER: Record = { info: 0, minor: 1, major: 2, critical: 3 } const VERDICT_ORDER: Record = { approve: 0, comment: 1, request_changes: 2 } const JUDGE_REASON_MAX = 300 @@ -32,7 +32,9 @@ export function judgeCommandFor(command: string): string { const first = command.trim().split(/\s+/)[0] ?? '' const bin = first.split('/').pop() ?? '' const def = AGENT_DEFS.find((d) => d.bin === bin) - if (!def) return command + if (!def) { + return command + } const flagPattern = new RegExp(`(^|\\s)${escapeRegExp(def.modelFlag)}(?:=|\\s+)\\S+`) if (flagPattern.test(command)) { return command.replace(flagPattern, `$1${def.modelFlag} ${def.judgeModel}`) @@ -56,9 +58,13 @@ export type JudgeOutput = { } function validCandidateId(id: unknown, aCount: number, bCount: number): id is string { - if (typeof id !== 'string') return false + if (typeof id !== 'string') { + return false + } const match = /^([AB])(\d+)$/.exec(id) - if (!match) return false + if (!match) { + return false + } const index = Number(match[2]) return match[1] === 'A' ? index < aCount : index < bCount } @@ -73,23 +79,44 @@ export function sanitizeJudgeOutput(raw: unknown, aCount: number, bCount: number // fragment's representative sits outside its own group): the link closing // a cycle is dropped, first link wins. const closesCycle = (from: string, to: string): boolean => { - for (let current: string | undefined = to; current !== undefined; current = duplicateLinks.get(current)) { - if (current === from) return true + for ( + let current: string | undefined = to; + current !== undefined; + current = duplicateLinks.get(current) + ) { + if (current === from) { + return true + } } return false } for (const item of Array.isArray(r.decisions) ? r.decisions : []) { - if (!item || typeof item !== 'object') continue + if (!item || typeof item !== 'object') { + continue + } const d = item as Record - if (!validCandidateId(d.id, aCount, bCount) || seen.has(d.id)) continue - if (d.action !== 'keep' && d.action !== 'reject') continue + if (!validCandidateId(d.id, aCount, bCount) || seen.has(d.id)) { + continue + } + if (d.action !== 'keep' && d.action !== 'reject') { + continue + } seen.add(d.id) const target = - validCandidateId(d.duplicate_of, aCount, bCount) && d.duplicate_of !== d.id ? d.duplicate_of : undefined + validCandidateId(d.duplicate_of, aCount, bCount) && d.duplicate_of !== d.id + ? d.duplicate_of + : undefined const duplicateOf = target !== undefined && !closesCycle(d.id, target) ? target : undefined - if (duplicateOf !== undefined) duplicateLinks.set(d.id, duplicateOf) - const reason = typeof d.reason === 'string' ? d.reason.trim().slice(0, JUDGE_REASON_MAX) || undefined : undefined - const severity = SEVERITIES.includes(d.severity as FindingSeverity) ? (d.severity as FindingSeverity) : undefined + if (duplicateOf !== undefined) { + duplicateLinks.set(d.id, duplicateOf) + } + const reason = + typeof d.reason === 'string' + ? d.reason.trim().slice(0, JUDGE_REASON_MAX) || undefined + : undefined + const severity = SEVERITIES.has(d.severity as FindingSeverity) + ? (d.severity as FindingSeverity) + : undefined decisions.push({ id: d.id, action: d.action, @@ -108,7 +135,9 @@ export function parsePartialJudge( bCount: number, ): { decisions: JudgeDecision[] } | null { const repaired = repairTruncatedJson(text) - if (!repaired) return null + if (!repaired) { + return null + } let parsed: unknown try { parsed = JSON.parse(repaired) @@ -138,7 +167,9 @@ export function dedupeExactCrossLane(a: SanitizedReview, b: SanitizedReview): Cr const aIndexByKey = new Map() a.findings.forEach((finding, index) => { const key = keyOf(finding) - if (key !== null && !aIndexByKey.has(key)) aIndexByKey.set(key, index) + if (key !== null && !aIndexByKey.has(key)) { + aIndexByKey.set(key, index) + } }) const aFindings = [...a.findings] @@ -156,7 +187,9 @@ export function dedupeExactCrossLane(a: SanitizedReview, b: SanitizedReview): Cr merged++ const kept = aFindings[aIndex] as Finding const severity = - SEVERITY_ORDER[finding.severity] > SEVERITY_ORDER[kept.severity] ? finding.severity : kept.severity + SEVERITY_ORDER[finding.severity] > SEVERITY_ORDER[kept.severity] + ? finding.severity + : kept.severity aFindings[aIndex] = { ...kept, severity, consensus: true } }) @@ -173,7 +206,11 @@ export function dedupeExactCrossLane(a: SanitizedReview, b: SanitizedReview): Cr } } - return { a: { ...a, findings: aFindings }, b: { ...b, findings: bFindings, narrative: bNarrative }, merged } + return { + a: { ...a, findings: aFindings }, + b: { ...b, findings: bFindings, narrative: bNarrative }, + merged, + } } type Candidate = { @@ -187,7 +224,9 @@ type Candidate = { function mergeReviewedFiles(entries: ReviewedFile[]): ReviewedFile[] { const statusByPath = new Map() for (const { path, status } of entries) { - if (status === 'findings' || !statusByPath.has(path)) statusByPath.set(path, status) + if (status === 'findings' || !statusByPath.has(path)) { + statusByPath.set(path, status) + } } return [...statusByPath].map(([path, status]) => ({ path, status })) } @@ -202,10 +241,24 @@ function mergeReviewedFiles(entries: ReviewedFile[]): ReviewedFile[] { * of the two reviews; the narrative is reviewer A's, refs remapped to the * merged findings even when the kept representative is a B finding. */ -export function assembleDualReview(a: SanitizedReview, b: SanitizedReview, judge: JudgeOutput): DualAssembly { +export function assembleDualReview( + a: SanitizedReview, + b: SanitizedReview, + judge: JudgeOutput, +): DualAssembly { const candidates: Candidate[] = [ - ...a.findings.map((finding, index) => ({ id: `A${index}`, side: 'A' as const, index, finding })), - ...b.findings.map((finding, index) => ({ id: `B${index}`, side: 'B' as const, index, finding })), + ...a.findings.map((finding, index) => ({ + id: `A${index}`, + side: 'A' as const, + index, + finding, + })), + ...b.findings.map((finding, index) => ({ + id: `B${index}`, + side: 'B' as const, + index, + finding, + })), ] const candidateById = new Map(candidates.map((c) => [c.id, c])) const decisionById = new Map(judge.decisions.map((d) => [d.id, d])) @@ -215,7 +268,9 @@ export function assembleDualReview(a: SanitizedReview, b: SanitizedReview, judge let current = id for (;;) { const next = decisionById.get(current)?.duplicate_of - if (!next || !candidateById.has(next) || visited.has(next)) return current + if (!next || !candidateById.has(next) || visited.has(next)) { + return current + } visited.add(next) current = next } @@ -229,7 +284,12 @@ export function assembleDualReview(a: SanitizedReview, b: SanitizedReview, judge membersByRoot.set(root, members) } - type Group = { representative: Candidate; members: Candidate[]; consensus: boolean; survives: boolean } + type Group = { + representative: Candidate + members: Candidate[] + consensus: boolean + survives: boolean + } const groupByMemberId = new Map() const groups: Group[] = [] for (const [rootId, members] of membersByRoot) { @@ -241,7 +301,9 @@ export function assembleDualReview(a: SanitizedReview, b: SanitizedReview, judge // A rejected root must not speak for members it absorbed: a security // finding, or one the judge explicitly kept, takes over instead of being // silently dropped with the root. - if (rejected) representative = securityMember ?? keptMember ?? representative + if (rejected) { + representative = securityMember ?? keptMember ?? representative + } const group: Group = { representative, members, @@ -249,19 +311,28 @@ export function assembleDualReview(a: SanitizedReview, b: SanitizedReview, judge survives: !rejected || securityMember !== undefined || keptMember !== undefined, } groups.push(group) - for (const member of members) groupByMemberId.set(member.id, group) + for (const member of members) { + groupByMemberId.set(member.id, group) + } } const stats: DualStats = { merged: 0, rejected: 0, added_by_b: 0 } for (const group of groups) { - if (!group.survives) stats.rejected += group.members.length - else stats.merged += group.members.length - 1 + if (!group.survives) { + stats.rejected += group.members.length + } else { + stats.merged += group.members.length - 1 + } } - stats.added_by_b = groups.filter((g) => g.survives && g.representative.side === 'B' && !g.consensus).length + stats.added_by_b = groups.filter( + (g) => g.survives && g.representative.side === 'B' && !g.consensus, + ).length const mergedFinding = (group: Group): Finding => { const severities = group.members.map((m) => m.finding.severity) - const maxSeverity = severities.reduce((worst, s) => (SEVERITY_ORDER[s] > SEVERITY_ORDER[worst] ? s : worst)) + const maxSeverity = severities.reduce((worst, s) => + SEVERITY_ORDER[s] > SEVERITY_ORDER[worst] ? s : worst, + ) const severity = decisionById.get(group.representative.id)?.severity ?? maxSeverity return { ...group.representative.finding, @@ -274,15 +345,21 @@ export function assembleDualReview(a: SanitizedReview, b: SanitizedReview, judge const finalIndexByGroup = new Map() for (const candidate of candidates) { const group = groupByMemberId.get(candidate.id) as Group - if (!group.survives || group.representative.id !== candidate.id) continue + if (!group.survives || group.representative.id !== candidate.id) { + continue + } finalIndexByGroup.set(group, findings.length) findings.push(mergedFinding(group)) } const newIndexByAIndex = new Map() for (const candidate of candidates) { - if (candidate.side !== 'A') continue + if (candidate.side !== 'A') { + continue + } const finalIndex = finalIndexByGroup.get(groupByMemberId.get(candidate.id) as Group) - if (finalIndex !== undefined) newIndexByAIndex.set(candidate.index, finalIndex) + if (finalIndex !== undefined) { + newIndexByAIndex.set(candidate.index, finalIndex) + } } let narrative = a.narrative @@ -309,11 +386,17 @@ export function assembleDualReview(a: SanitizedReview, b: SanitizedReview, judge // all still speaks (an approve without findings is legitimate). const laneSurvived = { A: false, B: false } for (const candidate of candidates) { - if ((groupByMemberId.get(candidate.id) as Group).survives) laneSurvived[candidate.side] = true + if ((groupByMemberId.get(candidate.id) as Group).survives) { + laneSurvived[candidate.side] = true + } } const verdicts: Verdict[] = [] - if (a.findings.length === 0 || laneSurvived.A) verdicts.push(a.verdict) - if (b.findings.length === 0 || laneSurvived.B) verdicts.push(b.verdict) + if (a.findings.length === 0 || laneSurvived.A) { + verdicts.push(a.verdict) + } + if (b.findings.length === 0 || laneSurvived.B) { + verdicts.push(b.verdict) + } const verdict = verdicts.length > 0 ? verdicts.reduce(worstVerdict) : 'comment' const files_reviewed = diff --git a/packages/cli/src/export.ts b/packages/cli/src/export.ts index 0f6661f..05baba2 100644 --- a/packages/cli/src/export.ts +++ b/packages/cli/src/export.ts @@ -1,15 +1,21 @@ import { writeFileSync } from 'node:fs' import { join } from 'node:path' -import type { Finding, ReviewRecord } from './contract.js' import { ensureWorkDir } from './config.js' +import type { Finding, ReviewRecord } from './contract.js' import { repoRoot } from './git.js' import { t } from './i18n.js' import { resolveRecord } from './record.js' function verdictLabel(verdict: string): string { - if (verdict === 'approve') return t('export.verdictApprove') - if (verdict === 'request_changes') return t('export.verdictChanges') - if (verdict === 'comment') return t('export.verdictComment') + if (verdict === 'approve') { + return t('export.verdictApprove') + } + if (verdict === 'request_changes') { + return t('export.verdictChanges') + } + if (verdict === 'comment') { + return t('export.verdictComment') + } return verdict } @@ -22,9 +28,13 @@ function renderFinding(f: Finding, index: number): string { const parts: string[] = [] const badge = [f.severity, f.kind].filter(Boolean).join(' / ') parts.push(`### ${index + 1}. ${findingAnchor(f)} — ${badge}`) - if (f.title) parts.push(`**${f.title}**`) + if (f.title) { + parts.push(`**${f.title}**`) + } parts.push(f.message) - if (f.suggestion) parts.push('```suggestion\n' + f.suggestion + '\n```') + if (f.suggestion) { + parts.push('```suggestion\n' + f.suggestion + '\n```') + } return parts.join('\n\n') } @@ -49,20 +59,33 @@ export function renderMarkdown(record: ReviewRecord): string { } if (n) { - if (n.intent) out.push(`**${t('export.intent')}:** ${n.intent} _(${t('export.confidence')}: ${n.confidence})_`) + if (n.intent) { + out.push( + `**${t('export.intent')}:** ${n.intent} _(${t('export.confidence')}: ${n.confidence})_`, + ) + } if (n.prologue) { out.push(`## ${t('export.prologue')}`) const p: string[] = [] - if (n.prologue.why) p.push(`**${t('export.why')}:** ${n.prologue.why}`) - if (n.prologue.what) p.push(`**${t('export.what')}:** ${n.prologue.what}`) - for (const kc of n.prologue.key_changes) p.push(`- **${kc.title}**${kc.detail ? ` — ${kc.detail}` : ''}`) + if (n.prologue.why) { + p.push(`**${t('export.why')}:** ${n.prologue.why}`) + } + if (n.prologue.what) { + p.push(`**${t('export.what')}:** ${n.prologue.what}`) + } + for (const kc of n.prologue.key_changes) { + p.push(`- **${kc.title}**${kc.detail ? ` — ${kc.detail}` : ''}`) + } out.push(p.join('\n\n')) } if (n.review_first.length) { out.push(`## ${t('export.reviewFirst')}`) out.push( n.review_first - .map((rf, i) => `${i + 1}. **[${t(`risk.${rf.risk}`)}]** ${rf.point}${rf.file ? ` (\`${rf.file}\`)` : ''}`) + .map( + (rf, i) => + `${i + 1}. **[${t(`risk.${rf.risk}`)}]** ${rf.point}${rf.file ? ` (\`${rf.file}\`)` : ''}`, + ) .join('\n'), ) } @@ -71,12 +94,22 @@ export function renderMarkdown(record: ReviewRecord): string { n.steps.forEach((ch, i) => { const head = `### ${i + 1}. ${ch.title}${ch.risk ? ` — ${t('export.risk', { risk: t(`risk.${ch.risk}`) })}` : ''}` const body: string[] = [head] - if (ch.rationale) body.push(ch.rationale) - if (ch.take) body.push(`> ${ch.take}`) - if (ch.check) body.push(`- [ ] ${t('export.toVerify')}: ${ch.check}`) - if (ch.files.length) body.push(`${t('export.files')}: ${ch.files.map((f) => `\`${f}\``).join(', ')}`) + if (ch.rationale) { + body.push(ch.rationale) + } + if (ch.take) { + body.push(`> ${ch.take}`) + } + if (ch.check) { + body.push(`- [ ] ${t('export.toVerify')}: ${ch.check}`) + } + if (ch.files.length) { + body.push(`${t('export.files')}: ${ch.files.map((f) => `\`${f}\``).join(', ')}`) + } if (ch.finding_refs.length) { - body.push(`${t('export.findingsRefs')}: ${ch.finding_refs.map((r) => `#${r + 1}`).join(', ')}`) + body.push( + `${t('export.findingsRefs')}: ${ch.finding_refs.map((r) => `#${r + 1}`).join(', ')}`, + ) } out.push(body.join('\n\n')) }) @@ -91,7 +124,11 @@ export function renderMarkdown(record: ReviewRecord): string { return `${out.join('\n\n')}\n` } -export function exportCommand(opts: { review?: string; out?: string; cwd: string }): void { +export function exportCommand(opts: { + review?: string | undefined + out?: string | undefined + cwd: string +}): void { const cwd = repoRoot(opts.cwd) const { record, sourcePath } = resolveRecord({ review: opts.review, cwd }) const markdown = renderMarkdown(record) diff --git a/packages/cli/src/fix.test.ts b/packages/cli/src/fix.test.ts index a5f46e3..3545e58 100644 --- a/packages/cli/src/fix.test.ts +++ b/packages/cli/src/fix.test.ts @@ -5,22 +5,32 @@ import { buildAgentFixPrompt, createFixRunner, fixCommandFor } from './fix.js' describe('fixCommandFor', () => { test('claude gets acceptEdits permission mode', () => { expect(fixCommandFor('claude -p')).toBe('claude -p --permission-mode acceptEdits') - expect(fixCommandFor('claude -p --model opus')).toBe('claude -p --model opus --permission-mode acceptEdits') + expect(fixCommandFor('claude -p --model opus')).toBe( + 'claude -p --model opus --permission-mode acceptEdits', + ) }) test('codex exec gets a workspace-write sandbox, before the stdin dash', () => { expect(fixCommandFor('codex exec -')).toBe('codex exec --sandbox workspace-write -') - expect(fixCommandFor('codex exec -m gpt-5.5 -')).toBe('codex exec --sandbox workspace-write -m gpt-5.5 -') + expect(fixCommandFor('codex exec -m gpt-5.5 -')).toBe( + 'codex exec --sandbox workspace-write -m gpt-5.5 -', + ) }) test('gemini gets auto_edit approval mode', () => { expect(fixCommandFor('gemini')).toBe('gemini --approval-mode auto_edit') - expect(fixCommandFor('gemini -m gemini-2.5-pro')).toBe('gemini -m gemini-2.5-pro --approval-mode auto_edit') + expect(fixCommandFor('gemini -m gemini-2.5-pro')).toBe( + 'gemini -m gemini-2.5-pro --approval-mode auto_edit', + ) }) test('commands already carrying an edit flag are left alone', () => { - expect(fixCommandFor('claude -p --permission-mode plan')).toBe('claude -p --permission-mode plan') - expect(fixCommandFor('codex exec --sandbox danger-full-access -')).toBe('codex exec --sandbox danger-full-access -') + expect(fixCommandFor('claude -p --permission-mode plan')).toBe( + 'claude -p --permission-mode plan', + ) + expect(fixCommandFor('codex exec --sandbox danger-full-access -')).toBe( + 'codex exec --sandbox danger-full-access -', + ) expect(fixCommandFor('codex exec --full-auto -')).toBe('codex exec --full-auto -') expect(fixCommandFor('gemini --yolo')).toBe('gemini --yolo') expect(fixCommandFor('gemini --approval-mode yolo')).toBe('gemini --approval-mode yolo') @@ -38,7 +48,13 @@ function record() { verdict: 'request_changes', summary: 's', findings: [ - { file: 'src/a.ts', line: 3, severity: 'major', message: 'broken null check', suggestion: 'use ??' }, + { + file: 'src/a.ts', + line: 3, + severity: 'major', + message: 'broken null check', + suggestion: 'use ??', + }, { file: 'src/b.ts', severity: 'minor', message: 'rename this' }, { file: 'src/c.ts', severity: 'info', kind: 'praise', message: 'nice' }, ], @@ -108,8 +124,14 @@ describe('createFixRunner', () => { }) const started = runner.start([0, 1]) expect(started.ok).toBe(true) - while (runner.status().phase === 'running') await new Promise((r) => setTimeout(r, 5)) - expect(runner.status()).toMatchObject({ phase: 'done', summary: 'two files patched', selected: [0, 1] }) + while (runner.status().phase === 'running') { + await new Promise((r) => setTimeout(r, 5)) + } + expect(runner.status()).toMatchObject({ + phase: 'done', + summary: 'two files patched', + selected: [0, 1], + }) expect(seenPrompt).toContain('broken null check') expect(seenCommand).toBe('claude -p --permission-mode acceptEdits') }) @@ -140,7 +162,9 @@ describe('createFixRunner', () => { expect(runner.start([0]).ok).toBe(true) expect(runner.start([0])).toMatchObject({ ok: false, code: 409 }) release() - while (runner.status().phase === 'running') await new Promise((r) => setTimeout(r, 5)) + while (runner.status().phase === 'running') { + await new Promise((r) => setTimeout(r, 5)) + } expect(runner.start([1]).ok).toBe(true) }) @@ -151,7 +175,9 @@ describe('createFixRunner', () => { }, }) expect(runner.start([0]).ok).toBe(true) - while (runner.status().phase === 'running') await new Promise((r) => setTimeout(r, 5)) + while (runner.status().phase === 'running') { + await new Promise((r) => setTimeout(r, 5)) + } expect(runner.status()).toMatchObject({ phase: 'error', error: 'agent exploded' }) expect(runner.start([0]).ok).toBe(true) }) diff --git a/packages/cli/src/fix.ts b/packages/cli/src/fix.ts index 43b4635..4ed6582 100644 --- a/packages/cli/src/fix.ts +++ b/packages/cli/src/fix.ts @@ -1,5 +1,4 @@ -import type { AgentRunOptions } from './agent.js' -import { runAgent } from './agent.js' +import { runAgent, type AgentRunOptions } from './agent.js' import type { Finding, ReviewRecord } from './contract.js' import { tryGit } from './git.js' @@ -15,28 +14,39 @@ const MAX_SUMMARY_CHARS = 4000 */ export function fixCommandFor(command: string): string { if (/^claude(\s|$)/.test(command)) { - if (command.includes('--permission-mode')) return command + if (command.includes('--permission-mode')) { + return command + } return `${command} --permission-mode acceptEdits` } if (/^codex\s+exec(\s|$)/.test(command)) { - if (command.includes('--sandbox') || command.includes('--full-auto')) return command + if (command.includes('--sandbox') || command.includes('--full-auto')) { + return command + } return command.replace(/^codex\s+exec/, 'codex exec --sandbox workspace-write') } if (/^gemini(\s|$)/.test(command)) { - if (command.includes('--approval-mode') || command.includes('--yolo')) return command + if (command.includes('--approval-mode') || command.includes('--yolo')) { + return command + } return `${command} --approval-mode auto_edit` } return command } function isFixable(finding: Finding): boolean { - if (finding.kind === 'praise' || finding.kind === 'why') return false + if (finding.kind === 'praise' || finding.kind === 'why') { + return false + } return finding.severity !== 'info' } export function buildAgentFixPrompt(record: ReviewRecord, ids: number[]): string { - const findings = ids.map((id) => { - const f = record.review.findings[id]! + const findings = ids.flatMap((id) => { + const f = record.review.findings[id] + if (!f) { + return [] + } return { file: f.file, ...(f.line !== undefined ? { line: f.line } : {}), @@ -104,18 +114,28 @@ export function createFixRunner(opts: { function headMoved(): boolean { const reviewedSha = opts.getRecord()?.meta.head_sha - if (!reviewedSha) return false + if (!reviewedSha) { + return false + } const head = currentHead() return head !== null && head.trim() !== reviewedSha } function validate(record: ReviewRecord, ids: number[]): string | null { - if (!Array.isArray(ids) || ids.length === 0) return 'no findings selected' + if (!Array.isArray(ids) || ids.length === 0) { + return 'no findings selected' + } for (const id of ids) { - if (!Number.isInteger(id)) return 'invalid finding id' + if (!Number.isInteger(id)) { + return 'invalid finding id' + } const finding = record.review.findings[id] - if (!finding) return 'unknown finding id' - if (!isFixable(finding)) return 'finding has nothing to fix' + if (!finding) { + return 'unknown finding id' + } + if (!isFixable(finding)) { + return 'finding has nothing to fix' + } } return null } @@ -131,11 +151,17 @@ export function createFixRunner(opts: { head_moved: headMoved(), }), start(ids) { - if (phase === 'running') return { ok: false, code: 409, error: 'a fix is already running' } + if (phase === 'running') { + return { ok: false, code: 409, error: 'a fix is already running' } + } const record = opts.getRecord() - if (!record) return { ok: false, code: 409, error: 'no review available yet' } + if (!record) { + return { ok: false, code: 409, error: 'no review available yet' } + } const invalid = validate(record, ids) - if (invalid) return { ok: false, code: 400, error: invalid } + if (invalid) { + return { ok: false, code: 400, error: invalid } + } phase = 'running' selected = [...ids] diff --git a/packages/cli/src/git.test.ts b/packages/cli/src/git.test.ts new file mode 100644 index 0000000..ae54f57 --- /dev/null +++ b/packages/cli/src/git.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test' +import { subprocessEnv } from './git.js' + +describe('subprocessEnv', () => { + test('purges variables that redirect git to a different repo', () => { + const source = { + PATH: '/usr/bin', + GIT_DIR: '/some/other/repo/.git', + GIT_WORK_TREE: '/some/other/repo', + GIT_INDEX_FILE: '/some/other/repo/.git/index', + GIT_OBJECT_DIRECTORY: '/some/other/repo/.git/objects', + GIT_COMMON_DIR: '/some/other/repo/.git', + GIT_PREFIX: 'sub/dir/', + GIT_ALTERNATE_OBJECT_DIRECTORIES: '/some/other/repo/.git/objects-alt', + GIT_QUARANTINE_PATH: '/some/other/repo/.git/objects/incoming', + } + const result = subprocessEnv(source) + expect(result.GIT_DIR).toBeUndefined() + expect(result.GIT_WORK_TREE).toBeUndefined() + expect(result.GIT_INDEX_FILE).toBeUndefined() + expect(result.GIT_OBJECT_DIRECTORY).toBeUndefined() + expect(result.GIT_COMMON_DIR).toBeUndefined() + expect(result.GIT_PREFIX).toBeUndefined() + expect(result.GIT_ALTERNATE_OBJECT_DIRECTORIES).toBeUndefined() + expect(result.GIT_QUARANTINE_PATH).toBeUndefined() + expect(result.PATH).toBe('/usr/bin') + }) + + test('keeps legitimate user GIT_* settings untouched, not just non-GIT vars', () => { + const source = { + GIT_DIR: '/some/other/repo/.git', + GIT_SSH_COMMAND: 'ssh -i ~/.ssh/deploy_key', + GIT_AUTHOR_NAME: 'Ada Lovelace', + GIT_AUTHOR_EMAIL: 'ada@example.com', + GIT_COMMITTER_NAME: 'Ada Lovelace', + GIT_COMMITTER_EMAIL: 'ada@example.com', + GIT_CONFIG_GLOBAL: '/custom/gitconfig', + GIT_ASKPASS: '/usr/bin/my-askpass', + } + const result = subprocessEnv(source) + expect(result.GIT_DIR).toBeUndefined() + expect(result.GIT_SSH_COMMAND).toBe('ssh -i ~/.ssh/deploy_key') + expect(result.GIT_AUTHOR_NAME).toBe('Ada Lovelace') + expect(result.GIT_AUTHOR_EMAIL).toBe('ada@example.com') + expect(result.GIT_COMMITTER_NAME).toBe('Ada Lovelace') + expect(result.GIT_COMMITTER_EMAIL).toBe('ada@example.com') + expect(result.GIT_CONFIG_GLOBAL).toBe('/custom/gitconfig') + expect(result.GIT_ASKPASS).toBe('/usr/bin/my-askpass') + }) + + test('defaults to process.env when no source is given', () => { + const previous = process.env.GIT_DIR + process.env.GIT_DIR = '/some/other/repo/.git' + try { + const result = subprocessEnv() + expect(result.GIT_DIR).toBeUndefined() + } finally { + if (previous === undefined) { + delete process.env.GIT_DIR + } else { + process.env.GIT_DIR = previous + } + } + }) +}) diff --git a/packages/cli/src/git.ts b/packages/cli/src/git.ts index 983860b..24aef42 100644 --- a/packages/cli/src/git.ts +++ b/packages/cli/src/git.ts @@ -1,6 +1,29 @@ import { execFileSync } from 'node:child_process' import { t } from './i18n.js' +// Set by git itself on the hooks it invokes (this CLI's own pre-commit/pre-push, +// or any enclosing process's), these redirect every git call below away from +// `cwd` and onto whatever repo set them. `cwd` is the only intended source of +// truth here, so exactly these must never propagate. Deliberately NOT a blanket +// GIT_*: user settings like GIT_SSH_COMMAND, GIT_AUTHOR_*/GIT_COMMITTER_* or +// GIT_CONFIG_GLOBAL are legitimate and must reach the subprocess unchanged. +const REPO_LOCATION_ENV_VARS = new Set([ + 'GIT_DIR', + 'GIT_WORK_TREE', + 'GIT_INDEX_FILE', + 'GIT_OBJECT_DIRECTORY', + 'GIT_COMMON_DIR', + 'GIT_PREFIX', + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_QUARANTINE_PATH', +]) + +export function subprocessEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return Object.fromEntries( + Object.entries(source).filter(([key]) => !REPO_LOCATION_ENV_VARS.has(key)), + ) +} + export function git(args: string[], cwd: string): string { try { // stderr captured, not inherited: failing probes don't pollute the output @@ -9,10 +32,11 @@ export function git(args: string[], cwd: string): string { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'], + env: subprocessEnv(), }).trimEnd() } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - throw new Error(t('git.notFound')) + throw new Error(t('git.notFound'), { cause: err }) } throw err } @@ -29,7 +53,13 @@ export function tryGit(args: string[], cwd: string): string | null { /** Optional external command (gh, glab): null if missing, failing, or too slow. */ export function tryExec(cmd: string, args: string[], cwd: string): string | null { try { - return execFileSync(cmd, args, { cwd, encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() + return execFileSync(cmd, args, { + cwd, + encoding: 'utf8', + timeout: 8000, + stdio: ['ignore', 'pipe', 'ignore'], + env: subprocessEnv(), + }).trim() } catch { return null } @@ -61,7 +91,9 @@ export function isAncestor(a: string, b: string, cwd: string): boolean { export function revListCount(range: string, cwd: string): number | null { const out = tryGit(['rev-list', '--count', range], cwd) - if (out === null) return null + if (out === null) { + return null + } const n = Number(out) return Number.isFinite(n) ? n : null } diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts index 23c8c69..0c8cd0f 100644 --- a/packages/cli/src/i18n.ts +++ b/packages/cli/src/i18n.ts @@ -57,16 +57,21 @@ version exists (nothing is sent). Set CODESEMA_NO_UPDATE_CHECK=1 to disable. 'agent.exitCode': 'agent command exited with code {code}', 'agent.noneFound': "no supported agent CLI found on PATH (looked for: {bins}) — pass one with --agent '' (it receives the full prompt on stdin and must print the review JSON on stdout)", - 'agent.noJsonReview': 'the agent did not return a JSON review (raw output saved to .codesema/agent-output.txt)', + 'agent.noJsonReview': + 'the agent did not return a JSON review (raw output saved to .codesema/agent-output.txt)', - 'prep.detachedHead': 'detached HEAD — checkout the branch you want reviewed first, or pass --branch ', + 'prep.detachedHead': + 'detached HEAD — checkout the branch you want reviewed first, or pass --branch ', 'prep.branchNotFound': '--branch {branch}: local branch not found', 'prep.targetFlagNotFound': '--target {flag}: branch not found (neither local nor origin/{flag})', 'prep.noTarget': 'could not detect the target branch — pass it explicitly with --target ', - 'prep.targetIsSelf': '"{branch}" is the target branch itself — pick your feature branch, or pass --target ', - 'prep.noMergeBase': 'no merge-base between {target} and {branch} — pass another base with --target ', + 'prep.targetIsSelf': + '"{branch}" is the target branch itself — pick your feature branch, or pass --target ', + 'prep.noMergeBase': + 'no merge-base between {target} and {branch} — pass another base with --target ', 'prep.emptyDiff': 'empty diff between {target} and {branch} — nothing to review.{hint}', - 'prep.dirtyHint': ' Your working tree has uncommitted changes: commit them first, codesema reviews committed work.', + 'prep.dirtyHint': + ' Your working tree has uncommitted changes: commit them first, codesema reviews committed work.', 'prep.title': 'codesema prep', 'prep.label.branch': 'branch', 'prep.label.target': 'target', @@ -76,11 +81,14 @@ version exists (nothing is sent). Set CODESEMA_NO_UPDATE_CHECK=1 to disable. 'prep.label.input': 'input', 'prep.customNote': '.codesema/PROMPT.md merged into instructions', 'prep.label.rules': 'rules', - 'prep.rulesNote': '{n} team rule from .codesema/RULES.md | {n} team rules from .codesema/RULES.md', - 'prep.next': 'Next: have your AI agent write .codesema/review.json (see the codesema skill), then run `codesema show`.', + 'prep.rulesNote': + '{n} team rule from .codesema/RULES.md | {n} team rules from .codesema/RULES.md', + 'prep.next': + 'Next: have your AI agent write .codesema/review.json (see the codesema skill), then run `codesema show`.', 'review.trustTitle': 'This repository provides its own review agent command:', - 'review.trustWarning': 'It runs on your machine, in your shell. Approve it only if you trust this repo.', + 'review.trustWarning': + 'It runs on your machine, in your shell. Approve it only if you trust this repo.', 'review.trustQuestion': 'Run this repo-provided agent command?', 'review.trustCancel': 'Cancel', 'review.trustCancelHint': 'do not run', @@ -99,13 +107,17 @@ version exists (nothing is sent). Set CODESEMA_NO_UPDATE_CHECK=1 to disable. 'review.dualLaneB': 'prosecutor', 'review.dualJudging': 'deliberation · {n} notes on the bench', 'review.dualJudgeProgress': 'deliberation · {done}/{total} adjudicated', - 'review.dualStats': 'dual review: {merged} merged · {rejected} rejected by the judge · {added} added by the prosecutor', + 'review.dualStats': + 'dual review: {merged} merged · {rejected} rejected by the judge · {added} added by the prosecutor', 'review.dualConsensus': '{n} note raised by both reviewers | {n} notes raised by both reviewers', 'review.coverageGap': '⚠ {lane} did not examine {n} file(s): {files}', - 'review.dualReviewerFailed': 'one reviewer failed ({message}); finished with the surviving review', + 'review.dualReviewerFailed': + 'one reviewer failed ({message}); finished with the surviving review', 'review.dualJudgeFailed': 'judge unusable ({message}); kept the union of both reviews', - 'review.customPrompt': 'custom instructions from .codesema/PROMPT.md merged into the agent prompt', - 'review.teamRules': '{n} team rule from .codesema/RULES.md drives the hunt pass | {n} team rules from .codesema/RULES.md drive the hunt pass', + 'review.customPrompt': + 'custom instructions from .codesema/PROMPT.md merged into the agent prompt', + 'review.teamRules': + '{n} team rule from .codesema/RULES.md drives the hunt pass | {n} team rules from .codesema/RULES.md drive the hunt pass', 'review.webLiveHint': '· live, findings appear as the agent works', 'review.spinner': 'reviewing with {cmd}', 'review.runFailed': 'agent run failed', @@ -115,8 +127,10 @@ version exists (nothing is sent). Set CODESEMA_NO_UPDATE_CHECK=1 to disable. 'review.gateReasonSeverity': '{n} finding(s) at or above {level}', 'review.gateReasonVerdict': 'changes requested', 'review.unusableOutput': 'unusable agent output', - 'review.groundedDropped': '{n} finding dropped: file not in the diff | {n} findings dropped: file not in the diff', - 'review.groundedDeanchored': '{n} line anchor removed: line outside the diff | {n} line anchors removed: lines outside the diff', + 'review.groundedDropped': + '{n} finding dropped: file not in the diff | {n} findings dropped: file not in the diff', + 'review.groundedDeanchored': + '{n} line anchor removed: line outside the diff | {n} line anchors removed: lines outside the diff', 'review.groundedMerged': '{n} duplicate finding merged | {n} duplicate findings merged', 'review.groundedVerdict': 'verdict escalated to request_changes: critical finding on an approve', 'review.ready': 'review ready', @@ -124,7 +138,8 @@ version exists (nothing is sent). Set CODESEMA_NO_UPDATE_CHECK=1 to disable. 'review.syncHint': 'codesema sync saves this review to your codesema.com workspace', 'review.syncPushed': '☁ review synced to your linked codesema.com workspace', 'review.syncAlready': '☁ review already synced (identical content)', - 'review.syncBlockedSecrets': '☁ sync skipped: {n} potential secret(s) in the diff · codesema sync --force to override', + 'review.syncBlockedSecrets': + '☁ sync skipped: {n} potential secret(s) in the diff · codesema sync --force to override', 'review.syncFailed': '☁ sync failed: {message} · the review stays archived locally', 'notify.failedRun': 'review failed: agent run failed', @@ -161,7 +176,8 @@ version exists (nothing is sent). Set CODESEMA_NO_UPDATE_CHECK=1 to disable. 'wizard.effort': 'Reasoning effort?', 'wizard.saved': 'saved: {path}', - 'config.notInteractive': '`codesema config` is interactive — run it from a terminal, or edit the config file directly', + 'config.notInteractive': + '`codesema config` is interactive — run it from a terminal, or edit the config file directly', 'config.currentAgent': 'current agent: {command}', 'config.fromPath': 'from {path}', 'config.saveWhere': 'Save where?', @@ -180,7 +196,8 @@ version exists (nothing is sent). Set CODESEMA_NO_UPDATE_CHECK=1 to disable. 'config.autoSyncOn': 'on', 'config.autoSyncOff': 'off', 'config.autoSyncUnset': 'not chosen yet (asked after the first `codesema sync`)', - 'config.autoSyncQuestion': 'Push every completed review to your codesema.com workspace automatically?', + 'config.autoSyncQuestion': + 'Push every completed review to your codesema.com workspace automatically?', 'config.autoSyncSaved': 'auto-sync {state}: {path}', 'config.back': 'Back', 'config.languageSaved': 'language saved: {path}', @@ -239,7 +256,8 @@ version exists (nothing is sent). Set CODESEMA_NO_UPDATE_CHECK=1 to disable. 'record.invalidJson': '{path} is not valid JSON — the agent output must be a single JSON object', 'record.noInput': '.codesema/input.json not found — run `codesema prep` first', 'record.reviewNotFound': 'review file not found: {path}', - 'record.nothingToShow': 'no review to show — run `codesema prep`, let your agent write .codesema/review.json, then retry', + 'record.nothingToShow': + 'no review to show — run `codesema prep`, let your agent write .codesema/review.json, then retry', 'export.verdictApprove': 'Approved ✅', 'export.verdictChanges': 'Changes requested ❌', @@ -291,8 +309,10 @@ version exists (nothing is sent). Set CODESEMA_NO_UPDATE_CHECK=1 to disable. 'sync.linkExpired': 'the link request expired before being confirmed: run `codesema link` again', 'sync.deleted': 'All synced data deleted and local credentials cleared.', 'sync.noCredentials': 'no synced workspace on this machine (run `codesema sync` first)', - 'sync.nonInteractiveSetup': 'sync is not set up: run `codesema sync` once in an interactive terminal to opt in', - 'sync.unknownAction': 'unknown sync action: {action} (expected `codesema sync` or `codesema sync delete`)', + 'sync.nonInteractiveSetup': + 'sync is not set up: run `codesema sync` once in an interactive terminal to opt in', + 'sync.unknownAction': + 'unknown sync action: {action} (expected `codesema sync` or `codesema sync delete`)', 'sync.unreachable': 'could not reach {url}: check your connection or CODESEMA_SYNC_URL', 'sync.badResponse': 'unexpected response from {url}: required fields are missing or invalid', @@ -376,7 +396,7 @@ version existe (rien n'est envoyé). CODESEMA_NO_UPDATE_CHECK=1 pour désactiver `, 'cli.unknownCommand': 'commande inconnue : {command}', 'cli.intFlagError': '--{name} {raw} : entier attendu entre {min} et {max}', - 'cli.failOnError': '--fail-on {raw} invalide : attendu l\'un de {values}', + 'cli.failOnError': "--fail-on {raw} invalide : attendu l'un de {values}", 'git.notFound': 'git introuvable sur le PATH : installez git (https://git-scm.com) et réessayez', @@ -384,16 +404,22 @@ version existe (rien n'est envoyé). CODESEMA_NO_UPDATE_CHECK=1 pour désactiver 'agent.exitCode': "la commande d'agent a quitté avec le code {code}", 'agent.noneFound': "aucune CLI d'agent trouvée sur le PATH (recherchées : {bins}) : passez-en une avec --agent '' (elle reçoit le prompt complet sur stdin et doit afficher le JSON de la revue sur stdout)", - 'agent.noJsonReview': "l'agent n'a pas renvoyé de revue JSON (sortie brute sauvegardée dans .codesema/agent-output.txt)", + 'agent.noJsonReview': + "l'agent n'a pas renvoyé de revue JSON (sortie brute sauvegardée dans .codesema/agent-output.txt)", - 'prep.detachedHead': 'HEAD détachée : positionnez-vous d\'abord sur la branche à passer en revue, ou passez --branch ', + 'prep.detachedHead': + "HEAD détachée : positionnez-vous d'abord sur la branche à passer en revue, ou passez --branch ", 'prep.branchNotFound': '--branch {branch} : branche locale introuvable', 'prep.targetFlagNotFound': '--target {flag} : branche introuvable (ni locale ni origin/{flag})', - 'prep.noTarget': 'impossible de détecter la branche cible : passez-la explicitement avec --target ', - 'prep.targetIsSelf': '"{branch}" est la branche cible elle-même : choisissez votre branche de feature, ou passez --target ', - 'prep.noMergeBase': 'pas de merge-base entre {target} et {branch} : passez une autre base avec --target ', + 'prep.noTarget': + 'impossible de détecter la branche cible : passez-la explicitement avec --target ', + 'prep.targetIsSelf': + '"{branch}" est la branche cible elle-même : choisissez votre branche de feature, ou passez --target ', + 'prep.noMergeBase': + 'pas de merge-base entre {target} et {branch} : passez une autre base avec --target ', 'prep.emptyDiff': 'diff vide entre {target} et {branch} : rien à passer en revue.{hint}', - 'prep.dirtyHint': ' Votre working tree a des changements non commités : commitez-les d\'abord, codesema passe en revue le travail commité.', + 'prep.dirtyHint': + " Votre working tree a des changements non commités : commitez-les d'abord, codesema passe en revue le travail commité.", 'prep.title': 'codesema prep', 'prep.label.branch': 'branche', 'prep.label.target': 'cible', @@ -403,12 +429,15 @@ version existe (rien n'est envoyé). CODESEMA_NO_UPDATE_CHECK=1 pour désactiver 'prep.label.input': 'entrée', 'prep.customNote': '.codesema/PROMPT.md fusionné dans les instructions', 'prep.label.rules': 'règles', - 'prep.rulesNote': '{n} règle d\'équipe depuis .codesema/RULES.md | {n} règles d\'équipe depuis .codesema/RULES.md', - 'prep.next': 'Ensuite : faites écrire .codesema/review.json à votre agent IA (voir le skill codesema), puis lancez `codesema show`.', - - 'review.trustTitle': 'Ce dépôt fournit sa propre commande d\'agent de revue :', - 'review.trustWarning': 'Elle s\'exécute sur votre machine, dans votre shell. Approuvez-la seulement si vous faites confiance à ce dépôt.', - 'review.trustQuestion': 'Exécuter cette commande d\'agent fournie par le dépôt ?', + 'prep.rulesNote': + "{n} règle d'équipe depuis .codesema/RULES.md | {n} règles d'équipe depuis .codesema/RULES.md", + 'prep.next': + 'Ensuite : faites écrire .codesema/review.json à votre agent IA (voir le skill codesema), puis lancez `codesema show`.', + + 'review.trustTitle': "Ce dépôt fournit sa propre commande d'agent de revue :", + 'review.trustWarning': + "Elle s'exécute sur votre machine, dans votre shell. Approuvez-la seulement si vous faites confiance à ce dépôt.", + 'review.trustQuestion': "Exécuter cette commande d'agent fournie par le dépôt ?", 'review.trustCancel': 'Annuler', 'review.trustCancelHint': 'ne pas exécuter', 'review.trustApprove': 'Approuver et exécuter', @@ -420,30 +449,38 @@ version existe (rien n'est envoyé). CODESEMA_NO_UPDATE_CHECK=1 pour désactiver 'review.commits': '{n} commit | {n} commits', 'review.findingCount': '{n} note | {n} notes', 'review.modeIncremental': 'incrémental', - 'review.modeIncrementalHint': '· mise à jour de la revue faite à {sha} · passez --full pour repartir de zéro', + 'review.modeIncrementalHint': + '· mise à jour de la revue faite à {sha} · passez --full pour repartir de zéro', 'review.modeDual': 'duale · deux reviewers + un juge', 'review.dualLaneA': 'reviewer', 'review.dualLaneB': 'procureur', 'review.dualJudging': 'délibération · {n} notes à arbitrer', 'review.dualJudgeProgress': 'délibération · {done}/{total} arbitrées', - 'review.dualStats': 'revue duale : {merged} fusionnées · {rejected} rejetées par le juge · {added} ajoutées par le procureur', - 'review.dualConsensus': '{n} note relevée par les deux reviewers | {n} notes relevées par les deux reviewers', - 'review.coverageGap': '⚠ {lane} n\'a pas examiné {n} fichier(s) : {files}', - 'review.dualReviewerFailed': 'un reviewer a échoué ({message}) ; la revue survivante a été utilisée', + 'review.dualStats': + 'revue duale : {merged} fusionnées · {rejected} rejetées par le juge · {added} ajoutées par le procureur', + 'review.dualConsensus': + '{n} note relevée par les deux reviewers | {n} notes relevées par les deux reviewers', + 'review.coverageGap': "⚠ {lane} n'a pas examiné {n} fichier(s) : {files}", + 'review.dualReviewerFailed': + 'un reviewer a échoué ({message}) ; la revue survivante a été utilisée', 'review.dualJudgeFailed': 'juge inutilisable ({message}) ; union des deux revues conservée', - 'review.customPrompt': 'instructions personnalisées de .codesema/PROMPT.md fusionnées dans le prompt de l\'agent', - 'review.teamRules': '{n} règle d\'équipe de .codesema/RULES.md guide la passe de chasse | {n} règles d\'équipe de .codesema/RULES.md guident la passe de chasse', - 'review.webLiveHint': '· en direct, les notes apparaissent pendant que l\'agent travaille', + 'review.customPrompt': + "instructions personnalisées de .codesema/PROMPT.md fusionnées dans le prompt de l'agent", + 'review.teamRules': + "{n} règle d'équipe de .codesema/RULES.md guide la passe de chasse | {n} règles d'équipe de .codesema/RULES.md guident la passe de chasse", + 'review.webLiveHint': "· en direct, les notes apparaissent pendant que l'agent travaille", 'review.spinner': 'revue avec {cmd}', - 'review.runFailed': 'échec de l\'agent', - 'review.runFailedDetail': 'échec de l\'agent : {message}', + 'review.runFailed': "échec de l'agent", + 'review.runFailedDetail': "échec de l'agent : {message}", 'review.stillUp': '{url} toujours actif · Ctrl+C pour arrêter', 'review.gateFailed': 'gate CI échoué : {reason}', 'review.gateReasonSeverity': '{n} finding(s) au niveau {level} ou supérieur', 'review.gateReasonVerdict': 'changements demandés', - 'review.unusableOutput': 'sortie d\'agent inutilisable', - 'review.groundedDropped': '{n} finding écarté : fichier absent du diff | {n} findings écartés : fichier absent du diff', - 'review.groundedDeanchored': '{n} ancre de ligne retirée : ligne hors du diff | {n} ancres de ligne retirées : lignes hors du diff', + 'review.unusableOutput': "sortie d'agent inutilisable", + 'review.groundedDropped': + '{n} finding écarté : fichier absent du diff | {n} findings écartés : fichier absent du diff', + 'review.groundedDeanchored': + '{n} ancre de ligne retirée : ligne hors du diff | {n} ancres de ligne retirées : lignes hors du diff', 'review.groundedMerged': '{n} finding doublon fusionné | {n} findings doublons fusionnés', 'review.groundedVerdict': 'verdict passé à request_changes : finding critique sur un approve', 'review.ready': 'revue prête', @@ -451,11 +488,12 @@ version existe (rien n'est envoyé). CODESEMA_NO_UPDATE_CHECK=1 pour désactiver 'review.syncHint': 'codesema sync enregistre cette review dans votre workspace codesema.com', 'review.syncPushed': '☁ revue synchronisée sur votre workspace codesema.com lié', 'review.syncAlready': '☁ revue déjà synchronisée (contenu identique)', - 'review.syncBlockedSecrets': '☁ synchro ignorée : {n} secret(s) potentiel(s) dans la diff · codesema sync --force pour forcer', + 'review.syncBlockedSecrets': + '☁ synchro ignorée : {n} secret(s) potentiel(s) dans la diff · codesema sync --force pour forcer', 'review.syncFailed': '☁ échec de la synchro : {message} · la revue reste archivée en local', - 'notify.failedRun': 'échec de la revue : échec de l\'agent', - 'notify.failedOutput': 'échec de la revue : sortie d\'agent inutilisable', + 'notify.failedRun': "échec de la revue : échec de l'agent", + 'notify.failedOutput': "échec de la revue : sortie d'agent inutilisable", 'notify.ready': 'revue prête · {findings} · {verdict}', 'field.branch': 'branche', @@ -470,15 +508,17 @@ version existe (rien n'est envoyé). CODESEMA_NO_UPDATE_CHECK=1 pour désactiver 'field.status': 'statut', 'field.account': 'compte', - 'wizard.firstRun': 'Premier lancement : choisissez l\'agent qui fera la revue de votre code.', - 'wizard.firstRunHint': 'Sauvegardé une fois, pour tous les dépôts. Modifiable à tout moment avec `codesema config`.', + 'wizard.firstRun': "Premier lancement : choisissez l'agent qui fera la revue de votre code.", + 'wizard.firstRunHint': + 'Sauvegardé une fois, pour tous les dépôts. Modifiable à tout moment avec `codesema config`.', 'wizard.notOnPath': 'introuvable sur le PATH : {bins}', 'wizard.whichAgent': 'Quel agent IA fait la revue ?', 'wizard.current': 'actuel', 'wizard.customCommand': 'Commande personnalisée', 'wizard.stdinStdout': 'stdin → stdout', - 'wizard.fullCommandTitle': 'Commande d\'agent complète', - 'wizard.fullCommandPlaceholder': 'lit le prompt sur stdin, affiche le JSON de la revue sur stdout', + 'wizard.fullCommandTitle': "Commande d'agent complète", + 'wizard.fullCommandPlaceholder': + 'lit le prompt sur stdin, affiche le JSON de la revue sur stdout', 'wizard.modelFor': 'Modèle pour {label} ?', 'wizard.cliDefault': 'Défaut CLI', 'wizard.letDecide': 'laisser {bin} décider', @@ -488,7 +528,8 @@ version existe (rien n'est envoyé). CODESEMA_NO_UPDATE_CHECK=1 pour désactiver 'wizard.effort': 'Effort de raisonnement ?', 'wizard.saved': 'sauvegardé : {path}', - 'config.notInteractive': '`codesema config` est interactif : lancez-le depuis un terminal, ou éditez directement le fichier de config', + 'config.notInteractive': + '`codesema config` est interactif : lancez-le depuis un terminal, ou éditez directement le fichier de config', 'config.currentAgent': 'agent actuel : {command}', 'config.fromPath': 'depuis {path}', 'config.saveWhere': 'Sauvegarder où ?', @@ -496,7 +537,7 @@ version existe (rien n'est envoyé). CODESEMA_NO_UPDATE_CHECK=1 pour désactiver 'config.everywhereHint': 'config globale, tous les dépôts', 'config.thisRepo': 'Ce dépôt uniquement', 'config.thisRepoHint': '.codesema/config.json, prime sur la globale', - 'config.agentSaved': 'commande d\'agent sauvegardée : {command}', + 'config.agentSaved': "commande d'agent sauvegardée : {command}", 'config.savedTo': 'config : {path}', 'config.menuTitle': 'Que voulez-vous configurer ?', 'config.agentEntry': 'Agent & modèle', @@ -507,7 +548,8 @@ version existe (rien n'est envoyé). CODESEMA_NO_UPDATE_CHECK=1 pour désactiver 'config.autoSyncOn': 'activé', 'config.autoSyncOff': 'désactivé', 'config.autoSyncUnset': 'pas encore choisi (proposé après le premier `codesema sync`)', - 'config.autoSyncQuestion': 'Pousser automatiquement chaque review terminée vers votre workspace codesema.com ?', + 'config.autoSyncQuestion': + 'Pousser automatiquement chaque review terminée vers votre workspace codesema.com ?', 'config.autoSyncSaved': 'auto-sync {state} : {path}', 'config.back': 'Retour', 'config.languageSaved': 'langue enregistrée : {path}', @@ -529,7 +571,7 @@ version existe (rien n'est envoyé). CODESEMA_NO_UPDATE_CHECK=1 pour désactiver 'upgrade.done': 'codesema mis à jour en {latest}', 'upgrade.failed': 'échec de la mise à jour : lancez {command} manuellement', 'ui.phaseReading': 'lecture du diff…', - 'ui.phaseCalls': 'suivi des chaînes d\'appel…', + 'ui.phaseCalls': "suivi des chaînes d'appel…", 'ui.phaseGrouping': 'regroupement des changements en étapes…', 'ui.phaseRisks': 'évaluation des risques…', 'ui.phaseStory': 'écriture du récit…', @@ -540,7 +582,7 @@ version existe (rien n'est envoyé). CODESEMA_NO_UPDATE_CHECK=1 pour désactiver 'ui.progressVerdict': 'verdict {verdict} · rédaction des notes', 'summary.none': 'aucune', - 'summary.checkFirst': 'à vérifier d\'abord', + 'summary.checkFirst': "à vérifier d'abord", 'summary.praiseCount': '{n} éloge | {n} éloges', 'summary.sevCritical': '{n} critique | {n} critiques', 'summary.sevMajor': '{n} majeure | {n} majeures', @@ -563,10 +605,12 @@ version existe (rien n'est envoyé). CODESEMA_NO_UPDATE_CHECK=1 pour désactiver 'serve.noWebUi': 'UI web embarquée introuvable dans {path} : installation ou build cassé', 'serve.noFreePort': 'aucun port libre entre {start} et {end}', - 'record.invalidJson': '{path} n\'est pas du JSON valide : la sortie de l\'agent doit être un unique objet JSON', - 'record.noInput': '.codesema/input.json introuvable : lancez d\'abord `codesema prep`', + 'record.invalidJson': + "{path} n'est pas du JSON valide : la sortie de l'agent doit être un unique objet JSON", + 'record.noInput': ".codesema/input.json introuvable : lancez d'abord `codesema prep`", 'record.reviewNotFound': 'fichier de revue introuvable : {path}', - 'record.nothingToShow': 'aucune revue à afficher : lancez `codesema prep`, laissez votre agent écrire .codesema/review.json, puis réessayez', + 'record.nothingToShow': + 'aucune revue à afficher : lancez `codesema prep`, laissez votre agent écrire .codesema/review.json, puis réessayez', 'export.verdictApprove': 'Approuvée ✅', 'export.verdictChanges': 'Changements demandés ❌', @@ -615,11 +659,15 @@ version existe (rien n'est envoyé). CODESEMA_NO_UPDATE_CHECK=1 pour désactiver 'sync.linkBrowserOpen': 'Confirmez le rattachement dans votre navigateur :', 'sync.linkWaiting': 'En attente de la confirmation dans le navigateur… (Ctrl+C pour annuler)', 'sync.linkFailed': 'Rattachement non confirmé.', - 'sync.linkExpired': 'la demande de rattachement a expiré avant d\'être confirmée : relancez `codesema link`', + 'sync.linkExpired': + "la demande de rattachement a expiré avant d'être confirmée : relancez `codesema link`", 'sync.deleted': 'Données synchronisées supprimées et credentials locaux effacés.', - 'sync.noCredentials': 'aucun workspace synchronisé sur cette machine (lancez `codesema sync` d\'abord)', - 'sync.nonInteractiveSetup': 'sync non configuré : lancez `codesema sync` une fois dans un terminal interactif pour l\'activer', - 'sync.unknownAction': 'action sync inconnue : {action} (attendu `codesema sync` ou `codesema sync delete`)', + 'sync.noCredentials': + "aucun workspace synchronisé sur cette machine (lancez `codesema sync` d'abord)", + 'sync.nonInteractiveSetup': + "sync non configuré : lancez `codesema sync` une fois dans un terminal interactif pour l'activer", + 'sync.unknownAction': + 'action sync inconnue : {action} (attendu `codesema sync` ou `codesema sync delete`)', 'sync.unreachable': 'impossible de joindre {url} : vérifiez votre connexion ou CODESEMA_SYNC_URL', 'sync.badResponse': 'réponse inattendue de {url} : champs requis manquants ou invalides', @@ -629,7 +677,7 @@ version existe (rien n'est envoyé). CODESEMA_NO_UPDATE_CHECK=1 pour désactiver 'menu.dualReview': 'Revue duale', 'menu.dualReviewHint': 'deux reviewers en parallèle, un juge fusionne leurs notes', 'menu.show': 'Afficher la dernière revue', - 'menu.showHint': 'ouvrir la dernière revue dans l\'UI web locale', + 'menu.showHint': "ouvrir la dernière revue dans l'UI web locale", 'menu.sync': 'Synchroniser', 'menu.syncHintPush': 'pousser la dernière revue', 'menu.syncHintSetup': 'pas encore configuré', @@ -642,10 +690,10 @@ version existe (rien n'est envoyé). CODESEMA_NO_UPDATE_CHECK=1 pour désactiver 'menu.syncDeleteConfirmDelete': 'Tout supprimer', 'menu.syncDeleteConfirmDeleteHint': 'irréversible', 'menu.config': 'Configuration', - 'menu.configHint': 'changer la langue, l\'agent, le modèle et l\'effort', + 'menu.configHint': "changer la langue, l'agent, le modèle et l'effort", 'menu.quit': 'Quitter', 'menu.needRepo': 'à lancer dans un repo git', - 'menu.notInRepo': 'pas dans un repo git : placez-vous d\'abord dans votre projet', + 'menu.notInRepo': "pas dans un repo git : placez-vous d'abord dans votre projet", 'menu.cloud': 'Cloud', 'menu.cloudTitle': 'Cloud · codesema.com', 'menu.cloudHintActive': 'workspace connecté', @@ -691,10 +739,12 @@ export function t(key: MessageKey, params?: Record, count?: num if (msg.includes(' | ')) { const n = count ?? (typeof params?.n === 'number' ? params.n : undefined) const parts = msg.split(' | ') - msg = (n === 1 ? parts[0] : parts[1] ?? parts[0]) ?? msg + msg = (n === 1 ? parts[0] : (parts[1] ?? parts[0])) ?? msg } if (params) { - for (const [k, v] of Object.entries(params)) msg = msg.replaceAll(`{${k}}`, String(v)) + for (const [k, v] of Object.entries(params)) { + msg = msg.replaceAll(`{${k}}`, String(v)) + } } return msg } diff --git a/packages/cli/src/impact.test.ts b/packages/cli/src/impact.test.ts index 5bb0ea2..92caae8 100644 --- a/packages/cli/src/impact.test.ts +++ b/packages/cli/src/impact.test.ts @@ -1,8 +1,9 @@ -import { afterAll, beforeAll, describe, expect, test } from 'bun:test' import { execFileSync } from 'node:child_process' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { subprocessEnv } from './git.js' import { buildImpactCandidates, changedSymbolsFromDiff } from './impact.js' function tsDiff(file: string, minusLines: string[], plusLines: string[]): string { @@ -50,7 +51,12 @@ describe('changedSymbolsFromDiff', () => { const diff = tsDiff( 'app/models.py', [], - ['def compute_score(user):', 'class Invoice:', ' def method_inside(self):', 'def _private_thing():'], + [ + 'def compute_score(user):', + 'class Invoice:', + ' def method_inside(self):', + 'def _private_thing():', + ], ) expect(changedSymbolsFromDiff(diff)).toEqual([ { name: 'compute_score', file: 'app/models.py', change: 'added' }, @@ -65,7 +71,9 @@ describe('changedSymbolsFromDiff', () => { test('names shorter than 3 chars are filtered out', () => { const diff = tsDiff('src/tiny.ts', [], ['export const pi = 3.14', 'export const abc = 1']) - expect(changedSymbolsFromDiff(diff)).toEqual([{ name: 'abc', file: 'src/tiny.ts', change: 'added' }]) + expect(changedSymbolsFromDiff(diff)).toEqual([ + { name: 'abc', file: 'src/tiny.ts', change: 'added' }, + ]) }) }) @@ -73,7 +81,11 @@ describe('buildImpactCandidates', () => { let repo: string function run(args: string[]) { - execFileSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { cwd: repo, stdio: 'ignore' }) + execFileSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { + cwd: repo, + stdio: 'ignore', + env: subprocessEnv(), + }) } beforeAll(() => { @@ -88,12 +100,18 @@ describe('buildImpactCandidates', () => { join(repo, 'src/checkout.ts'), "import { computeTotal } from './price'\n\nconst total = computeTotal(cart.items)\n", ) - writeFileSync(join(repo, 'src/invoice.ts'), "import { computeTotal } from './price'\nexport const x = computeTotal(lines)\n") + writeFileSync( + join(repo, 'src/invoice.ts'), + "import { computeTotal } from './price'\nexport const x = computeTotal(lines)\n", + ) writeFileSync( join(repo, 'src/usages.ts'), `import { formatLabel } from './hot'\n${Array.from({ length: 25 }, (_, i) => `formatLabel(${i})`).join('\n')}\n`, ) - writeFileSync(join(repo, 'src/hot.ts'), 'export function formatLabel(n: number): string {\n return String(n)\n}\n') + writeFileSync( + join(repo, 'src/hot.ts'), + 'export function formatLabel(n: number): string {\n return String(n)\n}\n', + ) writeFileSync(join(repo, 'notes.txt'), 'computeTotal is documented here\n') run(['add', '-A']) run(['commit', '-m', 'init']) diff --git a/packages/cli/src/impact.ts b/packages/cli/src/impact.ts index 56fcf55..5fb2af6 100644 --- a/packages/cli/src/impact.ts +++ b/packages/cli/src/impact.ts @@ -26,7 +26,18 @@ const MIN_NAME_LENGTH = 3 const MAX_SYMBOLS = 20 const MAX_USED_AT_PER_SYMBOL = 20 const MAX_IMPORTERS_PER_FILE = 15 -const GENERIC_BASENAMES = new Set(['index', 'main', 'mod', 'lib', 'app', 'utils', 'types', 'setup', 'config', '__init__']) +const GENERIC_BASENAMES = new Set([ + 'index', + 'main', + 'mod', + 'lib', + 'app', + 'utils', + 'types', + 'setup', + 'config', + '__init__', +]) const TS_JS_EXTENSIONS = new Set(['ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs']) @@ -41,10 +52,16 @@ const PYTHON_DECLARATIONS = [/^(?:async\s+)?def\s+([A-Za-z_]\w*)/, /^class\s+([A function declarationPatterns(file: string): RegExp[] { const dot = file.lastIndexOf('.') - if (dot < 0) return [] + if (dot < 0) { + return [] + } const ext = file.slice(dot + 1).toLowerCase() - if (TS_JS_EXTENSIONS.has(ext)) return TS_JS_DECLARATIONS - if (ext === 'py') return PYTHON_DECLARATIONS + if (TS_JS_EXTENSIONS.has(ext)) { + return TS_JS_DECLARATIONS + } + if (ext === 'py') { + return PYTHON_DECLARATIONS + } return [] } @@ -54,8 +71,12 @@ function isUsableName(name: string): boolean { function parseDiffPath(raw: string): string | null { const path = raw.trim() - if (path === '/dev/null') return null - if (path.startsWith('a/') || path.startsWith('b/')) return path.slice(2) + if (path === '/dev/null') { + return null + } + if (path.startsWith('a/') || path.startsWith('b/')) { + return path.slice(2) + } return path } @@ -67,7 +88,9 @@ export function diffFilePaths(diff: string): string[] { minusPath = parseDiffPath(line.slice(4)) } else if (line.startsWith('+++ ')) { const file = parseDiffPath(line.slice(4)) ?? minusPath - if (file && !files.includes(file)) files.push(file) + if (file && !files.includes(file)) { + files.push(file) + } } } return files @@ -83,7 +106,8 @@ export function changedSymbolsFromDiff(diff: string): ChangedSymbol[] { const flush = () => { if (file) { for (const [name, side] of sides) { - const change: SymbolChangeKind = side.minus && side.plus ? 'modified' : side.plus ? 'added' : 'removed' + const change: SymbolChangeKind = + side.minus && side.plus ? 'modified' : side.plus ? 'added' : 'removed' symbols.push({ name, file, change }) } } @@ -93,7 +117,9 @@ export function changedSymbolsFromDiff(diff: string): ChangedSymbol[] { const record = (content: string, side: 'minus' | 'plus') => { for (const pattern of patterns) { const name = pattern.exec(content)?.[1] - if (!name || !isUsableName(name)) continue + if (!name || !isUsableName(name)) { + continue + } const entry = sides.get(name) ?? { minus: false, plus: false } entry[side] = true sides.set(name, entry) @@ -112,8 +138,11 @@ export function changedSymbolsFromDiff(diff: string): ChangedSymbol[] { file = parseDiffPath(line.slice(4)) ?? minusPath patterns = file ? declarationPatterns(file) : [] } else if (file && patterns.length > 0) { - if (line.startsWith('+')) record(line.slice(1), 'plus') - else if (line.startsWith('-')) record(line.slice(1), 'minus') + if (line.startsWith('+')) { + record(line.slice(1), 'plus') + } else if (line.startsWith('-')) { + record(line.slice(1), 'minus') + } } } flush() @@ -121,14 +150,23 @@ export function changedSymbolsFromDiff(diff: string): ChangedSymbol[] { } function grepUsages(name: string, excludes: string[], cwd: string): string[] { - const out = tryGit(['grep', '-n', '--word-regexp', '--fixed-strings', '-e', name, '--', '.', ...excludes], cwd) - if (!out) return [] + const out = tryGit( + ['grep', '-n', '--word-regexp', '--fixed-strings', '-e', name, '--', '.', ...excludes], + cwd, + ) + if (!out) { + return [] + } const usages: string[] = [] for (const line of out.split('\n')) { const match = /^(.+?):(\d+):/.exec(line) - if (!match) continue + if (!match) { + continue + } usages.push(`${match[1]}:${match[2]}`) - if (usages.length >= MAX_USED_AT_PER_SYMBOL) break + if (usages.length >= MAX_USED_AT_PER_SYMBOL) { + break + } } return usages } @@ -136,16 +174,31 @@ function grepUsages(name: string, excludes: string[], cwd: string): string[] { function grepImporters(file: string, excludes: string[], cwd: string): string[] { const basename = file.split('/').pop() ?? file const stem = basename.replace(/\.[^.]+$/, '') - if (stem.length < MIN_NAME_LENGTH || GENERIC_BASENAMES.has(stem.toLowerCase())) return [] - const out = tryGit(['grep', '-n', '--word-regexp', '--fixed-strings', '-e', stem, '--', '.', ...excludes], cwd) - if (!out) return [] + if (stem.length < MIN_NAME_LENGTH || GENERIC_BASENAMES.has(stem.toLowerCase())) { + return [] + } + const out = tryGit( + ['grep', '-n', '--word-regexp', '--fixed-strings', '-e', stem, '--', '.', ...excludes], + cwd, + ) + if (!out) { + return [] + } const importers: string[] = [] for (const line of out.split('\n')) { const match = /^(.+?):\d+:(.*)$/.exec(line) - if (!match || match[1] === undefined || match[2] === undefined) continue - if (!/\b(import|require|from|include|use)\b/.test(match[2])) continue - if (!importers.includes(match[1])) importers.push(match[1]) - if (importers.length >= MAX_IMPORTERS_PER_FILE) break + if (!match || match[1] === undefined || match[2] === undefined) { + continue + } + if (!/\b(import|require|from|include|use)\b/.test(match[2])) { + continue + } + if (!importers.includes(match[1])) { + importers.push(match[1]) + } + if (importers.length >= MAX_IMPORTERS_PER_FILE) { + break + } } return importers } @@ -161,17 +214,28 @@ export function buildImpactCandidates(diff: string, cwd: string): ImpactCandidat for (const symbol of candidates) { const usedAt = grepUsages(symbol.name, excludes, cwd) if (usedAt.length > 0) { - symbols.push({ name: symbol.name, changed_in: symbol.file, change: symbol.change, used_at: usedAt }) + symbols.push({ + name: symbol.name, + changed_in: symbol.file, + change: symbol.change, + used_at: usedAt, + }) } } const importedBy: Record = {} for (const file of diffFiles) { - if (declarationPatterns(file).length === 0) continue + if (declarationPatterns(file).length === 0) { + continue + } const importers = grepImporters(file, excludes, cwd) - if (importers.length > 0) importedBy[file] = importers + if (importers.length > 0) { + importedBy[file] = importers + } } - if (symbols.length === 0 && Object.keys(importedBy).length === 0) return null + if (symbols.length === 0 && Object.keys(importedBy).length === 0) { + return null + } return { note: IMPACT_NOTE, symbols, imported_by: importedBy } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3d2a6bf..4dab8d1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,9 +1,9 @@ #!/usr/bin/env node import { parseArgs } from 'node:util' import { loadConfig } from './config.js' -import { setLanguage, t } from './i18n.js' import { exportCommand } from './export.js' import { tryGit } from './git.js' +import { setLanguage, t } from './i18n.js' import { reviewFlagsPassed, runMenu } from './menu.js' import { prep } from './prep.js' import { review, REVIEW_GATE_VALUES, type ReviewGate } from './review.js' @@ -14,8 +14,15 @@ import { maybeOfferUpgrade } from './upgrade.js' import { VERSION } from './version.js' import { configCommand } from './wizard.js' -function parseIntFlag(name: string, raw: string | undefined, min: number, max: number): number | undefined { - if (raw === undefined) return undefined +function parseIntFlag( + name: string, + raw: string | undefined, + min: number, + max: number, +): number | undefined { + if (raw === undefined) { + return undefined + } const n = Number(raw) if (!Number.isInteger(n) || n < min || n > max) { throw new Error(t('cli.intFlagError', { name, raw, min, max })) @@ -24,8 +31,12 @@ function parseIntFlag(name: string, raw: string | undefined, min: number, max: n } function parseFailOn(raw: string | undefined): ReviewGate | undefined { - if (raw === undefined) return undefined - if ((REVIEW_GATE_VALUES as readonly string[]).includes(raw)) return raw as ReviewGate + if (raw === undefined) { + return undefined + } + if ((REVIEW_GATE_VALUES as readonly string[]).includes(raw)) { + return raw as ReviewGate + } throw new Error(t('cli.failOnError', { raw, values: REVIEW_GATE_VALUES.join(', ') })) } @@ -83,7 +94,11 @@ async function main(): Promise { }) break case 'prep': - prep({ branch: values.branch, target: values.target ?? loadConfig(repoRoot).target, cwd: process.cwd() }) + prep({ + branch: values.branch, + target: values.target ?? loadConfig(repoRoot).target, + cwd: process.cwd(), + }) break case 'show': await show({ diff --git a/packages/cli/src/menu.test.ts b/packages/cli/src/menu.test.ts index e0b44fe..8eb59fd 100644 --- a/packages/cli/src/menu.test.ts +++ b/packages/cli/src/menu.test.ts @@ -4,9 +4,9 @@ import { buildCloudMenuItems, buildMenuItems, dispatchMenuAction, + reviewFlagsPassed, type MenuActionId, type MenuActions, - reviewFlagsPassed, } from './menu.js' afterEach(() => setLanguage(null)) @@ -16,7 +16,14 @@ describe('buildMenuItems', () => { for (const hasSyncCredentials of [true, false]) { for (const inRepo of [true, false]) { const items = buildMenuItems({ hasSyncCredentials, inRepo }) - expect(items.map((item) => item.id)).toEqual(['review', 'dualReview', 'show', 'cloud', 'config', 'quit']) + expect(items.map((item) => item.id)).toEqual([ + 'review', + 'dualReview', + 'show', + 'cloud', + 'config', + 'quit', + ]) } } }) @@ -29,8 +36,12 @@ describe('buildMenuItems', () => { }) test('the cloud hint reflects whether a workspace exists', () => { - const withCredentials = buildMenuItems({ hasSyncCredentials: true, inRepo: true }).find((i) => i.id === 'cloud') - const withoutCredentials = buildMenuItems({ hasSyncCredentials: false, inRepo: true }).find((i) => i.id === 'cloud') + const withCredentials = buildMenuItems({ hasSyncCredentials: true, inRepo: true }).find( + (i) => i.id === 'cloud', + ) + const withoutCredentials = buildMenuItems({ hasSyncCredentials: false, inRepo: true }).find( + (i) => i.id === 'cloud', + ) expect(withCredentials?.hint).toBe(t('menu.cloudHintActive')) expect(withoutCredentials?.hint).toBe(t('menu.cloudHintSetup')) }) @@ -67,10 +78,13 @@ describe('buildCloudMenuItems', () => { }) test('the sync hint depends on whether credentials exist', () => { - const withCredentials = buildCloudMenuItems({ hasSyncCredentials: true, inRepo: true }).find((i) => i.id === 'sync') - const withoutCredentials = buildCloudMenuItems({ hasSyncCredentials: false, inRepo: true }).find( + const withCredentials = buildCloudMenuItems({ hasSyncCredentials: true, inRepo: true }).find( (i) => i.id === 'sync', ) + const withoutCredentials = buildCloudMenuItems({ + hasSyncCredentials: false, + inRepo: true, + }).find((i) => i.id === 'sync') expect(withCredentials?.hint).toBe(t('menu.syncHintPush')) expect(withoutCredentials?.hint).toBe(t('menu.syncHintSetup')) }) @@ -129,7 +143,15 @@ describe('dispatchMenuAction', () => { } test('routes each id to its matching action and none other', async () => { - const ids: MenuActionId[] = ['review', 'dualReview', 'show', 'sync', 'link', 'syncDelete', 'config'] + const ids: MenuActionId[] = [ + 'review', + 'dualReview', + 'show', + 'sync', + 'link', + 'syncDelete', + 'config', + ] for (const id of ids) { const { actions, calls } = spyActions() await dispatchMenuAction(id, actions) @@ -139,7 +161,10 @@ describe('dispatchMenuAction', () => { test('propagates a rejection from the underlying action', async () => { const { actions } = spyActions() - const failing: MenuActions = { ...actions, link: async () => Promise.reject(new Error('bad pairing code')) } + const failing: MenuActions = { + ...actions, + link: async () => Promise.reject(new Error('bad pairing code')), + } await expect(dispatchMenuAction('link', failing)).rejects.toThrow('bad pairing code') }) }) diff --git a/packages/cli/src/menu.ts b/packages/cli/src/menu.ts index dbe1f8e..1ead090 100644 --- a/packages/cli/src/menu.ts +++ b/packages/cli/src/menu.ts @@ -9,7 +9,8 @@ import { configCommand } from './wizard.js' export type MenuItemId = 'review' | 'dualReview' | 'show' | 'cloud' | 'config' | 'quit' export type CloudItemId = 'sync' | 'link' | 'syncDelete' | 'back' -export type MenuActionId = 'review' | 'dualReview' | 'show' | 'sync' | 'link' | 'syncDelete' | 'config' +export type MenuActionId = + 'review' | 'dualReview' | 'show' | 'sync' | 'link' | 'syncDelete' | 'config' export type MenuItem = { id: Id @@ -26,13 +27,21 @@ export function buildMenuItems(context: MenuContext): MenuItem[] { // Repo-scoped actions stay visible outside a repo (hiding the product's main // action reads as a regression); the hint says where to run them instead. return [ - { id: 'review', label: t('menu.review'), hint: context.inRepo ? t('menu.reviewHint') : t('menu.needRepo') }, + { + id: 'review', + label: t('menu.review'), + hint: context.inRepo ? t('menu.reviewHint') : t('menu.needRepo'), + }, { id: 'dualReview', label: t('menu.dualReview'), hint: context.inRepo ? t('menu.dualReviewHint') : t('menu.needRepo'), }, - { id: 'show', label: t('menu.show'), hint: context.inRepo ? t('menu.showHint') : t('menu.needRepo') }, + { + id: 'show', + label: t('menu.show'), + hint: context.inRepo ? t('menu.showHint') : t('menu.needRepo'), + }, { id: 'cloud', label: t('menu.cloud'), @@ -63,7 +72,16 @@ export function buildCloudMenuItems(context: MenuContext): MenuItem return items } -const REVIEW_FLAGS = ['branch', 'target', 'agent', 'full', 'dual', 'no-open', 'port', 'timeout'] as const +const REVIEW_FLAGS = [ + 'branch', + 'target', + 'agent', + 'full', + 'dual', + 'no-open', + 'port', + 'timeout', +] as const // Bare `codesema` opens the menu, but `codesema --branch x` has always meant // "review that branch": any review flag falls through to the review command @@ -89,7 +107,12 @@ function buildActions(cwd: string): MenuActions { return { review: () => review({ open: true, cwd }), dualReview: () => review({ open: true, cwd, dual: true }), - show: () => show({ open: true, cwd, port: loadConfig(tryGit(['rev-parse', '--show-toplevel'], cwd)).port }), + show: () => + show({ + open: true, + cwd, + port: loadConfig(tryGit(['rev-parse', '--show-toplevel'], cwd)).port, + }), sync: () => syncCommand({ cwd }), link: () => linkCommand({}), syncDelete: () => syncCommand({ action: 'delete', cwd }), @@ -122,7 +145,9 @@ async function runCloudMenu(cwd: string, actions: MenuActions): Promise { })), summary: false, }) - if (picked === null || picked === 'back') return + if (picked === null || picked === 'back') { + return + } if (picked === 'sync' && !context.inRepo) { printNotInRepo() @@ -157,7 +182,9 @@ export async function runMenu(opts: { cwd: string }): Promise { })), summary: false, }) - if (picked === null || picked === 'quit') return + if (picked === null || picked === 'quit') { + return + } if ((picked === 'review' || picked === 'dualReview' || picked === 'show') && !context.inRepo) { printNotInRepo() diff --git a/packages/cli/src/notify.ts b/packages/cli/src/notify.ts index 624a391..5ff1aa0 100644 --- a/packages/cli/src/notify.ts +++ b/packages/cli/src/notify.ts @@ -7,11 +7,19 @@ function appleScriptString(text: string): string { export function notifyDesktop(title: string, body: string): void { const command = process.platform === 'darwin' - ? { cmd: 'osascript', args: ['-e', `display notification ${appleScriptString(body)} with title ${appleScriptString(title)}`] } + ? { + cmd: 'osascript', + args: [ + '-e', + `display notification ${appleScriptString(body)} with title ${appleScriptString(title)}`, + ], + } : process.platform === 'linux' ? { cmd: 'notify-send', args: ['--app-name=codesema', title, body] } : null - if (!command) return + if (!command) { + return + } try { spawn(command.cmd, command.args, { stdio: 'ignore', detached: true }).unref() } catch { diff --git a/packages/cli/src/partial.test.ts b/packages/cli/src/partial.test.ts index ae626cd..5c638a0 100644 --- a/packages/cli/src/partial.test.ts +++ b/packages/cli/src/partial.test.ts @@ -5,10 +5,27 @@ const FULL = JSON.stringify({ verdict: 'request_changes', summary: 'Deux problèmes de gestion d’erreur.', findings: [ - { file: 'src/a.ts', line: 12, severity: 'major', kind: 'design', title: 'Erreur avalée', message: 'Le catch vide masque la panne.' }, - { file: 'src/b.ts', severity: 'minor', kind: 'convention', title: 'Nommage', message: 'Renommer x en userCount.' }, + { + file: 'src/a.ts', + line: 12, + severity: 'major', + kind: 'design', + title: 'Erreur avalée', + message: 'Le catch vide masque la panne.', + }, + { + file: 'src/b.ts', + severity: 'minor', + kind: 'convention', + title: 'Nommage', + message: 'Renommer x en userCount.', + }, ], - narrative: { intent: 'Fiabiliser les erreurs', confidence: 'high', steps: [{ title: 'Fondations' }] }, + narrative: { + intent: 'Fiabiliser les erreurs', + confidence: 'high', + steps: [{ title: 'Fondations' }], + }, }) describe('repairTruncatedJson', () => { @@ -37,8 +54,12 @@ describe('repairTruncatedJson', () => { }) test('array truncated in the middle of an object', () => { - const repaired = repairTruncatedJson('{"findings":[{"file":"a.ts","message":"ok"},{"file":"b.ts","mess') - expect(JSON.parse(repaired!)).toEqual({ findings: [{ file: 'a.ts', message: 'ok' }, { file: 'b.ts' }] }) + const repaired = repairTruncatedJson( + '{"findings":[{"file":"a.ts","message":"ok"},{"file":"b.ts","mess', + ) + expect(JSON.parse(repaired!)).toEqual({ + findings: [{ file: 'a.ts', message: 'ok' }, { file: 'b.ts' }], + }) }) test('escape sequence cut at end of string', () => { @@ -75,12 +96,16 @@ describe('parsePartialReview', () => { test('progressive prefix: each slice parses or returns null, without throwing', () => { for (let cut = 1; cut <= FULL.length; cut++) { const partial = parsePartialReview(FULL.slice(0, cut)) - if (cut === FULL.length) expect(partial?.findings).toHaveLength(2) + if (cut === FULL.length) { + expect(partial?.findings).toHaveLength(2) + } } }) test('finding without file/message ignored', () => { - const partial = parsePartialReview('{"verdict":"comment","findings":[{"file":"a.ts"},{"file":"b.ts","message":"ok"}]}')! + const partial = parsePartialReview( + '{"verdict":"comment","findings":[{"file":"a.ts"},{"file":"b.ts","message":"ok"}]}', + )! expect(partial.findings).toEqual([{ file: 'b.ts', message: 'ok' }]) }) diff --git a/packages/cli/src/partial.ts b/packages/cli/src/partial.ts index 00fdc20..e13f27b 100644 --- a/packages/cli/src/partial.ts +++ b/packages/cli/src/partial.ts @@ -8,9 +8,9 @@ export type PartialFinding = { } export type PartialReview = { - verdict?: 'approve' | 'request_changes' | 'comment' - summary?: string - intent?: string + verdict?: 'approve' | 'request_changes' | 'comment' | undefined + summary?: string | undefined + intent?: string | undefined findings: PartialFinding[] stepTitles: string[] } @@ -20,18 +20,26 @@ const CLOSED_STRING_TAIL = /^"(?:[^"\\]|\\.)*"$/ function lastNonWhitespace(s: string, before: number): string { for (let i = before - 1; i >= 0; i--) { - const ch = s[i]! - if (ch !== ' ' && ch !== '\n' && ch !== '\r' && ch !== '\t') return ch + const ch = s.charAt(i) + if (ch !== ' ' && ch !== '\n' && ch !== '\r' && ch !== '\t') { + return ch + } } return '' } function stringStartBackwards(s: string): number { for (let i = s.length - 2; i >= 0; i--) { - if (s[i] !== '"') continue + if (s[i] !== '"') { + continue + } let backslashes = 0 - for (let j = i - 1; j >= 0 && s[j] === '\\'; j--) backslashes++ - if (backslashes % 2 === 0) return i + for (let j = i - 1; j >= 0 && s[j] === '\\'; j--) { + backslashes++ + } + if (backslashes % 2 === 0) { + return i + } } return -1 } @@ -43,7 +51,9 @@ function stringStartBackwards(s: string): number { */ export function repairTruncatedJson(raw: string): string | null { const start = raw.indexOf('{') - if (start < 0) return null + if (start < 0) { + return null + } let s = raw.slice(start) const stack: string[] = [] @@ -53,11 +63,15 @@ export function repairTruncatedJson(raw: string): string | null { let lastStructural = -1 for (let i = 0; i < s.length; i++) { - const ch = s[i]! + const ch = s.charAt(i) if (inString) { - if (escaped) escaped = false - else if (ch === '\\') escaped = true - else if (ch === '"') inString = false + if (escaped) { + escaped = false + } else if (ch === '\\') { + escaped = true + } else if (ch === '"') { + inString = false + } continue } if (ch === '"') { @@ -76,24 +90,32 @@ export function repairTruncatedJson(raw: string): string | null { } if (ch === '}' || ch === ']') { stack.pop() - if (stack.length === 0) return s.slice(0, i + 1) + if (stack.length === 0) { + return s.slice(0, i + 1) + } } } if (inString) { - if (escaped) s = s.slice(0, -1) + if (escaped) { + s = s.slice(0, -1) + } s = s.replace(/\\u[0-9a-fA-F]{0,3}$/, '') s += '"' const prev = lastNonWhitespace(s, stringStart) const isKey = stack[stack.length - 1] === '{' && (prev === '{' || prev === ',') - if (isKey) s = s.slice(0, stringStart) + if (isKey) { + s = s.slice(0, stringStart) + } } else { const tail = s.slice(lastStructural + 1).trim() if (tail && !LITERAL_TAIL.test(tail)) { if (CLOSED_STRING_TAIL.test(tail)) { const before = s[lastStructural] ?? '' const isKey = stack[stack.length - 1] === '{' && (before === '{' || before === ',') - if (isKey) s = s.slice(0, lastStructural + 1) + if (isKey) { + s = s.slice(0, lastStructural + 1) + } } else { s = s.slice(0, lastStructural + 1) } @@ -117,14 +139,18 @@ export function repairTruncatedJson(raw: string): string | null { break } - for (let i = stack.length - 1; i >= 0; i--) s += stack[i] === '{' ? '}' : ']' + for (let i = stack.length - 1; i >= 0; i--) { + s += stack[i] === '{' ? '}' : ']' + } return s } /** Tolerant extraction of the fields already readable from the review in progress. */ export function parsePartialReview(raw: string): PartialReview | null { const repaired = repairTruncatedJson(raw) - if (!repaired) return null + if (!repaired) { + return null + } let parsed: unknown try { @@ -132,19 +158,27 @@ export function parsePartialReview(raw: string): PartialReview | null { } catch { return null } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } const r = parsed as Record const verdict = - r.verdict === 'approve' || r.verdict === 'request_changes' || r.verdict === 'comment' ? r.verdict : undefined + r.verdict === 'approve' || r.verdict === 'request_changes' || r.verdict === 'comment' + ? r.verdict + : undefined const summary = typeof r.summary === 'string' && r.summary.trim() ? r.summary.trim() : undefined const findings: PartialFinding[] = [] if (Array.isArray(r.findings)) { for (const item of r.findings.slice(0, 200)) { - if (!item || typeof item !== 'object') continue + if (!item || typeof item !== 'object') { + continue + } const f = item as Record - if (typeof f.file !== 'string' || !f.file || typeof f.message !== 'string' || !f.message) continue + if (typeof f.file !== 'string' || !f.file || typeof f.message !== 'string' || !f.message) { + continue + } findings.push({ file: f.file, message: f.message, @@ -156,16 +190,28 @@ export function parsePartialReview(raw: string): PartialReview | null { } } - const narrative = r.narrative && typeof r.narrative === 'object' ? (r.narrative as Record) : undefined + const narrative = + r.narrative && typeof r.narrative === 'object' + ? (r.narrative as Record) + : undefined // Streams from pre-rename agent prompts used "chapters". const rawSteps = narrative?.steps ?? narrative?.chapters const stepTitles = Array.isArray(rawSteps) ? rawSteps - .map((c) => (c && typeof c === 'object' && typeof (c as { title?: unknown }).title === 'string' ? (c as { title: string }).title : null)) + .map((c) => + c && typeof c === 'object' && typeof (c as { title?: unknown }).title === 'string' + ? (c as { title: string }).title + : null, + ) .filter((t): t is string => Boolean(t)) : [] - const intent = typeof narrative?.intent === 'string' && narrative.intent.trim() ? narrative.intent.trim() : undefined + const intent = + typeof narrative?.intent === 'string' && narrative.intent.trim() + ? narrative.intent.trim() + : undefined - if (!verdict && !summary && !intent && findings.length === 0 && stepTitles.length === 0) return null + if (!verdict && !summary && !intent && findings.length === 0 && stepTitles.length === 0) { + return null + } return { verdict, summary, intent, findings, stepTitles } } diff --git a/packages/cli/src/prep.test.ts b/packages/cli/src/prep.test.ts index c2205ed..5355505 100644 --- a/packages/cli/src/prep.test.ts +++ b/packages/cli/src/prep.test.ts @@ -1,14 +1,19 @@ -import { afterAll, beforeAll, describe, expect, test } from 'bun:test' import { execFileSync } from 'node:child_process' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { subprocessEnv } from './git.js' import { detectTarget, prep } from './prep.js' let repo: string function run(args: string[]) { - execFileSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { cwd: repo, stdio: 'ignore' }) + execFileSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { + cwd: repo, + stdio: 'ignore', + env: subprocessEnv(), + }) } function commitFile(name: string, content: string, msg: string) { @@ -36,7 +41,10 @@ afterAll(() => { describe('detectTarget', () => { test('valid --target resolved, source = flag', () => { - expect(detectTarget('feature/x', 'develop', repo)).toEqual({ target: 'develop', source: '--target flag' }) + expect(detectTarget('feature/x', 'develop', repo)).toEqual({ + target: 'develop', + source: '--target flag', + }) }) test('--target not found: explicit error', () => { @@ -147,13 +155,23 @@ describe('prep', () => { test('impact_candidates: filled when a changed export has callers outside the diff', () => { run(['checkout', 'develop']) - writeFileSync(join(repo, 'greeting.ts'), 'export function greetUser(name: string): string {\n return name\n}\n') - writeFileSync(join(repo, 'consumer.ts'), "import { greetUser } from './greeting'\nconsole.log(greetUser('a'))\n") + writeFileSync( + join(repo, 'greeting.ts'), + 'export function greetUser(name: string): string {\n return name\n}\n', + ) + writeFileSync( + join(repo, 'consumer.ts'), + "import { greetUser } from './greeting'\nconsole.log(greetUser('a'))\n", + ) run(['add', '-A']) run(['commit', '-m', 'chore: add greeting and consumer']) run(['checkout', '-b', 'feature/impact']) try { - commitFile('greeting.ts', 'export function greetUser(name: string, loud: boolean): string {\n return name\n}\n', 'feat: loud greeting') + commitFile( + 'greeting.ts', + 'export function greetUser(name: string, loud: boolean): string {\n return name\n}\n', + 'feat: loud greeting', + ) const input = prep({ target: 'develop', cwd: repo, quiet: true }) const symbol = input.impact_candidates?.symbols.find((s) => s.name === 'greetUser') expect(symbol?.change).toBe('modified') diff --git a/packages/cli/src/prep.ts b/packages/cli/src/prep.ts index 0aa3df0..bd4e1b2 100644 --- a/packages/cli/src/prep.ts +++ b/packages/cli/src/prep.ts @@ -1,10 +1,21 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { ensureWorkDir } from './config.js' -import { currentBranch, git, headSha, mergeBase, refExists, repoRoot, revListCount, tryExec, tryGit } from './git.js' -import { buildImpactCandidates, type ImpactCandidates } from './impact.js' +import { + currentBranch, + git, + headSha, + mergeBase, + refExists, + repoRoot, + revListCount, + tryExec, + tryGit, +} from './git.js' import { t } from './i18n.js' +import { buildImpactCandidates, type ImpactCandidates } from './impact.js' import { loadRules } from './rules.js' +import type { ServerContext } from './server-context.js' import { renderFieldRows, type FieldRow } from './ui.js' const TARGET_CANDIDATES = ['develop', 'main', 'master'] as const @@ -44,11 +55,23 @@ export type PrepInput = { rules: string[] | null impact_candidates: ImpactCandidates | null diff: string + /** + * Best-effort context fetched from the codesema server (conventions, learned + * rules, facts, freshness); null when offline, unlinked or on any failure. + * Always null right out of prep() (pure git computation): populated + * separately by review() via buildServerContext(). Never a substitute for + * `rules`, which stays local and takes precedence. + */ + server_context: ServerContext | null } function resolveRef(name: string, cwd: string): string | null { - if (refExists(name, cwd)) return name - if (refExists(`origin/${name}`, cwd)) return `origin/${name}` + if (refExists(name, cwd)) { + return name + } + if (refExists(`origin/${name}`, cwd)) { + return `origin/${name}` + } return null } @@ -70,38 +93,60 @@ function targetFromForge(cwd: string): { target: string; source: string } | null const name = (JSON.parse(glabOut) as { target_branch?: string }).target_branch if (name) { const ref = resolveRef(name, cwd) - if (ref) return { target: ref, source: 'gitlab (glab mr view)' } + if (ref) { + return { target: ref, source: 'gitlab (glab mr view)' } + } } } catch { // unexpected glab output: fall through to the next fallback } } - const ghOut = skipGithub ? null : tryExec('gh', ['pr', 'view', '--json', 'baseRefName', '--jq', '.baseRefName'], cwd) + const ghOut = skipGithub + ? null + : tryExec('gh', ['pr', 'view', '--json', 'baseRefName', '--jq', '.baseRefName'], cwd) if (ghOut) { const ref = resolveRef(ghOut, cwd) - if (ref) return { target: ref, source: 'github (gh pr view)' } + if (ref) { + return { target: ref, source: 'github (gh pr view)' } + } } return null } function targetFromOriginHead(cwd: string): { target: string; source: string } | null { const sym = tryGit(['symbolic-ref', 'refs/remotes/origin/HEAD'], cwd) - if (!sym) return null + if (!sym) { + return null + } const ref = sym.replace('refs/remotes/', '') - if (!refExists(ref, cwd)) return null + if (!refExists(ref, cwd)) { + return null + } return { target: ref, source: 'origin/HEAD' } } -function targetFromHeuristic(current: string, headRef: string, cwd: string): { target: string; source: string } | null { +function targetFromHeuristic( + current: string, + headRef: string, + cwd: string, +): { target: string; source: string } | null { let best: { target: string; distance: number } | null = null for (const name of TARGET_CANDIDATES) { const ref = resolveRef(name, cwd) - if (!ref || sameBranch(ref, current)) continue + if (!ref || sameBranch(ref, current)) { + continue + } const mb = mergeBase(ref, headRef, cwd) - if (!mb) continue + if (!mb) { + continue + } const distance = revListCount(`${mb}..${headRef}`, cwd) - if (distance === null) continue - if (!best || distance < best.distance) best = { target: ref, distance } + if (distance === null) { + continue + } + if (!best || distance < best.distance) { + best = { target: ref, distance } + } } return best ? { target: best.target, source: 'heuristic (nearest merge-base)' } : null } @@ -114,7 +159,9 @@ export function detectTarget( ): { target: string; source: string } { if (flag) { const ref = resolveRef(flag, cwd) - if (!ref) throw new Error(t('prep.targetFlagNotFound', { flag })) + if (!ref) { + throw new Error(t('prep.targetFlagNotFound', { flag })) + } return { target: ref, source: '--target flag' } } const forge = headRef === 'HEAD' ? targetFromForge(cwd) : null @@ -131,7 +178,9 @@ function excludePathspecs(cwd: string): string[] { if (existsSync(ignoreFile)) { for (const raw of readFileSync(ignoreFile, 'utf8').split('\n')) { const line = raw.trim() - if (!line || line.startsWith('#')) continue + if (!line || line.startsWith('#')) { + continue + } patterns.push(line) } } @@ -145,10 +194,18 @@ function excludePathspecs(cwd: string): string[] { * -U10: reviewers judge changes against the enclosing code, not three bare lines. */ export function mrDiff(range: string, cwd: string, excludes = excludePathspecs(cwd)): string { - return git(['-c', 'core.quotePath=false', 'diff', '--no-color', '-U10', range, '--', '.', ...excludes], cwd) + return git( + ['-c', 'core.quotePath=false', 'diff', '--no-color', '-U10', range, '--', '.', ...excludes], + cwd, + ) } -export function prep(opts: { branch?: string; target?: string; cwd: string; quiet?: boolean }): PrepInput { +export function prep(opts: { + branch?: string | undefined + target?: string | undefined + cwd: string + quiet?: boolean | undefined +}): PrepInput { const cwd = repoRoot(opts.cwd) const checkedOut = currentBranch(cwd) const branch = opts.branch ?? checkedOut @@ -179,7 +236,9 @@ export function prep(opts: { branch?: string; target?: string; cwd: string; quie throw new Error(t('prep.emptyDiff', { target, branch, hint })) } - const commits = (tryGit(['log', '--pretty=%s', `${target}..${headRef}`, '--max-count=30'], cwd) ?? '') + const commits = ( + tryGit(['log', '--pretty=%s', `${target}..${headRef}`, '--max-count=30'], cwd) ?? '' + ) .split('\n') .filter(Boolean) .map((subject) => { @@ -190,7 +249,12 @@ export function prep(opts: { branch?: string; target?: string; cwd: string; quie : subject }) - const files = (tryGit(['-c', 'core.quotePath=false', 'diff', '--numstat', range, '--', '.', ...excludes], cwd) ?? '') + const files = ( + tryGit( + ['-c', 'core.quotePath=false', 'diff', '--numstat', range, '--', '.', ...excludes], + cwd, + ) ?? '' + ) .split('\n') .filter(Boolean) .map((line) => { @@ -222,6 +286,7 @@ export function prep(opts: { branch?: string; target?: string; cwd: string; quie rules, impact_candidates: buildImpactCandidates(diff, cwd), diff, + server_context: null, } const dir = ensureWorkDir(cwd) @@ -239,10 +304,14 @@ export function prep(opts: { branch?: string; target?: string; cwd: string; quie { label: t('prep.label.files'), value: `${files.length} (+${additions} −${deletions})` }, { label: t('prep.label.commits'), value: String(commits.length) }, ...(custom ? [{ label: t('prep.label.custom'), value: t('prep.customNote') }] : []), - ...(rules ? [{ label: t('prep.label.rules'), value: t('prep.rulesNote', { n: rules.length }) }] : []), + ...(rules + ? [{ label: t('prep.label.rules'), value: t('prep.rulesNote', { n: rules.length }) }] + : []), { label: t('prep.label.input'), value: inputPath }, ] - for (const line of renderFieldRows(rows)) console.log(line) + for (const line of renderFieldRows(rows)) { + console.log(line) + } console.log('') console.log(t('prep.next')) } diff --git a/packages/cli/src/record.test.ts b/packages/cli/src/record.test.ts index 35bcd7b..c801b0b 100644 --- a/packages/cli/src/record.test.ts +++ b/packages/cli/src/record.test.ts @@ -1,7 +1,7 @@ -import { afterAll, beforeAll, describe, expect, test } from 'bun:test' import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' import { sanitizeRecord } from './contract.js' import { archiveRecord, findPreviousReview } from './record.js' @@ -35,7 +35,7 @@ describe('archiveRecord', () => { archiveRecord(record({ branch: 'feat/x', target: 'develop' }), dir) const names = readdirSync(reviewsDir) - const kept = names.filter((n) => /^feat-x-\d{8}-\d{6}\.json$/.test(n)).sort() + const kept = names.filter((n) => /^feat-x-\d{8}-\d{6}\.json$/.test(n)).toSorted() expect(kept.length).toBe(5) expect(kept).not.toContain('feat-x-20260101-000000.json') expect(kept).not.toContain('feat-x-20260102-000000.json') diff --git a/packages/cli/src/record.ts b/packages/cli/src/record.ts index 907e89f..620a517 100644 --- a/packages/cli/src/record.ts +++ b/packages/cli/src/record.ts @@ -1,13 +1,24 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs' import { join } from 'node:path' -import type { ReviewRecord } from './contract.js' -import { sanitizeRecord } from './contract.js' +import { sanitizeRecord, type ReviewRecord } from './contract.js' import { t } from './i18n.js' const ARCHIVES_KEPT_PER_BRANCH = 5 function slug(s: string): string { - return s.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'review' + return ( + s + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') || 'review' + ) } function stamp(d: Date): string { @@ -23,7 +34,7 @@ function archiveNames(reviewsDir: string, slugged: string): string[] { const stampTail = /^\d{8}-\d{6}\.json$/ return readdirSync(reviewsDir) .filter((n) => n.startsWith(`${slugged}-`) && stampTail.test(n.slice(slugged.length + 1))) - .sort() + .toSorted() } export function archiveRecord(record: ReviewRecord, cwd: string): string { @@ -55,7 +66,7 @@ function buildRecord(agentOutputPath: string, dir: string): ReviewRecord { } const raw = readJson(inputPath) const input = (raw && typeof raw === 'object' ? raw : {}) as Record - return sanitizeRecord({ + const record = sanitizeRecord({ meta: { title: input.title, branch: input.branch, @@ -67,17 +78,31 @@ function buildRecord(agentOutputPath: string, dir: string): ReviewRecord { commits: input.commits, diff: input.diff, review: readJson(agentOutputPath), - })! + }) + if (!record) { + throw new Error(t('record.invalidJson', { path: agentOutputPath })) + } + return record } function latestSavedRecord(reviewsDir: string): { record: ReviewRecord; path: string } | null { - if (!existsSync(reviewsDir)) return null - const names = readdirSync(reviewsDir).filter((n) => n.endsWith('.json')).sort() + if (!existsSync(reviewsDir)) { + return null + } + const names = readdirSync(reviewsDir) + .filter((n) => n.endsWith('.json')) + .toSorted() for (let i = names.length - 1; i >= 0; i--) { - const path = join(reviewsDir, names[i]!) + const name = names[i] + if (!name) { + continue + } + const path = join(reviewsDir, name) try { const record = sanitizeRecord(readJson(path)) - if (record) return { record, path } + if (record) { + return { record, path } + } } catch { // unreadable archive: fall back to the previous one } @@ -86,10 +111,16 @@ function latestSavedRecord(reviewsDir: string): { record: ReviewRecord; path: st } /** Last archived review of this branch to this target, with a known head_sha. */ -export function findPreviousReview(cwd: string, branch: string, target: string): ReviewRecord | null { +export function findPreviousReview( + cwd: string, + branch: string, + target: string, +): ReviewRecord | null { const reviewsDir = join(cwd, '.codesema', 'reviews') - if (!existsSync(reviewsDir)) return null - const names = archiveNames(reviewsDir, slug(branch)).reverse() + if (!existsSync(reviewsDir)) { + return null + } + const names = archiveNames(reviewsDir, slug(branch)).toReversed() for (const name of names) { try { const record = sanitizeRecord(readJson(join(reviewsDir, name))) @@ -110,7 +141,7 @@ export type ResolvedRecord = { sourcePath: string } -export function resolveRecord(opts: { review?: string; cwd: string }): ResolvedRecord { +export function resolveRecord(opts: { review?: string | undefined; cwd: string }): ResolvedRecord { const dir = join(opts.cwd, '.codesema') const freshPath = opts.review ?? join(dir, 'review.json') if (existsSync(freshPath)) { diff --git a/packages/cli/src/review.test.ts b/packages/cli/src/review.test.ts index c458cc5..3adeda4 100644 --- a/packages/cli/src/review.test.ts +++ b/packages/cli/src/review.test.ts @@ -1,7 +1,7 @@ -import { afterAll, describe, expect, test } from 'bun:test' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { afterAll, describe, expect, test } from 'bun:test' import type { AgentRunOptions } from './agent.js' import type { Finding, GroundingReport, SanitizedReview, Verdict } from './contract.js' import type { PrepInput } from './prep.js' @@ -80,6 +80,7 @@ describe('agentVisibleInput', () => { rules: ['[C1] no any'], impact_candidates: { note: 'best-effort', symbols: [], imported_by: { 'a.ts': ['b.ts'] } }, diff: 'diff --git a/a.ts b/a.ts', + server_context: null, } expect(agentVisibleInput(input)).toEqual({ branch: 'feature/x', @@ -89,15 +90,51 @@ describe('agentVisibleInput', () => { custom_instructions: null, rules: ['[C1] no any'], impact_candidates: { note: 'best-effort', symbols: [], imported_by: { 'a.ts': ['b.ts'] } }, + server_context: null, }) }) + + test('carries a populated server_context through unchanged', () => { + const input: PrepInput = { + version: 1, + generated_by: 'codesema prep', + title: 'feature/x', + branch: 'feature/x', + target: 'develop', + target_source: 'heuristic', + merge_base: 'abc123', + head_sha: 'def456', + repo_root: '/home/someone/secret-project', + commits: [], + files: [], + custom_instructions: null, + rules: null, + impact_candidates: null, + diff: '', + server_context: { + version: 1, + repo: { remote_url: 'git@github.com:acme/widgets.git' }, + freshness: { scan_sha: 'abc', scanned_at: '2026-08-01T00:00:00.000Z' }, + conventions: [{ id: 'c1', rule: 'no any', category: 'types', scope: null }], + learned_rules: [{ id: 'l1', rule: 'prefer composables' }], + facts: ['uses Elysia'], + stale_warning: null, + }, + } + expect(agentVisibleInput(input).server_context).toEqual(input.server_context) + }) }) describe('groundingReportLines', () => { const finding: Finding = { file: 'a.ts', severity: 'major', message: 'm' } test('untouched review: no lines', () => { - const report: GroundingReport = { dropped: [], deanchored: [], merged: 0, verdict_escalated: false } + const report: GroundingReport = { + dropped: [], + deanchored: [], + merged: 0, + verdict_escalated: false, + } expect(groundingReportLines(report)).toEqual([]) }) @@ -153,6 +190,13 @@ describe('reviewInstructions', () => { expect(p).toContain('[Cn]') }) + test('describes server_context and its precedence under the local rules', () => { + const p = reviewInstructions() + expect(p).toContain('server_context') + expect(p).toContain('rules wins') + expect(p).toContain('stale_warning') + }) + test('scopes the verdict to what the input can prove', () => { const p = reviewInstructions() expect(p).toContain('weighs ONLY what you could verify in the provided input') @@ -191,7 +235,11 @@ describe('runAgentJsonWithRetry', () => { calls.push(o.prompt) return '{"n":1}' } - const value = await runAgentJsonWithRetry(opts, (raw) => JSON.parse(raw) as { n: number }, runner) + const value = await runAgentJsonWithRetry( + opts, + (raw) => JSON.parse(raw) as { n: number }, + runner, + ) expect(value).toEqual({ n: 1 }) expect(calls).toHaveLength(1) }) @@ -202,7 +250,11 @@ describe('runAgentJsonWithRetry', () => { calls.push(o.prompt) return calls.length === 1 ? 'garbage' : '{"n":2}' } - const value = await runAgentJsonWithRetry(opts, (raw) => JSON.parse(raw) as { n: number }, runner) + const value = await runAgentJsonWithRetry( + opts, + (raw) => JSON.parse(raw) as { n: number }, + runner, + ) expect(value).toEqual({ n: 2 }) expect(calls).toHaveLength(2) expect(calls[1]).toContain('P') @@ -231,7 +283,9 @@ describe('runDualFlow', () => { const tempDirs: string[] = [] afterAll(() => { - for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }) + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } }) function setupDualRepo(agentPayload: string) { @@ -261,6 +315,7 @@ describe('runDualFlow', () => { rules: null, impact_candidates: null, diff: 'diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-old\n+new\n', + server_context: null, } writeFileSync(join(workDir, 'input.json'), JSON.stringify(input)) return { repo, workDir, callsPath, agentScript, input } @@ -291,19 +346,24 @@ describe('runDualFlow', () => { const outcome = await runDualFlow(flowOpts(fixture)) expect(outcome.ok).toBe(true) - if (!outcome.ok) return + if (!outcome.ok) { + return + } expect(outcome.reportLines.filter((line) => line.includes('did not examine'))).toHaveLength(2) }, 20000) test('identical lane findings merge deterministically into a consensus finding', async () => { - const finding = '{"file":"a.ts","line":1,"severity":"major","kind":"design","title":"t","message":"broken"}' + const finding = + '{"file":"a.ts","line":1,"severity":"major","kind":"design","title":"t","message":"broken"}' const payload = `{"verdict":"comment","summary":"ok","findings":[${finding}],"decisions":[{"id":"A0","action":"keep"}]}` const fixture = setupDualRepo(payload) const outcome = await runDualFlow(flowOpts(fixture)) expect(outcome.ok).toBe(true) - if (!outcome.ok) return + if (!outcome.ok) { + return + } expect(outcome.record.review.findings).toHaveLength(1) expect(outcome.record.review.findings[0]?.consensus).toBe(true) expect(outcome.record.meta.dual).toEqual({ merged: 1, rejected: 0, added_by_b: 0 }) diff --git a/packages/cli/src/review.ts b/packages/cli/src/review.ts index 41ddb1e..4183c0e 100644 --- a/packages/cli/src/review.ts +++ b/packages/cli/src/review.ts @@ -2,14 +2,22 @@ import { writeFileSync } from 'node:fs' import { join } from 'node:path' import { agentEnv, hardenedReviewCommand, runAgent, type AgentRunOptions } from './agent.js' import { pickBranch } from './branches.js' -import { ensureWorkDir, isRepoAgentTrusted, loadConfig, loadRepoConfig, trustRepoAgent } from './config.js' -import { createFixRunner, DEFAULT_TIMEOUT_S } from './fix.js' -import { isAncestor, repoRoot } from './git.js' -import { reviewLanguage, t, uiLocale } from './i18n.js' -import { notifyDesktop } from './notify.js' -import { openBrowser } from './open.js' -import type { FindingSeverity, GroundingReport, ReviewedFile, ReviewRecord, SanitizedReview } from './contract.js' -import { groundReview, sanitizeReview } from './contract.js' +import { + ensureWorkDir, + isRepoAgentTrusted, + loadConfig, + loadRepoConfig, + trustRepoAgent, +} from './config.js' +import { + groundReview, + sanitizeReview, + type FindingSeverity, + type GroundingReport, + type ReviewedFile, + type ReviewRecord, + type SanitizedReview, +} from './contract.js' import { assembleDualReview, dedupeExactCrossLane, @@ -21,31 +29,52 @@ import { sanitizeJudgeOutput, type JudgeOutput, } from './dual.js' -import type { PartialReview } from './partial.js' -import { parsePartialReview } from './partial.js' -import type { PrepInput } from './prep.js' -import { mrDiff, prep } from './prep.js' +import { createFixRunner, DEFAULT_TIMEOUT_S } from './fix.js' +import { isAncestor, repoRoot } from './git.js' +import { reviewLanguage, t, uiLocale } from './i18n.js' +import { notifyDesktop } from './notify.js' +import { openBrowser } from './open.js' +import { parsePartialReview, type PartialReview } from './partial.js' +import { mrDiff, prep, type PrepInput } from './prep.js' import { archiveRecord, findPreviousReview, resolveRecord } from './record.js' -import type { LiveSession } from './serve.js' -import { createSession, startServer } from './serve.js' +import { createSession, startServer, type LiveSession } from './serve.js' +import { buildServerContext, type ServerContext } from './server-context.js' import { printReviewSummary } from './summary.js' import { autoPushReview } from './sync.js' import { isInteractive, select } from './tui.js' -import { ACCENT, GREEN, RED, bold, dim, paint, printBanner, progressLabel, renderFieldRows, startSpinner, underline } from './ui.js' +import { + ACCENT, + bold, + dim, + GREEN, + paint, + printBanner, + progressLabel, + RED, + renderFieldRows, + startSpinner, + underline, +} from './ui.js' import { AGENT_DEFS, defaultCommand, detectAgents, runOnboarding } from './wizard.js' export const REVIEW_GATE_EXIT_CODE = 2 export type ReviewGate = FindingSeverity | 'request_changes' -export const REVIEW_GATE_VALUES: readonly ReviewGate[] = ['critical', 'major', 'minor', 'info', 'request_changes'] +export const REVIEW_GATE_VALUES: readonly ReviewGate[] = [ + 'critical', + 'major', + 'minor', + 'info', + 'request_changes', +] const SEVERITY_RANK: Record = { info: 0, minor: 1, major: 2, critical: 3 } /** Returns a human reason when the review trips the gate, or null when it passes. */ -export function reviewGateReason(review: SanitizedReview, gate: ReviewGate): string | null { +export function reviewGateReason(sanitized: SanitizedReview, gate: ReviewGate): string | null { if (gate === 'request_changes') { - return review.verdict === 'request_changes' ? t('review.gateReasonVerdict') : null + return sanitized.verdict === 'request_changes' ? t('review.gateReasonVerdict') : null } const threshold = SEVERITY_RANK[gate] - const count = review.findings.filter((f) => SEVERITY_RANK[f.severity] >= threshold).length + const count = sanitized.findings.filter((f) => SEVERITY_RANK[f.severity] >= threshold).length return count > 0 ? t('review.gateReasonSeverity', { n: count, level: gate }) : null } @@ -61,6 +90,7 @@ export function agentVisibleInput(input: PrepInput): { custom_instructions: string | null rules: string[] | null impact_candidates: PrepInput['impact_candidates'] + server_context: ServerContext | null } { return { branch: input.branch, @@ -70,15 +100,24 @@ export function agentVisibleInput(input: PrepInput): { custom_instructions: input.custom_instructions, rules: input.rules, impact_candidates: input.impact_candidates, + server_context: input.server_context, } } export function groundingReportLines(report: GroundingReport): string[] { const lines: string[] = [] - if (report.dropped.length > 0) lines.push(t('review.groundedDropped', { n: report.dropped.length })) - if (report.deanchored.length > 0) lines.push(t('review.groundedDeanchored', { n: report.deanchored.length })) - if (report.merged > 0) lines.push(t('review.groundedMerged', { n: report.merged })) - if (report.verdict_escalated) lines.push(t('review.groundedVerdict')) + if (report.dropped.length > 0) { + lines.push(t('review.groundedDropped', { n: report.dropped.length })) + } + if (report.deanchored.length > 0) { + lines.push(t('review.groundedDeanchored', { n: report.deanchored.length })) + } + if (report.merged > 0) { + lines.push(t('review.groundedMerged', { n: report.merged })) + } + if (report.verdict_escalated) { + lines.push(t('review.groundedVerdict')) + } return lines } @@ -89,7 +128,8 @@ function languageRule(): string { : 'write all human-readable text (summary, messages, narrative) in the language of the commit messages when clearly identifiable, otherwise in English' } -export const reviewInstructions = (): string => `You are a senior code reviewer. Review the merge request provided in the block below (JSON: branch, target, commits, files, and the full unified diff). Do NOT use any tools; base your review ONLY on the provided input. Then output the review as a single JSON object and NOTHING else (no prose, no code fences). +export const reviewInstructions = + (): string => `You are a senior code reviewer. Review the merge request provided in the block below (JSON: branch, target, commits, files, and the full unified diff). Do NOT use any tools; base your review ONLY on the provided input. Then output the review as a single JSON object and NOTHING else (no prose, no code fences). Review guidelines: - Judge the change on: correctness, regressions and breaking changes, security, error handling, missing tests, and whether it matches its stated intent (inferred from the branch name and commit messages). Ground EVERY finding in the diff; never speculate. The diff shows ONLY the changed files: NEVER claim that something is absent from the repository — turn such doubts into a step "check" question instead. @@ -101,6 +141,7 @@ Review guidelines: - When the input has a non-null impact_candidates, it lists where symbols changed by this MR are used elsewhere in the repository (used_at, as path:line) and which files import the changed files (imported_by). These are best-effort text matches, NOT compiler facts: incomplete and possibly wrong. Use them as leads only. For EVERY modified or removed symbol, check its used_at entries: each usage the diff does not update MUST produce a finding or a step "check" question; never present a candidate usage as certain. - When the input has a non-null rules, each entry is a team rule on a normalized grid line: "[Cn] (category) rule | Scope: ... | Where to look: ... | Bad: ... | Good: ... | Exceptions (do not flag): ..."; every segment after the rule is optional. "Scope" bounds where in the repo the rule applies; "Where to look" names the files, imports or code shapes to inspect; Bad/Good is the literal rejected/expected form; Exceptions list what the team knowingly tolerates. - When rules are present, HUNT them first: walk the rules in order and, for each one, jump straight to the diff files and lines its "Where to look" targets and check the rule exactly there; going where a rule says to look is what catches violations, a generic read-through misses them. Then RAKE: the file-by-file sweep above, for everything else. Flag a deviation as kind "convention": the message MUST cite the rule id [Cn] and its rule text, and the deviation MUST be introduced by the diff (a '+' line or a new file), never pre-existing surrounding code. Do NOT flag patterns a rule explicitly endorses, and never flag code covered by a rule's Exceptions. +- When the input has a non-null server_context, it carries repo knowledge fetched from the codesema server: "conventions" (team rules, each with id/rule/category/scope), "learned_rules" proposed by the team's auto-learning, and standalone "facts" about the repo. Use it as background only, never as a substitute for "rules": .codesema/RULES.md is local and authoritative, so on any conflict between server_context and rules, rules wins. When server_context.stale_warning is non-null, the server data predates the current HEAD: treat every field under server_context as possibly outdated, never cite it alone as evidence. - If the input has non-null custom_instructions, apply them on top of these guidelines; they win on conflicts. - Before emitting the JSON, actively try to REFUTE every finding: its file is present in the diff, its line sits inside a hunk, its failure scenario is named, and the diff really produces the claimed outcome. For kind "convention": the cited [Cn] exists in rules, the code deviates from that rule's letter (not from your taste), and no documented Exception covers it; a finding you cannot tie to a written rule is not a convention finding, reclassify it as "design" with its own failure scenario or drop it. Delete any finding you cannot defend; then fill "files_reviewed" with one { "path", "status" } entry per files[] path you examined: "findings" when you kept at least one finding on it, "clean" when you consciously cleared it. Any file in neither is reported to the human as not reviewed. Report boldly during the sweep, refute hard here: that split is what keeps recall high and false positives at zero. - Language: ${languageRule()}. Keep code identifiers and file paths verbatim. @@ -167,14 +208,25 @@ UPDATE the previous review into a new COMPLETE review of the whole MR: Output the FULL updated review JSON (exact same schema), and NOTHING else.` /** Incremental prompt when an archived review of this branch covers a strict ancestor of the reviewed head. */ -function buildIncrementalPrompt(input: PrepInput, cwd: string): { prompt: string; sinceSha: string } | null { +function buildIncrementalPrompt( + input: PrepInput, + cwd: string, +): { prompt: string; sinceSha: string } | null { const previous = findPreviousReview(cwd, input.branch, input.target) const since = previous?.meta.head_sha - if (!previous || !since) return null - if (since === input.head_sha) return null - if (!isAncestor(since, input.head_sha, cwd)) return null + if (!previous || !since) { + return null + } + if (since === input.head_sha) { + return null + } + if (!isAncestor(since, input.head_sha, cwd)) { + return null + } const incrementalDiff = mrDiff(`${since}..${input.head_sha}`, cwd) - if (!incrementalDiff.trim()) return null + if (!incrementalDiff.trim()) { + return null + } const prompt = [ reviewInstructions(), @@ -190,7 +242,9 @@ function buildIncrementalPrompt(input: PrepInput, cwd: string): { prompt: string function detectAgentCommand(cwd: string): string { const [first] = detectAgents(cwd) - if (first) return defaultCommand(first) + if (first) { + return defaultCommand(first) + } throw new Error(t('agent.noneFound', { bins: AGENT_DEFS.map((d) => d.bin).join(', ') })) } @@ -204,11 +258,17 @@ export function extractReviewJson(raw: string): string { } catch { continue } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue - if ('verdict' in (parsed as Record)) return candidate + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + continue + } + if ('verdict' in (parsed as Record)) { + return candidate + } fallback ??= candidate } - if (fallback) return fallback + if (fallback) { + return fallback + } throw new Error(t('agent.noJsonReview')) } @@ -216,11 +276,15 @@ export function extractReviewJson(raw: string): string { function* jsonCandidates(s: string): Generator { yield s for (const m of s.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)) { - if (m[1]) yield m[1].trim() + if (m[1]) { + yield m[1].trim() + } } for (let i = s.indexOf('{'); i >= 0; i = s.indexOf('{', i + 1)) { const end = balancedEnd(s, i) - if (end > i) yield s.slice(i, end + 1) + if (end > i) { + yield s.slice(i, end + 1) + } } } @@ -231,13 +295,20 @@ function balancedEnd(s: string, start: number): number { for (let i = start; i < s.length; i++) { const ch = s[i] if (inString) { - if (ch === '\\') i++ - else if (ch === '"') inString = false - } else if (ch === '"') inString = true - else if (ch === '{') depth++ - else if (ch === '}') { + if (ch === '\\') { + i++ + } else if (ch === '"') { + inString = false + } + } else if (ch === '"') { + inString = true + } else if (ch === '{') { + depth++ + } else if (ch === '}') { depth-- - if (depth === 0) return i + if (depth === 0) { + return i + } } } return -1 @@ -245,16 +316,24 @@ function balancedEnd(s: string, start: number): number { const PARTIAL_PARSE_INTERVAL_MS = 400 -function createPartialForwarder(session: LiveSession, lane: 'a' | 'b' = 'a'): (text: string) => PartialReview | null { +function createPartialForwarder( + session: LiveSession, + lane: 'a' | 'b' = 'a', +): (text: string) => PartialReview | null { let lastParse = 0 return (text: string) => { const now = Date.now() - if (now - lastParse < PARTIAL_PARSE_INTERVAL_MS) return null + if (now - lastParse < PARTIAL_PARSE_INTERVAL_MS) { + return null + } lastParse = now const partial = parsePartialReview(text) if (partial) { - if (lane === 'a') session.setPartial(partial) - else session.setPartialB(partial) + if (lane === 'a') { + session.setPartial(partial) + } else { + session.setPartialB(partial) + } } return partial } @@ -265,16 +344,28 @@ export function missingReviewedFiles( files: { path: string }[], reviewed: ReviewedFile[] | undefined, ): string[] | null { - if (reviewed === undefined) return null + if (reviewed === undefined) { + return null + } const seen = new Set(reviewed.map((f) => f.path)) return files.map((f) => f.path).filter((path) => !seen.has(path)) } -function coverageGapLine(input: PrepInput, lane: string, review: SanitizedReview): string | null { - const missing = missingReviewedFiles(input.files, review.files_reviewed) - if (!missing || missing.length === 0) return null +function coverageGapLine( + input: PrepInput, + lane: string, + sanitized: SanitizedReview, +): string | null { + const missing = missingReviewedFiles(input.files, sanitized.files_reviewed) + if (!missing || missing.length === 0) { + return null + } const shown = missing.slice(0, 3).join(', ') - return t('review.coverageGap', { lane, n: missing.length, files: missing.length > 3 ? `${shown}, …` : shown }) + return t('review.coverageGap', { + lane, + n: missing.length, + files: missing.length > 3 ? `${shown}, …` : shown, + }) } const INVALID_JSON_RETRY_NOTE = @@ -304,7 +395,10 @@ export async function runAgentJsonWithRetry( try { return parse(raw) } catch { - const retried = await runner({ ...opts, prompt: `${opts.prompt}\n\n${INVALID_JSON_RETRY_NOTE}` }) + const retried = await runner({ + ...opts, + prompt: `${opts.prompt}\n\n${INVALID_JSON_RETRY_NOTE}`, + }) try { return parse(retried) } catch (err) { @@ -333,7 +427,9 @@ export async function runDualFlow(opts: { const lanes: { a: string | null; b: string | null } = { a: null, b: null } const updateLanes = () => - spinner.update(`${t('review.dualLaneA')} ${lanes.a ?? '…'} · ${t('review.dualLaneB')} ${lanes.b ?? '…'}`) + spinner.update( + `${t('review.dualLaneA')} ${lanes.a ?? '…'} · ${t('review.dualLaneB')} ${lanes.b ?? '…'}`, + ) const laneRun = (lane: 'a' | 'b', prompt: string): Promise => { const forward = createPartialForwarder(session, lane) return runAgentJsonWithRetry( @@ -345,7 +441,9 @@ export async function runDualFlow(opts: { timeoutMs, onText: (text) => { const partial = forward(text) - if (!partial) return + if (!partial) { + return + } lanes[lane] = progressLabel(partial) updateLanes() }, @@ -362,9 +460,15 @@ export async function runDualFlow(opts: { const settle = ( res: PromiseSettledResult, ): { review: SanitizedReview | null; error: string | null; raw: string | null } => { - if (res.status === 'fulfilled') return { review: res.value, error: null, raw: null } + if (res.status === 'fulfilled') { + return { review: res.value, error: null, raw: null } + } const message = res.reason instanceof Error ? res.reason.message : String(res.reason) - return { review: null, error: message, raw: res.reason instanceof AgentOutputError ? res.reason.raw : null } + return { + review: null, + error: message, + raw: res.reason instanceof AgentOutputError ? res.reason.raw : null, + } } const a = settle(resA) const b = settle(resB) @@ -379,8 +483,8 @@ export async function runDualFlow(opts: { } } - const buildRecord = (review: SanitizedReview): ReviewRecord => { - writeFileSync(join(dir, 'review.json'), JSON.stringify(review, null, 2)) + const buildRecord = (sanitized: SanitizedReview): ReviewRecord => { + writeFileSync(join(dir, 'review.json'), JSON.stringify(sanitized, null, 2)) return resolveRecord({ cwd: input.repo_root }).record } @@ -427,10 +531,14 @@ export async function runDualFlow(opts: { timeoutMs, onText: (text) => { const now = Date.now() - if (now - lastJudgeParse < PARTIAL_PARSE_INTERVAL_MS) return + if (now - lastJudgeParse < PARTIAL_PARSE_INTERVAL_MS) { + return + } lastJudgeParse = now const partial = parsePartialJudge(text, aCount, bCount) - if (!partial) return + if (!partial) { + return + } session.setJudge({ total, decisions: partial.decisions }) spinner.update(t('review.dualJudgeProgress', { done: partial.decisions.length, total })) }, @@ -452,7 +560,11 @@ export async function runDualFlow(opts: { const consensusCount = final.review.findings.filter((f) => f.consensus).length const grounding: GroundingReport = { dropped: [...groundedA.report.dropped, ...groundedB.report.dropped, ...final.report.dropped], - deanchored: [...groundedA.report.deanchored, ...groundedB.report.deanchored, ...final.report.deanchored], + deanchored: [ + ...groundedA.report.deanchored, + ...groundedB.report.deanchored, + ...final.report.deanchored, + ], merged: groundedA.report.merged + groundedB.report.merged + final.report.merged, verdict_escalated: final.report.verdict_escalated, } @@ -483,7 +595,9 @@ export async function runDualFlow(opts: { * true if execution may proceed, false if the user cancels. */ async function ensureRepoAgentTrusted(cwd: string, command: string): Promise { - if (isRepoAgentTrusted(cwd, command)) return true + if (isRepoAgentTrusted(cwd, command)) { + return true + } if (!isInteractive()) { throw new Error(t('review.repoAgentUnattended', { command })) } @@ -499,22 +613,24 @@ async function ensureRepoAgentTrusted(cwd: string, command: string): Promise { printBanner() @@ -524,7 +640,9 @@ export async function review(opts: { let agentCommand = opts.agent ?? config.agent if (!agentCommand && isInteractive()) { agentCommand = (await runOnboarding(cwd)) ?? undefined - if (agentCommand) console.log('') + if (agentCommand) { + console.log('') + } } agentCommand ??= detectAgentCommand(cwd) @@ -541,11 +659,19 @@ export async function review(opts: { let branch = opts.branch if (!branch && opts.interactive !== false && isInteractive()) { const picked = await pickBranch(cwd) - if (picked === null) return + if (picked === null) { + return + } branch = picked } - const input = prep({ branch, target: opts.target ?? config.target, cwd, quiet: true }) + const prepared = prep({ branch, target: opts.target ?? config.target, cwd, quiet: true }) + // Best-effort, never blocking: offline, unlinked workspace, a non-200 or a + // timeout all silently degrade to null (local review unchanged). + const input: PrepInput = { + ...prepared, + server_context: await buildServerContext(prepared.repo_root), + } const dir = ensureWorkDir(input.repo_root) // Dual reviews always start from scratch: the incremental prompt updates ONE @@ -607,14 +733,22 @@ export async function review(opts: { headerRows.push({ label: t('field.prompt'), value: dim(t('review.customPrompt')) }) } if (input.rules) { - headerRows.push({ label: t('field.rules'), value: dim(t('review.teamRules', { n: input.rules.length })) }) + headerRows.push({ + label: t('field.rules'), + value: dim(t('review.teamRules', { n: input.rules.length })), + }) } - headerRows.push({ label: t('field.web'), value: `${underline(paint(url, ACCENT))} ${dim(t('review.webLiveHint'))}` }) + headerRows.push({ + label: t('field.web'), + value: `${underline(paint(url, ACCENT))} ${dim(t('review.webLiveHint'))}`, + }) console.log('') renderFieldRows(headerRows).forEach((line) => console.log(line)) console.log('') - if (opts.open && !opts.failOn) openBrowser(url) + if (opts.open && !opts.failOn) { + openBrowser(url) + } const shortCmd = agentCommand.length > 40 ? `${agentCommand.slice(0, 37)}…` : agentCommand const spinner = startSpinner(t('review.spinner', { cmd: shortCmd })) @@ -623,11 +757,17 @@ export async function review(opts: { const heading = kind === 'run' ? t('review.runFailed') : t('review.unusableOutput') spinner.stop(` ${paint('✘', RED)} ${heading}`) session.setError(message) - if (isInteractive()) notifyDesktop('codesema', t(kind === 'run' ? 'notify.failedRun' : 'notify.failedOutput')) - console.error(`codesema: ${kind === 'run' ? t('review.runFailedDetail', { message }) : message}`) + if (isInteractive()) { + notifyDesktop('codesema', t(kind === 'run' ? 'notify.failedRun' : 'notify.failedOutput')) + } + console.error( + `codesema: ${kind === 'run' ? t('review.runFailedDetail', { message }) : message}`, + ) console.log(` ${t('review.stillUp', { url })}`) process.exitCode = 1 - if (opts.failOn) await stop() + if (opts.failOn) { + await stop() + } } let record: ReviewRecord @@ -635,7 +775,9 @@ export async function review(opts: { if (dual) { const outcome = await runDualFlow({ agentCommand, input, dir, timeoutMs, session, spinner }) if (!outcome.ok) { - if (outcome.rawOutput !== undefined) writeFileSync(join(dir, 'agent-output.txt'), outcome.rawOutput) + if (outcome.rawOutput !== undefined) { + writeFileSync(join(dir, 'agent-output.txt'), outcome.rawOutput) + } await failRun(outcome.failure, outcome.message) return } @@ -654,9 +796,13 @@ export async function review(opts: { timeoutMs, onText: (text) => { const partial = forwardPartial(text) - if (!partial) return + if (!partial) { + return + } const status = progressLabel(partial) - if (status) spinner.update(status) + if (status) { + spinner.update(status) + } }, }, (raw) => { @@ -686,7 +832,9 @@ export async function review(opts: { // against the full file list would cry wolf. if (!incremental) { const coverage = coverageGapLine(input, t('review.dualLaneA'), grounded.review) - if (coverage) reportLines.push(coverage) + if (coverage) { + reportLines.push(coverage) + } } } catch (err) { writeFileSync(join(dir, 'agent-output.txt'), out) diff --git a/packages/cli/src/rules.test.ts b/packages/cli/src/rules.test.ts index 60de53f..498453a 100644 --- a/packages/cli/src/rules.test.ts +++ b/packages/cli/src/rules.test.ts @@ -1,7 +1,7 @@ -import { afterAll, beforeAll, describe, expect, test } from 'bun:test' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' import { formatRules, loadRules, parseRules } from './rules.js' describe('parseRules', () => { diff --git a/packages/cli/src/rules.ts b/packages/cli/src/rules.ts index a3dce11..27d29e2 100644 --- a/packages/cli/src/rules.ts +++ b/packages/cli/src/rules.ts @@ -15,15 +15,21 @@ export function parseRules(content: string): string[] { const rules: string[] = [] for (const raw of content.split('\n')) { const line = raw.trim() - if (!line || line.startsWith('#') || line.startsWith(' @@ -82,15 +86,8 @@ function renderInline(text: string): string {
{{ $t('reviews.prologue.reviewFirst') }}
-
- +
+
@@ -98,11 +95,9 @@ function renderInline(text: string): string {
- - @@ -202,7 +195,7 @@ function renderInline(text: string): string { } .prologue-key-item::before { - content: ""; + content: ''; position: absolute; left: 0; top: 7px; diff --git a/packages/web/src/components/ReviewShell.vue b/packages/web/src/components/ReviewShell.vue index 92f411e..81a21e1 100644 --- a/packages/web/src/components/ReviewShell.vue +++ b/packages/web/src/components/ReviewShell.vue @@ -1,19 +1,18 @@