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/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..1066815 100644 --- a/package.json +++ b/package.json @@ -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/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..86f3f95 100644 --- a/packages/cli/src/prep.ts +++ b/packages/cli/src/prep.ts @@ -1,9 +1,19 @@ 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 { renderFieldRows, type FieldRow } from './ui.js' @@ -47,8 +57,12 @@ export type PrepInput = { } 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 +84,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 +150,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 +169,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 +185,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 +227,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 +240,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) => { @@ -239,10 +294,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..d096c7c 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' @@ -97,7 +97,12 @@ 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([]) }) @@ -191,7 +196,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 +211,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 +244,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) { @@ -291,19 +306,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..4087329 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,51 @@ 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 { 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 } @@ -75,10 +103,18 @@ export function agentVisibleInput(input: PrepInput): { 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 +125,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. @@ -167,14 +204,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 +238,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 +254,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 +272,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 +291,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 +312,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 +340,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 +391,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 +423,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 +437,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 +456,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 +479,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 +527,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 +556,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 +591,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 +609,24 @@ async function ensureRepoAgentTrusted(cwd: string, command: string): Promise { printBanner() @@ -524,7 +636,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,7 +655,9 @@ 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 } @@ -607,14 +723,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 +747,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 +765,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 +786,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 +822,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 @@