diff --git a/DOCS.md b/DOCS.md index 41a5105..77e5fa6 100644 --- a/DOCS.md +++ b/DOCS.md @@ -464,7 +464,7 @@ vg review --in-place # the same, stated explicitly vg review --local # deterministic scanners; no hosted model vg review --loop # review → deterministic patch → re-review vg review --base origin/main # merge-base of HEAD and the base branch -vg review explain arch-01 # the evidence behind one finding +vg review explain arch:: # the evidence behind one finding vg review findings-from-diff # deterministic graph/policy findings only vg review propose blast: --model forge --json ``` @@ -570,21 +570,60 @@ the signed receipt ceremony. vg review findings-from-diff vg review findings-from-diff --base origin/main vg review findings-from-diff --diff pr.patch --format json -vg review propose blast: --model forge --json ``` `--diff` reads a unified diff (`-` is stdin). The patch names the files and hunks; the code map still has to be built (`vg` or `vg build`). `--format json` -writes the findings document plus a `publishable` array of correctness rows -(`kind: "correctness"`, stable `id` used as `finding_key`, producer metadata -`blast_radius` or `architecture`) for App ingest. When capsule verification -already emitted a fact for a finding's path, that evidence id is cited on the -finding and on the publishable `receipts` array — no second receipt system. -Suggested-fix on those rows is an honest skip — there is no computed PatchIR -for blast-radius or architecture-policy rows. Propose is dry-run unless you pass -`--apply --yes` on a topic branch — never the default branch. Local Code Mode -ids are `spark`, `flow`, and `forge`; hosted Review uses `relay:`. This -path does not post a comment or a check run. +writes the findings document plus a `publishable` array of correctness rows. + +| Field | Blast-radius | Architecture-policy | +| --- | --- | --- | +| `kind` | `correctness` (top-level only) | `correctness` (top-level only) | +| `id` / `finding_key` | `blast:{node_id}` or `blast:{path}:{name}` | `arch:{rule}:{path}` | +| `source` | `scanner` | `scanner` | +| producer / `scanner_kind` | `blast_radius` | `architecture` | +| severity | `low` or `medium` | `low`, `medium`, or `high` — never `critical` from version lag | +| `receipts` | existing capsule `verify:` / `scan:` / `attest:` ids when those facts already exist | same | + +Ids are stable across head SHAs: same symbol or rule+path keeps the same key. +No spaces. Suggested-fix on publishable rows is an honest skip (`null` / +`skipped_no_patch`) — there is no computed PatchIR for blast-radius or +architecture-policy rows. Each run also writes +`.vibgrate/review-propose-handoff.json` (`vg.review.propose-handoff.v1`) so +`vg review propose` can resolve those ids without a second findings loop. +This path does not post a comment or a check run. + +#### Propose a PatchIR dry-run — `vg review propose` + +```bash +vg review propose blast: --model forge --json +vg review propose blast: --model forge --json --base origin/main +vg review propose arch:: --model forge --json --findings findings.json +vg review propose arch:: --model relay: --json +``` + +Attaches a PatchIR dry-run to one finding id via the VG Code agent loop — there +is no second Review runtime. Lookup is the current change set, then +`--findings` JSON (a `vg.review.findings.v1` document or a review receipt), +then the last-run `.vibgrate/review-propose-handoff.json`. Pass the same +`--base` / `--in-place` / `--diff` as findings-from-diff when you want that +change set explicitly. + +| Flag | Default | Description | +| --- | --- | --- | +| `--model ` | required | `relay:` (hosted Review) or `spark` \| `flow` \| `forge` (local Code Mode). A bare slug is invalid — propose never calls a backend in that case. | +| `--loop` | on | VG Code agent loop (capped; stops on no progress) | +| `--single` | off | One-shot residual → patch → verify instead of the loop | +| `--apply` | off | Write the patch (still requires `--yes`; refused on the default branch) | +| `--yes` | off | Consent to write when `--apply` is set | +| `--base ` | — | Same change set as `findings-from-diff --base` | +| `--in-place` | off | Same as `findings-from-diff --in-place` | +| `--diff ` | — | Same as `findings-from-diff --diff` (`-` is stdin) | +| `--findings ` | — | Findings JSON when the current change set does not list the id | + +Dry-run unless you pass `--apply --yes` on a **topic branch**. It never writes +the default branch. The same Code Mode and Relay ids are used by `vg code`. +This command does not post a check run or a review comment. #### Before you write it — `assess_change` diff --git a/README.md b/README.md index d3ea195..c8d1748 100644 --- a/README.md +++ b/README.md @@ -603,8 +603,21 @@ vg vex # generate an OpenVEX document for attestation ```bash vg review +vg review findings-from-diff --base origin/main --format json +vg review propose blast: --model forge --json +vg review propose arch:: --model forge --json --findings findings.json ``` +`vg review findings-from-diff` prints the deterministic `vg.review.findings.v1` +document (blast-radius and architecture-policy `correctness` rows plus security +scanners) and writes `.vibgrate/review-propose-handoff.json`. +`vg review propose ` attaches a PatchIR dry-run — `--model` is +`relay:` (hosted Review) or `spark` | `flow` | `forge` (local Code Mode). +Lookup is the current change set, then `--findings` JSON, then that last-run +handoff; pass the same `--base` / `--in-place` / `--diff` as findings-from-diff +when you want that change set explicitly. It writes nothing unless you pass +`--apply --yes` on a topic branch. + --- ## Privacy & offline-first @@ -747,7 +760,7 @@ All HCS computation runs in an optional, separately-licensed engine module that | `vg fix` | Ranked, risk-tiered upgrade plans from the hosted planner — then apply the one you choose | | `vg init [path]` | Initialise config and `.vibgrate/` | | `vg report` | Generate a report from a scan artifact | -| `vg review` | **Vibgrate Review** — architecture + security-control review of the current change, locally (`--in-place`, `--local`, `--loop`). Deterministic blast-radius findings from the code graph via `vg review findings-from-diff`; `vg review propose ` attaches a PatchIR dry-run. One decision (`pass` / `needs_review` / `fail` / `undetermined`) in a signed receipt (Ed25519 over the receipt digest; `vg review verify ` checks it offline); protected findings cannot be blessed into a pass. Reports change integrity, not a proof of security. Builds or refreshes the code map itself when it is missing or stale (`--no-auto-build` opts out) | +| `vg review` | **Vibgrate Review** — architecture + security-control review of the current change, locally (`--in-place`, `--local`, `--loop`). Deterministic blast-radius findings from the code graph via `vg review findings-from-diff`; `vg review propose ` attaches a PatchIR dry-run (same `--base` / `--in-place` / `--diff`, `--findings`, or `.vibgrate/review-propose-handoff.json`). One decision (`pass` / `needs_review` / `fail` / `undetermined`) in a signed receipt (Ed25519 over the receipt digest; `vg review verify ` checks it offline); protected findings cannot be blessed into a pass. Reports change integrity, not a proof of security. Builds or refreshes the code map itself when it is missing or stale (`--no-auto-build` opts out) | | `vg sbom export` / `delta` / `vex` | Export CycloneDX/SPDX SBOM, diff two artifacts, or emit an OpenVEX document | | `vg scan [path]` | Scan for upgrade drift | | `vg scan --full` | Comprehensive scan: drift + vulnerabilities + a banned-dependency report | diff --git a/action.yml b/action.yml index 1a35f89..25c8620 100644 --- a/action.yml +++ b/action.yml @@ -46,7 +46,7 @@ inputs: image-tag: description: 'Scanner image tag to run (defaults to a pinned, tested release).' required: false - default: '2026.917.1' # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs + default: '2026.921.1' # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs verify: description: 'Verify the image cosign signature + provenance before running (requires cosign on the runner).' required: false diff --git a/charts/vibgrate/Chart.yaml b/charts/vibgrate/Chart.yaml index da02a46..2a09f7d 100644 --- a/charts/vibgrate/Chart.yaml +++ b/charts/vibgrate/Chart.yaml @@ -6,8 +6,8 @@ type: application # independently of the CLI. appVersion pins the tested scanner image tag and is # stamped to the released @vibgrate/cli calendar version by # scripts/stamp-release-pins.mjs (via the marker on the appVersion line below). -version: 0.1.3 -appVersion: "2026.917.1" # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs +version: 0.1.2 +appVersion: "2026.921.1" # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs home: https://vibgrate.com icon: https://vibgrate.com/web-app-manifest-512x512.png sources: diff --git a/package.json b/package.json index d2ba0d5..3d0dced 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vibgrate/cli", - "version": "2026.917.1", + "version": "2026.921.1", "description": "vg — local codebase intelligence CLI + MCP server for AI coding agents: deterministic code graph, drift reporting, and version-correct library docs (Apache-2.0)", "//mcpName": "Official MCP registry ownership proof: the registry fetches the published npm package and requires this field to match the com.vibgrate/ai-context server entry (see docs/marketing/mcp-registry/README.md). Must ship in the published @vibgrate/cli package.json.", "mcpName": "com.vibgrate/ai-context", @@ -104,7 +104,7 @@ }, "devDependencies": { "@tree-sitter-grammars/tree-sitter-hcl": "1.2.0", - "@types/node": "^26.5.1", + "@types/node": "^26.6.1", "@types/semver": "^7.8.0", "@typescript-eslint/eslint-plugin": "^8.70.0", "@typescript-eslint/parser": "^8.70.0", @@ -112,7 +112,7 @@ "graphology-types": "^0.24.8", "tsup": "^8.0.0", "tsx": "^4.23.13", - "vitest": "^5.0.0" + "vitest": "^5.0.1" }, "engines": { "node": ">=22.0.0" diff --git a/packaging/homebrew-tap/Formula/vg.rb b/packaging/homebrew-tap/Formula/vg.rb index 1e7f056..40a501e 100644 --- a/packaging/homebrew-tap/Formula/vg.rb +++ b/packaging/homebrew-tap/Formula/vg.rb @@ -3,8 +3,8 @@ class Vg < Formula desc "Deterministic, no-API-key code graph for AI assistants (vg)" homepage "https://vibgrate.com" - url "https://registry.npmjs.org/@vibgrate/cli/-/cli-2026.917.1.tgz" - sha256 "13b8468bc5226af571b119d101e050399c556d33aa2d4bf22cdf12dcefb102c1" + url "https://registry.npmjs.org/@vibgrate/cli/-/cli-2026.914.1.tgz" + sha256 "21c164080d1ba33dc53d604a8754ffa0079daa9c8b771a9053c224a2c43877bf" license "Apache-2.0" depends_on "node" diff --git a/packaging/scoop-bucket/vg.json b/packaging/scoop-bucket/vg.json index f0b04ac..b76a0cc 100644 --- a/packaging/scoop-bucket/vg.json +++ b/packaging/scoop-bucket/vg.json @@ -1,5 +1,5 @@ { - "version": "2026.917.1", + "version": "2026.914.1", "description": "Deterministic, no-API-key code graph for AI assistants (vg)", "homepage": "https://vibgrate.com", "license": "Apache-2.0", diff --git a/plugins/claude/vg/skills/vg/SKILL.md b/plugins/claude/vg/skills/vg/SKILL.md index c01c111..d98a152 100644 --- a/plugins/claude/vg/skills/vg/SKILL.md +++ b/plugins/claude/vg/skills/vg/SKILL.md @@ -3,7 +3,7 @@ name: vg description: Query the local code graph (vg) for structure, impact, and navigation instead of grepping/reading many files. --- - + # vg — the code map @@ -48,9 +48,12 @@ When a task needs a library's API, use the docs tools before web search or training-data recall — they are official content matched to the version **this project has installed**, and they win when the two conflict. -- **Workflow:** `resolve_library` once per library, then `library_docs` with the - returned `targetId` and a focused query (good: "zod refine custom error - message"; bad: "zod"). Never guess a targetId. +- **Workflow:** `resolve_library` with the **package name only** (good: `"zod"`). + Then `library_docs` with the returned `targetId` and a topic (good: `"refine + custom error message"`). Do not send the whole phrase as the resolve query — + hosted resolve treats an unknown exact name as `not_found`. Never guess a + targetId. If docs return `entityCount` 0, the catalog has no snippet for that + version; read `node_modules` instead of retrying the same query. - **Budget:** at most **3 docs calls per task**. If 2 `library_docs` calls have not surfaced the section you need, read the package source under `node_modules` instead of searching again. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29816a3..0d1c7da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,8 +83,8 @@ importers: specifier: 1.2.0 version: 1.2.0 '@types/node': - specifier: ^26.5.1 - version: 26.5.1 + specifier: ^26.6.1 + version: 26.6.2 '@types/semver': specifier: ^7.8.0 version: 7.8.0 @@ -107,8 +107,8 @@ importers: specifier: ^4.23.13 version: 4.23.13 vitest: - specifier: ^5.0.0 - version: 5.0.0(@types/node@26.5.1)(vite@8.1.4(@types/node@26.5.1)(esbuild@0.27.7)(tsx@4.23.13)(yaml@2.9.0)) + specifier: ^5.0.1 + version: 5.0.1(@types/node@26.6.2)(vite@8.1.4(@types/node@26.6.2)(esbuild@0.27.7)(tsx@4.23.13)(yaml@2.9.0)) packages: @@ -473,7 +473,7 @@ packages: resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4 + hono: ^4.12.34 '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} @@ -820,8 +820,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@26.5.1': - resolution: {integrity: sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==} + '@types/node@26.6.2': + resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==} '@types/semver@7.8.0': resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} @@ -885,8 +885,8 @@ packages: resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/mocker@5.0.0': - resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} + '@vitest/mocker@5.0.1': + resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -896,8 +896,8 @@ packages: vite: optional: true - '@vitest/spy@5.0.0': - resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + '@vitest/spy@5.0.1': + resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==} '@yomguithereal/helpers@1.1.1': resolution: {integrity: sha512-UYvAq/XCA7xoh1juWDYsq3W0WywOB+pz8cgVnE1b45ZfdMhBvHDrgmSFG3jXeZSr2tMTYLGHFHON+ekG05Jebg==} @@ -1990,20 +1990,20 @@ packages: yaml: optional: true - vitest@5.0.0: - resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==} + vitest@5.0.1: + resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 5.0.0 - '@vitest/browser-preview': 5.0.0 + '@vitest/browser-playwright': 5.0.1 + '@vitest/browser-preview': 5.0.1 '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 - '@vitest/coverage-istanbul': 5.0.0 - '@vitest/coverage-v8': 5.0.0 - '@vitest/ui': 5.0.0 + '@vitest/coverage-istanbul': 5.0.1 + '@vitest/coverage-v8': 5.0.1 + '@vitest/ui': 5.0.1 happy-dom: '*' jsdom: '*' vite: ^6.4.0 || ^7.0.0 || ^8.0.0 @@ -2546,7 +2546,7 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@26.5.1': + '@types/node@26.6.2': dependencies: undici-types: 8.9.0 @@ -2643,16 +2643,16 @@ snapshots: '@typescript-eslint/types': 8.70.0 eslint-visitor-keys: 5.0.1 - '@vitest/mocker@5.0.0(vite@8.1.4(@types/node@26.5.1)(esbuild@0.27.7)(tsx@4.23.13)(yaml@2.9.0))': + '@vitest/mocker@5.0.1(vite@8.1.4(@types/node@26.6.2)(esbuild@0.27.7)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.0 + '@vitest/spy': 5.0.1 estree-walker: 3.0.3 magic-string: 1.2.3 optionalDependencies: - vite: 8.1.4(@types/node@26.5.1)(esbuild@0.27.7)(tsx@4.23.13)(yaml@2.9.0) + vite: 8.1.4(@types/node@26.6.2)(esbuild@0.27.7)(tsx@4.23.13)(yaml@2.9.0) - '@vitest/spy@5.0.0': {} + '@vitest/spy@5.0.1': {} '@yomguithereal/helpers@1.1.1': {} @@ -3780,7 +3780,7 @@ snapshots: vary@1.1.2: {} - vite@8.1.4(@types/node@26.5.1)(esbuild@0.27.7)(tsx@4.23.13)(yaml@2.9.0): + vite@8.1.4(@types/node@26.6.2)(esbuild@0.27.7)(tsx@4.23.13)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.7 @@ -3788,16 +3788,16 @@ snapshots: rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.5.1 + '@types/node': 26.6.2 esbuild: 0.27.7 fsevents: 2.3.3 tsx: 4.23.13 yaml: 2.9.0 - vitest@5.0.0(@types/node@26.5.1)(vite@8.1.4(@types/node@26.5.1)(esbuild@0.27.7)(tsx@4.23.13)(yaml@2.9.0)): + vitest@5.0.1(@types/node@26.6.2)(vite@8.1.4(@types/node@26.6.2)(esbuild@0.27.7)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.0(vite@8.1.4(@types/node@26.5.1)(esbuild@0.27.7)(tsx@4.23.13)(yaml@2.9.0)) + '@vitest/mocker': 5.0.1(vite@8.1.4(@types/node@26.6.2)(esbuild@0.27.7)(tsx@4.23.13)(yaml@2.9.0)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -3808,10 +3808,10 @@ snapshots: tinybench: 6.1.4 tinyexec: 1.3.0 tinyglobby: 0.2.17 - vite: 8.1.4(@types/node@26.5.1)(esbuild@0.27.7)(tsx@4.23.13)(yaml@2.9.0) + vite: 8.1.4(@types/node@26.6.2)(esbuild@0.27.7)(tsx@4.23.13)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.5.1 + '@types/node': 26.6.2 transitivePeerDependencies: - msw diff --git a/releases/v2026.921.1.md b/releases/v2026.921.1.md new file mode 100644 index 0000000..a87604c --- /dev/null +++ b/releases/v2026.921.1.md @@ -0,0 +1,44 @@ +# Vibgrate CLI 2026.921.1 + +_Released 2026-09-21_ + +This release of the Vibgrate CLI includes improvements to documentation and command functionality, as well as several fixes that enhance the overall user experience. Notable changes include updates to the `vg review` command and improvements to the AI assistant integration. + +## What changed + +### Improved + +- `vg review` help and documentation now comprehensively cover `findings-from-diff` and `propose` end-to-end. +- The `vg` skill for AI assistants now instructs the agent to read `node_modules` for documentation when no snippet is returned. + +### Fixed + +- `vg review propose ` now accepts the same change set as `vg review findings-from-diff`, ensuring consistent behavior across commands. +- `vg review` no longer raises an `arch:unverified_change` finding for files that cannot be tested, streamlining the review process. + +## Benchmarks + +Two-arm benchmark of this release against 2026.917.1, interleaved on one runner against the pinned corpus (236 metrics compared). + +| Metric | Previous | This release | +| --- | --- | --- | +| Languages with extraction | 19 count | 19 count | +| Definitions extracted (corpus total) | 25846 count | 25846 count | +| Call edges extracted (corpus total) | 17565 count | 17565 count | +| Locate accuracy (top-1) | 0.95 ratio | 0.95 ratio | +| Dependency detection (authored manifest truth) | 0.96 ratio | 0.96 ratio | +| CLI startup (--version, median) | 803.70 ms | 800.20 ms | + +1 regression(s) — published, not omitted: +- Agent tokens with vg (comparable tasks, total): 603265 → 643530 (6.7%) + +Full report and methodology: https://vibgrate.com/cli/benchmarks + +## Install or update + +```sh +npm install -g @vibgrate/cli +vg +``` + +Full changelog: https://vibgrate.com/changelog/cli/2026.921.1 diff --git a/scripts/stamp-packaging.mjs b/scripts/stamp-packaging.mjs index 82027d7..2f990ab 100644 --- a/scripts/stamp-packaging.mjs +++ b/scripts/stamp-packaging.mjs @@ -60,13 +60,14 @@ export function stampScoop(template, { version, sha256, tarballUrl }) { } // npm registry propagation after `npm publish` is eventually consistent — the -// tarball can 404 for a couple of minutes even after the Release workflow's -// publish step reports success. Retry generously (default ~4.5 minutes of -// total backoff) before giving up, so a normal propagation lag doesn't fail -// the packaging job. +// tarball can 404 for several minutes even after the Release workflow's +// publish step reports success (observed in practice exceeding the previous +// ~4.5 minute budget — see github.com/vibgrate/cli run 35227261907). Retry +// generously (default ~8 minutes of total backoff) before giving up, so a +// normal propagation lag doesn't fail the packaging job. export async function sha256OfUrl( url, - { retries = 8, fetchImpl = fetch, baseDelayMs = 5000, maxDelayMs = 60_000 } = {}, + { retries = 12, fetchImpl = fetch, baseDelayMs = 5000, maxDelayMs = 60_000 } = {}, ) { let lastErr; for (let attempt = 1; attempt <= retries; attempt += 1) { diff --git a/skills/vg/SKILL.md b/skills/vg/SKILL.md index 6f7aabb..9a7036e 100644 --- a/skills/vg/SKILL.md +++ b/skills/vg/SKILL.md @@ -3,7 +3,7 @@ name: vg description: Query the local code graph (vg) for structure, impact, and navigation instead of grepping/reading many files. --- - + # vg — the code map @@ -48,9 +48,12 @@ When a task needs a library's API, use the docs tools before web search or training-data recall — they are official content matched to the version **this project has installed**, and they win when the two conflict. -- **Workflow:** `resolve_library` once per library, then `library_docs` with the - returned `targetId` and a focused query (good: "zod refine custom error - message"; bad: "zod"). Never guess a targetId. +- **Workflow:** `resolve_library` with the **package name only** (good: `"zod"`). + Then `library_docs` with the returned `targetId` and a topic (good: `"refine + custom error message"`). Do not send the whole phrase as the resolve query — + hosted resolve treats an unknown exact name as `not_found`. Never guess a + targetId. If docs return `entityCount` 0, the catalog has no snippet for that + version; read `node_modules` instead of retrying the same query. - **Budget:** at most **3 docs calls per task**. If 2 `library_docs` calls have not surfaced the section you need, read the package source under `node_modules` instead of searching again. diff --git a/src/cli-options.test.ts b/src/cli-options.test.ts index e8cd360..672376a 100644 --- a/src/cli-options.test.ts +++ b/src/cli-options.test.ts @@ -75,6 +75,9 @@ describe('the CLI surface', () => { expect(proposeLongs).toContain('--loop'); expect(proposeLongs).toContain('--apply'); expect(proposeLongs).toContain('--model'); + expect(proposeLongs).toContain('--base'); + expect(proposeLongs).toContain('--diff'); + expect(proposeLongs).toContain('--findings'); }); it('still accepts `vg bundle --offline`, whose own declaration was removed', () => { diff --git a/src/code/agent.ts b/src/code/agent.ts index 653e018..1f6567d 100644 --- a/src/code/agent.ts +++ b/src/code/agent.ts @@ -91,7 +91,7 @@ import { import { createToolOutputCompressor, RETRIEVE_TOOL_NAME, type CompressionStats, type ToolOutputCompressor } from './compress-tool-output.js'; import { env as knobEnv } from '../compress/config.js'; import { repositoryIdFromRoot } from '../runtime/paths.js'; -import { residualEditsToToolCalls, type SymbolSpan } from './apply.js'; +import { dumpEditsToToolCalls, residualEditsToToolCalls, type SymbolSpan } from './apply.js'; import type { CodeFs } from './session.js'; import type { ChatMessage, CodeContext, FileChange, ImageAttachment, Provider, ProviderResult, ReasoningEffort, ToolCall, ToolSpec } from './types.js'; import type { VgGraph } from '../schema.js'; @@ -1088,12 +1088,29 @@ export async function runAgent(options: AgentOptions): Promise { (result.text ?? '').trim() && instructionRequiresMutation(instruction) ) { - const residual = residualEditsToToolCalls(result.text ?? ''); + const defaultFile = options.files?.length === 1 ? options.files[0] : undefined; + const residual = residualEditsToToolCalls(result.text ?? '', { defaultFile }); if (residual.length) { toolCalls = residual; result = { ...result, text: '', toolCalls }; } } + // Live Flow Review (#2662): the pack printed a PatchIR / `{path,search, + // replace}` / named-tool JSON dump. looksLikeToolCallDump flags that as + // a dump, but parseTextToolCalls only accepts `{name, arguments}`. Lift + // an applicable edit so happy-path-loop cannot die as no-tools. + if ( + toolCalls.length === 0 && + (result.text ?? '').trim() && + instructionRequiresMutation(instruction) + ) { + const defaultFile = options.files?.length === 1 ? options.files[0] : undefined; + const dumped = dumpEditsToToolCalls(result.text ?? '', { defaultFile }); + if (dumped.length) { + toolCalls = dumped; + result = { ...result, text: '', toolCalls }; + } + } // Every name — native, text-protocol, or rescued — is rewritten before // dispatch so `apply patch` / `read file` never hit "unknown tool". if (toolCalls.length) toolCalls = normalizeToolCalls(toolCalls, allTools); @@ -1207,10 +1224,22 @@ export async function runAgent(options: AgentOptions): Promise { const files = [...new Set(changes.map((c) => c.file))]; return finish('finished', `Edited ${files.join(', ')}.`, step); } + const rawDump = (result.text ?? fullText ?? '').trim(); + if (dumpStop && rawDump) { + onEvent({ type: 'tool-call', name: 'unparsed-dump', args: {} }); + onEvent({ + type: 'tool-result', + name: 'unparsed-dump', + content: rawDump.length > 2_000 ? `${rawDump.slice(0, 2_000)}\n…` : rawDump, + mutated: false, + failed: true, + }); + } return finish( 'no-tools', dumpStop - ? `The model (${providerInfo.model}) printed a tool or edit dump instead of applying a change or writing an answer. Try a stronger model, or re-ask with a more specific instruction.` + ? `The model (${providerInfo.model}) printed a tool or edit dump instead of applying a change or writing an answer. Try a stronger model, or re-ask with a more specific instruction.` + + (rawDump ? `\nLast model text:\n${rawDump.length > 2_000 ? `${rawDump.slice(0, 2_000)}\n…` : rawDump}` : '') : stubAsk ? `The model (${providerInfo.model}) stopped without applying a required file change. Try a stronger model, or re-ask with a more specific instruction.` : fullText || @@ -1727,11 +1756,12 @@ async function createLoopCompressor(instruction: string, model: string, env: Nod const FILE_HINT_RE = /(?:^|[\s`'"])(?:[\w.-]+\/)+[\w.-]+\.[A-Za-z][\w.-]*\b|\b[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|swift|rb|php|cs|cpp|c|h|vue|svelte)\b/; -/** Tight: `edit` / `edit_file`, or change/fix/replace plus a file path, or "so … return(s)". */ +/** Tight: `edit` / `edit_file`, Review propose, or change/fix/replace plus a file path, or "so … return(s)". */ export function instructionRequiresMutation(instruction: string): boolean { const t = (instruction ?? '').trim(); if (!t) return false; if (/\bedit(?:_file)?\b/i.test(t)) return true; + if (/\bpropose a minimal patch\b/i.test(t)) return true; if (/\b(change|fix|replace)\b/i.test(t) && FILE_HINT_RE.test(t)) return true; if (/\bso\b[\s\S]{0,120}\breturns?\b/i.test(t)) return true; return false; diff --git a/src/code/apply.test.ts b/src/code/apply.test.ts index 083d74a..626ce95 100644 --- a/src/code/apply.test.ts +++ b/src/code/apply.test.ts @@ -1,5 +1,17 @@ import { describe, it, expect } from 'vitest'; -import { parseEdits, applyEdit, applyEdits, residualEditsToToolCalls, type SymbolSpan } from './apply.js'; +import { + parseEdits, + parseEditDump, + applyEdit, + applyEdits, + residualEditsToToolCalls, + dumpEditsToToolCalls, + collectProviderEdits, + editsFromUnifiedDiff, + pathFromLine, + type SymbolSpan, +} from './apply.js'; +import { looksLikeToolCallDump, parseTextToolCalls } from './text-tool-protocol.js'; describe('parseEdits', () => { it('parses a search/replace block with the file on the preceding line', () => { @@ -22,6 +34,35 @@ describe('parseEdits', () => { ]); }); + it('parses an inline path on the SEARCH line (live Code Mode shape)', () => { + const edits = parseEdits(['<<<<<<< SEARCH src/scan.ts', 'const timeout = 0;', '=======', 'const timeout = 5000;', '>>>>>>> REPLACE'].join('\n')); + expect(edits).toEqual([ + { op: 'replace', file: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;', anchorSymbol: undefined }, + ]); + }); + + it('uses defaultFile when the residual has no path (cited-file handoff)', () => { + const edits = parseEdits(['<<<<<<< SEARCH', 'const timeout = 0;', '=======', 'const timeout = 5000;', '>>>>>>> REPLACE'].join('\n'), { + defaultFile: 'src/scan.ts', + }); + expect(edits[0]).toMatchObject({ op: 'replace', file: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }); + }); + + it('accepts markdown / File: path lines and *** SEARCH markers', () => { + const edits = parseEdits( + ['File: `src/scan.ts`', '*** SEARCH', 'const timeout = 0;', '=======', 'const timeout = 5000;', '*** REPLACE'].join('\n'), + ); + expect(edits[0]).toMatchObject({ file: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }); + }); + + it('extracts a path from a short prose line before SEARCH', () => { + expect(pathFromLine('I will edit src/scan.ts:')).toBe('src/scan.ts'); + const edits = parseEdits( + ['I will edit src/scan.ts:', '<<<<<<< SEARCH', 'a', '=======', 'b', '>>>>>>> REPLACE'].join('\n'), + ); + expect(edits[0]?.file).toBe('src/scan.ts'); + }); + it('parses multiple replace blocks with distinct files', () => { const text = [ 'src/a.ts', @@ -57,6 +98,29 @@ describe('residualEditsToToolCalls', () => { }, ]); expect(residualEditsToToolCalls(['<<<<<<< SEARCH', 'a', '=======', 'b', '>>>>>>> REPLACE'].join('\n'))).toEqual([]); + expect( + residualEditsToToolCalls(['<<<<<<< SEARCH', 'a', '=======', 'b', '>>>>>>> REPLACE'].join('\n'), { + defaultFile: 'src/scan.ts', + })[0], + ).toMatchObject({ name: 'edit_file', arguments: { path: 'src/scan.ts', search: 'a', replace: 'b' } }); + }); + + it('collectProviderEdits prefers residual text, then native edit_file tool calls', () => { + expect( + collectProviderEdits({ + text: '', + toolCalls: [ + { id: 'c1', name: 'edit_file', arguments: { path: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' } }, + ], + }), + ).toEqual([ + { op: 'replace', file: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;', anchorSymbol: undefined }, + ]); + expect( + editsFromUnifiedDiff( + ['--- a/src/scan.ts', '+++ b/src/scan.ts', '@@ -1,1 +1,1 @@', '-const timeout = 0;', '+const timeout = 5000;'].join('\n'), + )[0], + ).toMatchObject({ file: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }); }); it('lifts CREATE and DELETE', () => { @@ -69,6 +133,98 @@ describe('residualEditsToToolCalls', () => { }); }); +/** Live Flow Review (#2662 Mac tip 14d3baaa0): happy-path-loop printed this class of dump. */ +const LIVE_FLOW_PATCHIR_DUMP = JSON.stringify({ + schemaVersion: 'patch-ir/0', + operations: [ + { + op: 'replace-text', + file: 'src/scan.ts', + search: 'const timeout = 0;', + replace: 'const timeout = 5000;', + }, + ], +}); + +describe('dumpEditsToToolCalls — live Flow JSON / PatchIR residual', () => { + it('is the live miss: looksLikeToolCallDump, but parseTextToolCalls sees no {name,arguments}', () => { + expect(looksLikeToolCallDump(LIVE_FLOW_PATCHIR_DUMP)).toBe(true); + expect(parseTextToolCalls(LIVE_FLOW_PATCHIR_DUMP).calls).toEqual([]); + expect(LIVE_FLOW_PATCHIR_DUMP).not.toMatch(/<{5,}\s*SEARCH/); + }); + + it('lifts a PatchIR operations dump into edit_file', () => { + const calls = dumpEditsToToolCalls(LIVE_FLOW_PATCHIR_DUMP); + expect(calls).toEqual([ + { + id: 'residual_0', + name: 'edit_file', + arguments: { path: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }, + }, + ]); + expect(residualEditsToToolCalls(LIVE_FLOW_PATCHIR_DUMP)).toEqual(calls); + expect(parseEdits(LIVE_FLOW_PATCHIR_DUMP)[0]).toMatchObject({ + op: 'replace', + file: 'src/scan.ts', + search: 'const timeout = 0;', + replace: 'const timeout = 5000;', + }); + }); + + it('lifts {op:REPLACE,path,search,replace} and a path-less dump onto defaultFile', () => { + expect( + dumpEditsToToolCalls( + '{"op":"REPLACE","path":"src/scan.ts","search":"const timeout = 0;","replace":"const timeout = 5000;"}', + )[0], + ).toMatchObject({ + name: 'edit_file', + arguments: { path: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }, + }); + expect( + dumpEditsToToolCalls('{"search":"const timeout = 0;","replace":"const timeout = 5000;"}', { + defaultFile: 'src/scan.ts', + })[0], + ).toMatchObject({ arguments: { path: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' } }); + }); + + it('lifts prose + PatchIR, apply_patch args-root operations, and old_string aliases', () => { + const withProse = `Here is the edit.\n${LIVE_FLOW_PATCHIR_DUMP}\n`; + expect(dumpEditsToToolCalls(withProse)[0]?.arguments).toMatchObject({ + path: 'src/scan.ts', + replace: 'const timeout = 5000;', + }); + const applyDump = JSON.stringify({ + name: 'apply_patch', + arguments: { + schemaVersion: 'patch-ir/0', + operations: [ + { op: 'replace-text', file: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }, + ], + }, + }); + expect(dumpEditsToToolCalls(applyDump)[0]?.name).toBe('edit_file'); + expect( + parseEditDump( + '{"path":"src/scan.ts","old_string":"const timeout = 0;","new_string":"const timeout = 5000;"}', + )[0], + ).toMatchObject({ file: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }); + }); + + it('does not whole-file replace a REPLACED dump that has no SEARCH', () => { + expect(dumpEditsToToolCalls('{"id":"gREET.ts","op":"REPLACED","replacement":"Hi \\\\1!"}')).toEqual([]); + expect(parseEditDump('{"id":"gREET.ts","op":"REPLACED","replacement":"Hi \\\\1!"}')).toEqual([]); + }); + + it('collectProviderEdits lifts a oneshot PatchIR body (Relay no-patch hypothesis)', () => { + expect(collectProviderEdits({ text: LIVE_FLOW_PATCHIR_DUMP, toolCalls: [] })[0]).toMatchObject({ + op: 'replace', + file: 'src/scan.ts', + search: 'const timeout = 0;', + replace: 'const timeout = 5000;', + }); + }); +}); + describe('applyEdit — replace', () => { const file = 'src/a.ts'; it('applies an exact match', () => { diff --git a/src/code/apply.ts b/src/code/apply.ts index 4d2a867..77f04fe 100644 --- a/src/code/apply.ts +++ b/src/code/apply.ts @@ -47,13 +47,23 @@ export interface SymbolSpan { * and fences but strict about the block markers, so a malformed block surfaces * as an `invalid` outcome at apply time rather than silently corrupting a file. */ -export function parseEdits(text: string): CodeEdit[] { +export interface ParseEditsOptions { + /** + * When a SEARCH/REPLACE block has no path (common on live Code Mode replies + * that already saw a cited file), apply the block to this file. + */ + defaultFile?: string; +} + +export function parseEdits(text: string, opts: ParseEditsOptions = {}): CodeEdit[] { const edits: CodeEdit[] = []; const lines = text.split('\n'); let i = 0; let pendingFile = ''; + const fallback = (opts.defaultFile ?? '').trim(); const isFence = (s: string): boolean => /^\s*```/.test(s); + const resolveFile = (inline: string): string => inline || pendingFile || fallback; while (i < lines.length) { const line = lines[i]; @@ -83,10 +93,11 @@ export function parseEdits(text: string): CodeEdit[] { continue; } - // Search/replace block. The file path is the most recent non-fence, - // non-blank line before the SEARCH marker (or an inline `path` on it). - if (/^\s*<{5,}\s*SEARCH/.test(line)) { - const file = pendingFile; + // Search/replace block. Path is (in order): inline on the SEARCH line, + // the most recent path-looking line, or `defaultFile` (cited-file handoff). + const searchMark = searchReplaceMarker(line); + if (searchMark) { + const file = resolveFile(searchMark.file); const search: string[] = []; const replace: string[] = []; i++; @@ -95,11 +106,11 @@ export function parseEdits(text: string): CodeEdit[] { i++; } i++; // consume ======= - while (i < lines.length && !/^\s*>{5,}\s*REPLACE/.test(lines[i])) { + while (i < lines.length && !replaceEndMarker(lines[i])) { replace.push(lines[i]); i++; } - i++; // consume >>>>>>> REPLACE + i++; // consume >>>>>>> REPLACE / *** REPLACE edits.push({ op: 'replace', file, @@ -110,24 +121,164 @@ export function parseEdits(text: string): CodeEdit[] { continue; } - // Track the most recent file-path candidate: a lone token that looks like a - // path (has a slash or a file extension) and isn't a fence/marker. - const trimmed = line.trim(); - if (trimmed && !isFence(line) && looksLikePath(trimmed)) pendingFile = trimmed.replace(/[:`]+$/, ''); + const fromLine = pathFromLine(line); + if (fromLine && !isFence(line)) pendingFile = fromLine; i++; } + if (edits.length === 0) { + edits.push(...editsFromUnifiedDiff(text, fallback)); + } + // Live Flow Review (#2662): the pack printed PatchIR / `{op,path,search, + // replace}` JSON instead of SEARCH/REPLACE. looksLikeToolCallDump already + // flags that; without this fallback the loop dies as no-tools. + if (edits.length === 0) { + edits.push(...parseEditDump(text, opts)); + } + return edits; } +/** `<<<<<<< SEARCH` / `*** SEARCH` / optional inline path. */ +function searchReplaceMarker(line: string): { file: string } | null { + const m = + /^\s*<{5,}\s*SEARCH(?:\s+(\S+))?\s*$/.exec(line) ?? + /^\s*\*{3,}\s*SEARCH(?:\s+(\S+))?\s*$/.exec(line); + if (!m) return null; + return { file: m[1] ? normalizePathCandidate(m[1]) : '' }; +} + +function replaceEndMarker(line: string): boolean { + return /^\s*>{5,}\s*REPLACE/.test(line) || /^\s*\*{3,}\s*REPLACE\b/.test(line); +} + /** - * Lift residual SEARCH/REPLACE (the oneshot edit form) into tool calls. + * Strip markdown / `File:` wrappers so live Code Mode path lines still resolve. + * `**src/scan.ts**`, `` `src/scan.ts` ``, `File: src/scan.ts`. + */ +export function normalizePathCandidate(raw: string): string { + let t = (raw ?? '').trim(); + t = t.replace(/^File:\s*/i, '').replace(/^path:\s*/i, ''); + t = t.replace(/^[*`_#>\-\s]+/, '').replace(/[*`_]+$/g, ''); + t = t.replace(/[:`]+$/, ''); + return t.trim(); +} + +/** A path on its own line, or the first path-shaped token in a short prose line. */ +export function pathFromLine(line: string): string { + if (!line || /^\s*```/.test(line)) return ''; + const normalized = normalizePathCandidate(line); + if (looksLikePath(normalized)) return normalized; + const embedded = line.match( + /(?:^|[\s`'"])((?:[\w.-]+\/)+[\w.-]+\.[A-Za-z][\w.-]*)/, + ); + return embedded && looksLikePath(embedded[1]) ? embedded[1] : ''; +} + +/** + * Unified-diff residual (`--- a/file` / `+++ b/file` plus `-`/`+` lines). + * Live hosted and local packs emit this when they skip SEARCH/REPLACE markers. + */ +export function editsFromUnifiedDiff(text: string, defaultFile = ''): CodeEdit[] { + const edits: CodeEdit[] = []; + let file = ''; + let oldPath: string | null = null; + const search: string[] = []; + const replace: string[] = []; + const flush = (): void => { + const target = file || defaultFile; + if (!target || (search.length === 0 && replace.length === 0)) { + search.length = 0; + replace.length = 0; + return; + } + if (search.join('\n') === replace.join('\n')) { + search.length = 0; + replace.length = 0; + return; + } + edits.push({ + op: 'replace', + file: target, + search: search.join('\n'), + replace: replace.join('\n'), + anchorSymbol: undefined, + }); + search.length = 0; + replace.length = 0; + }; + for (const raw of text.split('\n')) { + const line = raw.replace(/\r$/, ''); + if (line.startsWith('--- ')) { + flush(); + const p = line.slice(4).trim(); + oldPath = p === '/dev/null' ? null : p.replace(/^a\//, ''); + file = ''; + continue; + } + if (line.startsWith('+++ ')) { + const p = line.slice(4).trim(); + file = p === '/dev/null' ? oldPath ?? '' : p.replace(/^b\//, ''); + continue; + } + if (line.startsWith('@@')) continue; + if (line.startsWith('+') && !line.startsWith('+++')) replace.push(line.slice(1)); + else if (line.startsWith('-') && !line.startsWith('---')) search.push(line.slice(1)); + else if (line.startsWith(' ') || line === '') { + const body = line.startsWith(' ') ? line.slice(1) : line; + search.push(body); + replace.push(body); + } + } + flush(); + return edits; +} + +/** + * Lift residual SEARCH/REPLACE (the oneshot edit form) or a printed + * PatchIR / `{path,search,replace}` JSON dump into tool calls. * Local Code Modes often emit this instead of `` markup. The * agent loop applies these as `edit_file` / `create_file` / `delete_file` - * so a text-protocol backend still drives the loop. Skips edits with no path. + * so a text-protocol backend still drives the loop. Skips edits with no path + * unless {@link ParseEditsOptions.defaultFile} is set. */ -export function residualEditsToToolCalls(text: string): ToolCall[] { - const edits = parseEdits(text); +export function residualEditsToToolCalls(text: string, opts: ParseEditsOptions = {}): ToolCall[] { + const fromMarkers = editsToToolCalls(parseEdits(text, opts)); + if (fromMarkers.length) return fromMarkers; + return dumpEditsToToolCalls(text, opts); +} + +/** Native / rescued `edit_file` (and kin) → the same {@link CodeEdit} list `parseEdits` yields. */ +export function toolCallsToEdits(calls: ToolCall[]): CodeEdit[] { + const edits: CodeEdit[] = []; + for (const call of calls) { + const name = String(call.name ?? '') + .trim() + .replace(/[\s-]+/g, '_') + .replace(/_+/g, '_'); + const args = call.arguments ?? {}; + const file = String(args.path ?? args.file ?? '').trim(); + if (name === 'edit_file' || name === 'replace_in_file') { + if (!file) continue; + edits.push({ + op: 'replace', + file, + search: String(args.search ?? ''), + replace: String(args.replace ?? ''), + anchorSymbol: typeof args.anchorSymbol === 'string' ? args.anchorSymbol : undefined, + }); + } else if (name === 'create_file') { + if (!file) continue; + edits.push({ op: 'create', file, content: String(args.content ?? '') }); + } else if (name === 'delete_file') { + if (!file) continue; + edits.push({ op: 'delete', file }); + } + } + return edits; +} + +function editsToToolCalls(edits: CodeEdit[]): ToolCall[] { const calls: ToolCall[] = []; for (const [i, edit] of edits.entries()) { if (!edit.file.trim()) continue; @@ -154,6 +305,192 @@ export function residualEditsToToolCalls(text: string): ToolCall[] { return calls; } +/** + * Lift a printed tool / PatchIR / `{path,search,replace}` JSON dump into + * tool calls. Live Code Mode Review (#2662) prints this instead of + * `` or SEARCH/REPLACE; `looksLikeToolCallDump` already flags it, + * but without this lift the loop dies as no-tools after the dump retries. + */ +export function dumpEditsToToolCalls(text: string, opts: ParseEditsOptions = {}): ToolCall[] { + return editsToToolCalls(parseEditDump(text, opts)); +} + +/** JSON / PatchIR / named-tool dumps → the same {@link CodeEdit} list as SEARCH/REPLACE. */ +export function parseEditDump(text: string, opts: ParseEditsOptions = {}): CodeEdit[] { + const edits: CodeEdit[] = []; + for (const value of extractJsonValues(text)) { + edits.push(...editsFromDumpValue(value, opts.defaultFile ?? '')); + } + return edits; +} + +function extractJsonValues(text: string): unknown[] { + const values: unknown[] = []; + const seen = new Set(); + const push = (raw: string): void => { + const parsed = parseLooseJson(raw); + if (parsed === null || typeof parsed !== 'object') return; + const key = JSON.stringify(parsed); + if (seen.has(key)) return; + seen.add(key); + values.push(parsed); + }; + const trimmed = (text ?? '').trim(); + if (trimmed) push(stripFence(trimmed)); + for (const m of (text ?? '').matchAll(/```(?:json|tool_call|tool)?\s*\n?([\s\S]*?)```/g)) { + push(m[1] ?? ''); + } + if (values.length === 0) { + for (const blob of balancedJsonObjects(text ?? '')) push(blob); + } + return values; +} + +function stripFence(text: string): string { + return text.replace(/^```(?:json|tool_call|tool)?\s*/i, '').replace(/```\s*$/i, '').trim(); +} + +/** Strict JSON first; then quote unquoted keys / identifier values (Flow dumps). */ +export function parseLooseJson(raw: string): unknown | null { + const t = stripFence(raw).trim(); + if (!t) return null; + try { + return JSON.parse(t); + } catch { + /* continue */ + } + const quotedKeys = t.replace(/([{,]\s*)([A-Za-z_][\w]*)\s*:/g, '$1"$2":'); + try { + return JSON.parse(quotedKeys); + } catch { + /* continue */ + } + const quotedVals = quotedKeys.replace(/:\s*([A-Za-z_][\w.-]*)\s*([,}])/g, ':"$1"$2'); + try { + return JSON.parse(quotedVals); + } catch { + return null; + } +} + +function balancedJsonObjects(text: string): string[] { + const out: string[] = []; + let start = -1; + let depth = 0; + let inStr = false; + let esc = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (inStr) { + if (esc) esc = false; + else if (ch === '\\') esc = true; + else if (ch === '"') inStr = false; + continue; + } + if (ch === '"') { + inStr = true; + continue; + } + if (ch === '{') { + if (depth === 0) start = i; + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0 && start >= 0) { + out.push(text.slice(start, i + 1)); + start = -1; + } + } + } + return out; +} + +function dumpTrimKeys(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k.trim()] = v && typeof v === 'object' && !Array.isArray(v) ? dumpTrimKeys(v) : v; + } + return out; +} + +function dumpString(o: Record, keys: string[]): string { + for (const k of keys) { + const v = o[k]; + if (typeof v === 'string') return v; + } + return ''; +} + +function unwrapDumpObject(o: Record): Record { + for (const key of ['patch', 'tool_call', 'function', 'edit', 'data', 'payload'] as const) { + const inner = o[key]; + if (inner && typeof inner === 'object' && !Array.isArray(inner)) { + return dumpTrimKeys(inner) as Record; + } + } + if (Array.isArray(o.tool_calls) && o.tool_calls.length === 1) { + const first = o.tool_calls[0]; + if (first && typeof first === 'object' && !Array.isArray(first)) { + return dumpTrimKeys(first) as Record; + } + } + return o; +} + +function editsFromDumpValue(value: unknown, defaultFile: string): CodeEdit[] { + if (Array.isArray(value)) { + return value.flatMap((v) => editsFromDumpValue(v, defaultFile)); + } + if (!value || typeof value !== 'object') return []; + let o = dumpTrimKeys(value) as Record; + o = unwrapDumpObject(o); + if (Array.isArray(o.operations)) return editsFromDumpValue(o.operations, defaultFile); + const nested = o.arguments ?? o.parameters ?? o.input; + if (nested && typeof nested === 'object' && !Array.isArray(nested)) { + const inner = dumpTrimKeys(nested) as Record; + if (typeof o.name === 'string' && inner.name === undefined) inner.name = o.name; + if (typeof o.tool === 'string' && inner.name === undefined) inner.name = o.tool; + if (typeof o.op === 'string' && inner.op === undefined) inner.op = o.op; + return editsFromDumpValue(inner, defaultFile); + } + if (typeof nested === 'string' && nested.trim().startsWith('{')) { + const parsed = parseLooseJson(nested); + if (parsed && typeof parsed === 'object') return editsFromDumpValue(parsed, defaultFile); + } + const file = dumpString(o, ['path', 'file', 'id']) || (defaultFile ?? '').trim(); + const op = dumpString(o, ['op', 'name', 'tool']).toLowerCase().replace(/[\s-]+/g, '_'); + const search = dumpString(o, ['search', 'old_string', 'oldString', 'old_text', 'oldText', 'old']); + const replace = dumpString(o, ['replace', 'replacement', 'new_string', 'newString', 'new_text', 'newText']); + const content = dumpString(o, ['content']); + if ((op === 'create_file' || op === 'create') && file && content) { + return [{ op: 'create', file, content }]; + } + if ((op === 'delete_file' || op === 'delete') && file) { + return [{ op: 'delete', file }]; + } + if (file && search && replace && search !== replace) { + return [{ op: 'replace', file, search, replace, anchorSymbol: typeof o.anchorSymbol === 'string' ? o.anchorSymbol : undefined }]; + } + return []; +} + +/** + * One-shot assess path: residual text, then JSON/PatchIR dumps, then native + * tool calls. Hosted Review often returns `edit_file` on the function-calling + * wire with an empty body — that must still become a patch. + */ +export function collectProviderEdits( + result: { text?: string; toolCalls?: ToolCall[] }, + opts: ParseEditsOptions = {}, +): CodeEdit[] { + const fromText = parseEdits(result.text ?? '', opts); + if (fromText.some((e) => e.file.trim())) return fromText.filter((e) => e.file.trim()); + const fromDump = parseEditDump(result.text ?? '', opts); + if (fromDump.some((e) => e.file.trim())) return fromDump.filter((e) => e.file.trim()); + return toolCallsToEdits(result.toolCalls ?? []); +} + function looksLikePath(s: string): boolean { if (/\s/.test(s.replace(/:$/, ''))) return false; // paths don't contain spaces return /\//.test(s) || /\.[A-Za-z0-9]{1,8}:?$/.test(s); diff --git a/src/code/loop-gate.test.ts b/src/code/loop-gate.test.ts index 3fa29dd..0a734e6 100644 --- a/src/code/loop-gate.test.ts +++ b/src/code/loop-gate.test.ts @@ -70,6 +70,7 @@ function textBackend( describe('loop-gate gold — edit-ask heuristic', () => { it('flags edit/change/fix/replace and so-returns, not locate Q&A', () => { expect(instructionRequiresMutation('edit src/greet.ts so greet returns Hello, !')).toBe(true); + expect(instructionRequiresMutation('Propose a minimal patch for this Review finding.')).toBe(true); expect(instructionRequiresMutation('Call edit_file on the cited path, then finish.')).toBe(true); expect(instructionRequiresMutation('edit the greeting')).toBe(true); expect(instructionRequiresMutation('fix the timeout in src/scan.ts')).toBe(true); @@ -131,6 +132,174 @@ describe('loop-gate gold — Code Mode tool channel', () => { expect(result.steps).toBeLessThanOrEqual(AGENT_EMPTY_REPLY_RETRIES + 1); }); + it('lifts a path-less SEARCH/REPLACE onto the single cited file (live llama-cpp shape)', async () => { + // Live Flow (#2662 tip): the pack printed markers without a path line + // because the Review finding already cited the file. Without defaultFile + // that residual was skipped and happy-path-loop died as no-tools after 3. + const residual = ['<<<<<<< SEARCH', 'const timeout = 0;', '=======', 'const timeout = 5000;', '>>>>>>> REPLACE'].join('\n'); + const backend = textBackend({ text: residual, model: 'flow-pack', provider: 'llama-cpp' }, false); + const fsImpl = memFs({ 'src/scan.ts': 'export function scanDir() {\n const timeout = 0;\n return timeout;\n}\n' }); + const result = await runAgent({ + graph: fixtureGraph(), + root: '/repo', + instruction: 'Propose a minimal patch for this Review finding. Call edit_file on src/scan.ts, then finish.', + files: ['src/scan.ts'], + providers: [withToolCallFallback(backend)], + fsImpl, + run: () => ({ stdout: '', exitCode: 0 }), + approve: async () => true, + maxSteps: AGENT_NO_PROGRESS_STOP_AT, + noAudit: true, + }); + expect(result.stopped).toBe('finished'); + expect(result.stopped).not.toBe('no-tools'); + expect(result.changes).toHaveLength(1); + expect(fsImpl.files['src/scan.ts']).toContain('5000'); + expect(result.steps).toBeLessThanOrEqual(AGENT_NO_PROGRESS_STOP_AT); + }); + + it('lifts a live Flow PatchIR dump on an edit-ask (not no-tools)', async () => { + // Live Flow review-gold (#2662, PR tip 14d3baaa0): happy-path-loop died + // stopReason no-tools at steps=4 — the pack printed PatchIR JSON that + // looksLikeToolCallDump flags, but parseTextToolCalls only accepts + // {name, arguments} and residual SEARCH/REPLACE was empty. + const dump = JSON.stringify({ + schemaVersion: 'patch-ir/0', + operations: [ + { op: 'replace-text', file: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }, + ], + }); + const backend = textBackend({ text: dump, model: 'flow', provider: 'llama-cpp' }, false); + const fsImpl = memFs({ 'src/scan.ts': 'export function scanDir() {\n const timeout = 0;\n return timeout;\n}\n' }); + const events: Array<{ type: string; name?: string }> = []; + const result = await runAgent({ + graph: fixtureGraph(), + root: '/repo', + instruction: 'Propose a minimal patch for this Review finding. Call edit_file on src/scan.ts, then finish.', + files: ['src/scan.ts'], + providers: [withToolCallFallback(backend)], + fsImpl, + run: () => ({ stdout: '', exitCode: 0 }), + approve: async () => true, + maxSteps: AGENT_NO_PROGRESS_STOP_AT, + noAudit: true, + onEvent: (e) => events.push(e), + }); + expect(result.stopped).toBe('finished'); + expect(result.stopped).not.toBe('no-tools'); + expect(result.changes).toHaveLength(1); + expect(fsImpl.files['src/scan.ts']).toContain('5000'); + expect(events.filter((e) => e.type === 'tool-call').map((e) => e.name)).toContain('edit_file'); + expect(result.steps).toBeLessThanOrEqual(AGENT_NO_PROGRESS_STOP_AT); + }); + + it('lifts a PatchIR dump after a read (live steps=4 shape)', async () => { + const dump = JSON.stringify({ + op: 'REPLACE', + path: 'src/scan.ts', + search: 'const timeout = 0;', + replace: 'const timeout = 5000;', + }); + const provider = new ScriptedProvider('flow', [ + { toolCalls: [tc('read_file', { path: 'src/scan.ts' }, 'r1')] }, + { text: dump }, + ]); + const fsImpl = memFs({ 'src/scan.ts': 'export function scanDir() {\n const timeout = 0;\n return timeout;\n}\n' }); + const result = await runAgent({ + graph: fixtureGraph(), + root: '/repo', + instruction: 'Propose a minimal patch for this Review finding. Call edit_file on src/scan.ts, then finish.', + files: ['src/scan.ts'], + providers: [withToolCallFallback(provider)], + fsImpl, + run: () => ({ stdout: '', exitCode: 0 }), + approve: async () => true, + maxSteps: AGENT_NO_PROGRESS_STOP_AT, + noAudit: true, + }); + expect(result.stopped).toBe('finished'); + expect(result.stopped).not.toBe('no-tools'); + expect(fsImpl.files['src/scan.ts']).toContain('5000'); + expect(result.steps).toBeLessThanOrEqual(AGENT_NO_PROGRESS_STOP_AT); + }); + + it('apply_patch text dump with operations at args root writes (not no-tools)', async () => { + // Live Flow often prints apply_patch as {name, arguments:{operations}} + // without wrapping operations in `patch`. parseTextToolCalls rescues the + // call; apply_patch must accept the args-root shape or the loop burns + // a step then dies as no-tools at steps=4. + const dump = JSON.stringify({ + name: 'apply_patch', + arguments: { + schemaVersion: 'patch-ir/0', + operations: [ + { op: 'replace-text', file: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }, + ], + }, + }); + const provider = new ScriptedProvider('flow', [ + { text: dump }, + { toolCalls: [tc('finish', { summary: 'raised the timeout' }, 'f1')] }, + ]); + const fsImpl = memFs({ 'src/scan.ts': 'export function scanDir() {\n const timeout = 0;\n return timeout;\n}\n' }); + const result = await runAgent({ + graph: fixtureGraph(), + root: '/repo', + instruction: 'Propose a minimal patch for this Review finding. Call edit_file on src/scan.ts, then finish.', + files: ['src/scan.ts'], + providers: [withToolCallFallback(provider)], + fsImpl, + run: () => ({ stdout: '', exitCode: 0 }), + approve: async () => true, + maxSteps: AGENT_NO_PROGRESS_STOP_AT, + noAudit: true, + }); + expect(result.stopped).toBe('finished'); + expect(result.stopped).not.toBe('no-tools'); + expect(fsImpl.files['src/scan.ts']).toContain('5000'); + expect(result.steps).toBeLessThanOrEqual(AGENT_NO_PROGRESS_STOP_AT); + }); + + it('captures the raw dump in finalText when a dump cannot be lifted', async () => { + const dump = '{"id":"gREET.ts","op":"REPLACED","replacement":"Hi \\\\1!"}'; + const backend = textBackend({ text: dump, model: 'flow', provider: 'llama-cpp' }, false); + const result = await runAgent({ + graph: fixtureGraph(), + root: '/repo', + instruction: 'edit src/greet.ts so greet returns Hello, !', + providers: [withToolCallFallback(backend)], + fsImpl: memFs({ 'src/greet.ts': 'export function greet(name: string) { return `hi ${name}`; }\n' }), + run: () => ({ stdout: '', exitCode: 0 }), + approve: async () => true, + maxSteps: AGENT_NO_PROGRESS_STOP_AT, + noAudit: true, + }); + expect(result.stopped).toBe('no-tools'); + expect(result.changes).toHaveLength(0); + expect(result.finalText).toMatch(/printed a tool or edit dump/i); + expect(result.finalText).toContain('Last model text:'); + expect(result.finalText).toContain('"op":"REPLACED"'); + }); + + it('lifts a unified-diff residual on an edit-ask', async () => { + const residual = ['--- a/src/scan.ts', '+++ b/src/scan.ts', '@@ -1 +1 @@', '-const timeout = 0;', '+const timeout = 5000;'].join('\n'); + const backend = textBackend({ text: residual, model: 'flow-pack', provider: 'llama-cpp' }, false); + const fsImpl = memFs({ 'src/scan.ts': 'const timeout = 0;\n' }); + const result = await runAgent({ + graph: fixtureGraph(), + root: '/repo', + instruction: 'edit src/scan.ts so the timeout is 5000', + providers: [withToolCallFallback(backend)], + fsImpl, + run: () => ({ stdout: '', exitCode: 0 }), + approve: async () => true, + maxSteps: AGENT_NO_PROGRESS_STOP_AT, + noAudit: true, + }); + expect(result.stopped).toBe('finished'); + expect(fsImpl.files['src/scan.ts']).toContain('5000'); + }); + it('does not end as silent no-tools when a Code Mode emits text-protocol markup', async () => { const backend = textBackend( { @@ -507,7 +676,8 @@ describe('loop-gate gold — Code Mode tool channel', () => { expect(result.stopped).toBe('no-tools'); expect(result.changes).toHaveLength(0); expect(fsImpl.files['src/greet.ts']).toBe(greet); - expect(result.finalText).not.toMatch(/"op"\s*:\s*"REPLACED"/); + expect(result.finalText).toMatch(/printed a tool or edit dump/i); + expect(result.finalText).toContain('Last model text:'); }); it('a spaced-name dump then empty reply is not a successful finish', async () => { diff --git a/src/code/session.test.ts b/src/code/session.test.ts index 48852f7..3073d62 100644 --- a/src/code/session.test.ts +++ b/src/code/session.test.ts @@ -95,6 +95,74 @@ describe('runCodeSession — governance lifecycle', () => { expect(r.provider.fellBack).toBe(true); }); + it('one-shot PatchIR JSON dump still produces a dry-run patch', async () => { + const dump = JSON.stringify({ + schemaVersion: 'patch-ir/0', + operations: [ + { op: 'replace-text', file: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }, + ], + }); + const hosted: Provider = { + id: 'vibgrate-relay', + label: 'Vibgrate Relay', + local: false, + model: 'hosted-coder', + async chat() { + return { text: dump, model: 'hosted-coder', provider: 'vibgrate-relay' }; + }, + }; + const fsImpl = memFs({ 'src/scan.ts': 'const timeout = 0;\n' }); + const r = await runCodeSession({ + graph: fixtureGraph(), + root: '/repo', + instruction: 'raise the timeout', + providers: [hosted], + fsImpl, + files: ['src/scan.ts'], + }); + expect(r.applied).toBe(false); + expect(r.changes[0]?.diff).toContain('+const timeout = 5000;'); + expect(fsImpl.files['src/scan.ts']).toBe('const timeout = 0;\n'); + }); + + it('one-shot native edit_file tool calls still produce a dry-run patch', async () => { + // Hosted Review oneshot (#2662 Mac): Relay returned tool_calls and no + // SEARCH/REPLACE body — parseEdits on text alone was no-patch. Lift the + // native call so oneshot matches the loop path. + const hosted: Provider = { + id: 'vibgrate-relay', + label: 'Vibgrate Relay', + local: false, + model: 'hosted-coder', + async chat() { + return { + text: '', + model: 'hosted-coder', + provider: 'vibgrate-relay', + toolCalls: [ + { + id: 'call_1', + name: 'edit_file', + arguments: { path: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }, + }, + ], + }; + }, + }; + const fsImpl = memFs({ 'src/scan.ts': 'const timeout = 0;\n' }); + const r = await runCodeSession({ + graph: fixtureGraph(), + root: '/repo', + instruction: 'raise the timeout', + providers: [hosted], + fsImpl, + files: ['src/scan.ts'], + }); + expect(r.applied).toBe(false); + expect(r.changes[0]?.diff).toContain('+const timeout = 5000;'); + expect(fsImpl.files['src/scan.ts']).toBe('const timeout = 0;\n'); + }); + it('does not write when the model returns no applicable edit', async () => { const fsImpl = memFs({ 'src/scan.ts': 'const timeout = 0;\n' }); const r = await runCodeSession({ graph: fixtureGraph(), root: '/repo', instruction: 'x', providers: [mock('no edits here')], apply: true, consent: true, fsImpl }); diff --git a/src/code/session.ts b/src/code/session.ts index 1030474..977d502 100644 --- a/src/code/session.ts +++ b/src/code/session.ts @@ -31,7 +31,7 @@ import { rankingAskFrom } from '../engine/user-ask.js'; import { buildTaskCapsule, capsuleToCodeContext } from './capsule.js'; import { recordCliCall, CLI_TOOL_ALIASES } from '../engine/savings.js'; import { repositoryIdFromRoot } from '../runtime/paths.js'; -import { parseEdits, applyEdits, type SymbolSpan } from './apply.js'; +import { collectProviderEdits, applyEdits, type SymbolSpan } from './apply.js'; import { unifiedDiff } from './diff.js'; import { buildMessages } from './prompt.js'; import { redactSecrets } from './providers.js'; @@ -147,7 +147,9 @@ export async function runCodeSession(options: RunSessionOptions): Promise { expect(looksLikeToolCallDump('{"op":"REPLACE","path":"src/greet.ts","search":"a","replace":"b"}')).toBe(true); expect(looksLikeToolCallDump('```json\n{"foo": 1}\n```')).toBe(false); }); + + it('does not parse a live Flow PatchIR dump as {name, arguments}', () => { + const dump = + '{"schemaVersion":"patch-ir/0","operations":[{"op":"replace-text","file":"src/scan.ts","search":"a","replace":"b"}]}'; + expect(looksLikeToolCallDump(dump)).toBe(true); + expect(parseTextToolCalls(dump).calls).toEqual([]); + }); }); describe('textToolProtocolInstruction', () => { diff --git a/src/code/tools.test.ts b/src/code/tools.test.ts index 7d64523..4a59761 100644 --- a/src/code/tools.test.ts +++ b/src/code/tools.test.ts @@ -526,6 +526,35 @@ describe('mutating tools (gated)', () => { expect(c.files['src/scan.ts']).toContain('timeout = 0'); }); + it('apply_patch accepts operations at the args root (live Flow dump)', async () => { + const c = ctx(); + const r = await executeTool( + call('apply_patch', { + schemaVersion: 'patch-ir/0', + operations: [ + { op: 'replace-text', file: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 9;' }, + ], + }), + c, + ); + expect(r.mutated).toBe(true); + expect(c.files['src/scan.ts']).toContain('timeout = 9'); + }); + + it('edit_file accepts old_string / new_string aliases', async () => { + const c = ctx(); + const r = await executeTool( + call('edit_file', { + file: 'src/scan.ts', + old_string: 'const timeout = 0;', + new_string: 'const timeout = 7;', + }), + c, + ); + expect(r.mutated).toBe(true); + expect(c.files['src/scan.ts']).toContain('timeout = 7'); + }); + it('apply_patch applies a multi-op PatchIR when approved', async () => { const c = ctx(); const r = await executeTool( diff --git a/src/code/tools.ts b/src/code/tools.ts index bb84cb2..2b05d76 100644 --- a/src/code/tools.ts +++ b/src/code/tools.ts @@ -562,13 +562,20 @@ export async function executeTool(call: ToolCall, ctx: ToolContext): Promise', 'a finding id from the last `vg review` run (e.g. arch-01)') + .argument('', 'a finding id from the current change (e.g. blast:, arch::)') .option('--base ', 'review HEAD against the merge-base with ') .action(async function (this: Command, findingId: string, opts: { base?: string }) { const global = readGlobal(this); @@ -270,7 +276,7 @@ export function registerReview(program: Command): void { const findingsFromDiff = cmd .command('findings-from-diff') .description( - 'emit deterministic Review findings from the change set and the code graph (blast radius + architecture/security scanners; no hosted model)', + 'emit deterministic vg.review.findings.v1 rows from the change set and the code graph (correctness blast-radius + architecture/security scanners; no hosted model; writes .vibgrate/review-propose-handoff.json for vg review propose)', ) .option('--base ', 'review HEAD against the merge-base with (e.g. origin/main)') .option( @@ -278,7 +284,11 @@ export function registerReview(program: Command): void { 'include the working tree when --base is also set (the default without --base already is in-place)', ) .option('--diff ', 'unified diff to treat as the change set (`-` reads stdin)') - .option('--format ', 'output format (text | json)', 'text') + .option( + '--format ', + 'output format (text | json). json writes the findings document plus a publishable correctness array', + 'text', + ) .action(async function ( this: Command, opts: { base?: string; inPlace?: boolean; diff?: string; format: string }, @@ -315,6 +325,12 @@ export function registerReview(program: Command): void { diffText, }); const findings = reviewed.receipt.findings; + writeProposeHandoff(root, { + schema_version: 'vg.review.propose-handoff.v1', + findings, + capsule: reviewed.capsule, + change: { base: opts.base, inPlace: opts.inPlace, hasDiff: Boolean(opts.diff) }, + }); if (global.json || opts.format === 'json') { // Findings document plus App-ingestible correctness rows. The App // parses this JSON; it must not import the public CLI. @@ -331,7 +347,7 @@ export function registerReview(program: Command): void { return; } if (!global.quiet) { - info(formatFindingsFromDiff(reviewed)); + info(formatFindingsFromDiff(reviewed, opts.base)); } }); applyGlobalOptions(findingsFromDiff); @@ -377,10 +393,22 @@ export function registerReview(program: Command): void { const propose = cmd .command('propose') .description( - `propose a PatchIR fix for one finding via the VG Code agent loop (dry-run; never writes the default branch)`, + 'propose a PatchIR fix for one finding via the VG Code agent loop (dry-run; --apply --yes writes a topic branch only, never the default branch)', + ) + .argument( + '', + 'a finding id from the current change, --findings JSON, or the last-run .vibgrate/review-propose-handoff.json (e.g. blast:, arch::)', + ) + .option('--base ', 'review HEAD against the merge-base with (same as findings-from-diff --base)') + .option( + '--in-place', + 'include the working tree when --base is also set (same as findings-from-diff --in-place)', + ) + .option('--diff ', 'unified diff to treat as the change set (`-` reads stdin; same as findings-from-diff --diff)') + .option( + '--findings ', + 'findings JSON from `vg review findings-from-diff --format json` (or a review receipt) when the current change set does not list the id; else last-run .vibgrate/review-propose-handoff.json', ) - .argument('', 'a finding id from the current change (e.g. blast:, arch::)') - .option('--base ', 'review HEAD against the merge-base with ') .option('--model ', 'relay: (hosted Review) or spark|flow|forge (local Code Mode); a bare slug is invalid') .option('--loop', `use the VG Code agent loop (cap ${REVIEW_PROPOSE_LOOP_CAP}; stops on no progress)`, true) .option('--single', 'one-shot residual → patch → verify instead of the agent loop') @@ -389,7 +417,17 @@ export function registerReview(program: Command): void { .action(async function ( this: Command, findingId: string, - opts: { base?: string; model?: string; loop?: boolean; single?: boolean; apply?: boolean; yes?: boolean }, + opts: { + base?: string; + inPlace?: boolean; + diff?: string; + findings?: string; + model?: string; + loop?: boolean; + single?: boolean; + apply?: boolean; + yes?: boolean; + }, ) { const global = readGlobal(this); const root = rootOf(global); @@ -407,25 +445,54 @@ export function registerReview(program: Command): void { }), Boolean(global.quiet) || Boolean(global.json), ); + let change: ChangeSet | undefined; + let diffText: string | undefined; + if (opts.diff) { + diffText = readDiffFile(opts.diff, root); + change = changeSetFromUnifiedDiff(collectChangeSet(root, opts.base, defaultRun, { inPlace: opts.inPlace }), diffText); + } const reviewed = await runReview({ root, base: opts.base, + inPlace: opts.inPlace, offline: global.offline, graphPath: global.graph, generatedAt: global.generatedAt, signingKey: null, + change, + diffText, }); - const all = [ - ...reviewed.receipt.findings.architecture_findings, - ...reviewed.receipt.findings.security_findings, - ]; - const hit = all.find((f) => f.id === findingId); - if (!hit) { - throw new CliError( - `no finding "${findingId}" in this change set — run \`vg review\` to list the current findings`, - ExitCode.NOT_FOUND, - ); + let findingsRaw: unknown; + if (opts.findings) { + const abs = path.resolve(root, opts.findings); + if (!fs.existsSync(abs)) { + throw new CliError( + `no findings JSON at ${opts.findings} — write one with \`vg review findings-from-diff --format json\``, + ExitCode.NOT_FOUND, + ); + } + try { + findingsRaw = JSON.parse(fs.readFileSync(abs, 'utf8')); + } catch { + throw new CliError( + `${opts.findings} is not valid JSON — expected a findings-from-diff document or a vg.review.receipt.v1`, + ExitCode.USAGE_ERROR, + ); + } } + const handoff = readProposeHandoff(root); + const resolved = resolveProposeFinding({ + findingId, + reviewedFindings: reviewed.receipt.findings, + reviewedCapsule: reviewed.capsule, + findingsFile: findingsRaw, + handoff, + }); + if (!resolved) { + throw new CliError(missingProposeFindingMessage(findingId, handoff), ExitCode.NOT_FOUND); + } + const hit = resolved.finding; + const capsule = resolved.capsule; const graph = loadGraph(root, global.graph); if (!graph) { throw new CliError( @@ -434,13 +501,13 @@ export function registerReview(program: Command): void { ); } const policySnippet = [ - ...reviewed.capsule.policies.map((p) => `${p.id}: ${p.rule}`), + ...capsule.policies.map((p) => `${p.id}: ${p.rule}`), hit.remediation, ] .filter(Boolean) .join('\n'); const result = await proposeFindingFix({ - capsule: reviewed.capsule, + capsule, finding: hit, policySnippet, modelId: opts.model, @@ -658,7 +725,7 @@ function readDiffFile(spec: string, root: string): string { * Human listing of the findings document. Points at `vg review propose` * so a PatchIR dry-run does not need a second loop. */ -function formatFindingsFromDiff(result: RunReviewResult): string { +function formatFindingsFromDiff(result: RunReviewResult, base?: string): string { const all = [ ...result.receipt.findings.architecture_findings, ...result.receipt.findings.security_findings, @@ -695,6 +762,7 @@ function formatFindingsFromDiff(result: RunReviewResult): string { lines.push( c.dim( ` propose a PatchIR dry-run: vg review propose --model forge --json` + + (base ? ` --base ${base}` : '') + (result.receipt.receipt_id ? ` · receipt ${result.receipt.receipt_id}` : ''), ), ); diff --git a/src/engine/chart/arch-types.ts b/src/engine/chart/arch-types.ts index 49bed3b..cdcbed7 100644 --- a/src/engine/chart/arch-types.ts +++ b/src/engine/chart/arch-types.ts @@ -199,6 +199,12 @@ export interface ArchCardVuln { tier: 'reachable' | 'potentially_reachable'; /** One-line human-readable evidence, e.g. "imported in src/api.ts, called at line 42". */ evidence?: string; + /** Workspace-relative file of the matching reach site, when the scan supplied one. */ + file?: string; + /** 1-based use line of the vulnerable symbol / enclosing function. */ + line?: number; + /** Enclosing function at the reach site, when the graph named it. */ + function?: string; } export interface ArchSliceColumn { diff --git a/src/engine/chart/vuln-annotations.test.ts b/src/engine/chart/vuln-annotations.test.ts index a8bac95..fdce6df 100644 --- a/src/engine/chart/vuln-annotations.test.ts +++ b/src/engine/chart/vuln-annotations.test.ts @@ -4,7 +4,7 @@ import * as path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import type { ScanReachabilityFinding } from '../../core-open/index.js'; import type { ArchSlice } from './arch-types.js'; -import { loadReachabilityFindings, withVulnBadges } from './vuln-annotations.js'; +import { loadReachabilityFindings, pickCardVulnSite, withVulnBadges } from './vuln-annotations.js'; let dir: string | undefined; @@ -97,6 +97,31 @@ describe('withVulnBadges', () => { expect(card?.vulnerabilities?.[0]).toMatchObject({ advisoryId: 'GHSA-xxxx', tier: 'reachable', package: 'left-pad' }); }); + it('stamps the use-line site onto the card so inspect can open it', () => { + const out = withVulnBadges(baseSlice(), [finding()]); + expect(out.columns[0]?.cards[0]?.vulnerabilities?.[0]).toMatchObject({ + file: 'src/api/Billing.ts', + line: 12, + function: 'charge', + evidence: 'imported in src/api/Billing.ts, called at line 12', + }); + }); + + it('prefers the site whose enclosing function matches the card', () => { + const out = withVulnBadges(baseSlice(), [ + finding({ + sites: [ + { file: 'src/api/Billing.ts', line: 3, function: 'other' }, + { file: 'src/api/Billing.ts', line: 42, function: 'BillingService' }, + ], + }), + ]); + expect(out.columns[0]?.cards[0]?.vulnerabilities?.[0]).toMatchObject({ + line: 42, + function: 'BillingService', + }); + }); + it('is a no-op when no site matches a file in this slice', () => { const out = withVulnBadges(baseSlice(), [finding({ sites: [{ file: 'unrelated/File.ts' }] })]); expect(out.columns[0]?.cards[0]?.vulnerabilities).toBeUndefined(); @@ -121,3 +146,11 @@ describe('withVulnBadges', () => { expect(vulns?.map((v) => v.advisoryId)).toEqual(['GHSA-first', 'GHSA-later']); }); }); + +describe('pickCardVulnSite', () => { + it('returns null when no site file matches', () => { + expect( + pickCardVulnSite(finding({ sites: [{ file: 'other.ts', line: 1 }] }), new Set(['src/api/Billing.ts']), new Set()), + ).toBeNull(); + }); +}); diff --git a/src/engine/chart/vuln-annotations.ts b/src/engine/chart/vuln-annotations.ts index a300356..4e6c27c 100644 --- a/src/engine/chart/vuln-annotations.ts +++ b/src/engine/chart/vuln-annotations.ts @@ -14,11 +14,15 @@ * Matching is file-based, same precision tradeoff as `external-lane.ts`: * `ReachabilitySite.file` (+ its enclosing `function` when present) is * matched against a card's own file and its members' files/names. + * When a site has a line, that use-site is stamped on the badge so inspect + * / open-file can land on the call rather than the function header. */ import type { ScanReachabilityFinding } from '../../core-open/index.js'; -import type { ArchCardVuln, ArchOverview, ArchPackageNode, ArchSlice } from './arch-types.js'; +import type { ArchCard, ArchCardVuln, ArchOverview, ArchPackageNode, ArchSlice } from './arch-types.js'; import { loadScanArtifact, pathUnder, posixPath } from './overlay-context.js'; +type ReachabilitySite = NonNullable[number]; + const MAX_VULNS_PER_CARD = 6; /** Best-effort load of the last scan's reachability findings. Never throws. */ @@ -32,6 +36,51 @@ export function relevantVulnFindings(findings: ScanReachabilityFinding[]): ScanR return findings.filter((f) => f.tier === 'reachable' || f.tier === 'potentially_reachable'); } +function normName(s: string): string { + return s.replace(/[()`]/g, '').trim().toLowerCase(); +} + +function cardNames(card: ArchCard): Set { + return new Set( + [card.title, card.symbolId, ...(card.members ?? []).map((m) => m.name)] + .filter(Boolean) + .map(normName), + ); +} + +function cardFiles(card: ArchCard): Set { + return new Set([card.file, ...(card.members ?? []).map((m) => m.file)].filter(Boolean).map(posixPath)); +} + +/** + * Prefer a site in this card's files whose enclosing function matches a + * card/member name, then any lined site in those files, then any site. + */ +export function pickCardVulnSite( + finding: ScanReachabilityFinding, + files: Set, + names: Set, +): ReachabilitySite | null { + const sites = (finding.sites ?? []).filter((s) => s.file && files.has(posixPath(s.file))); + if (!sites.length) return null; + const named = sites.filter((s) => s.function && names.has(normName(s.function))); + const pool = named.length ? named : sites; + return pool.find((s) => typeof s.line === 'number' && s.line > 0) ?? pool[0] ?? null; +} + +function toCardVuln(finding: ScanReachabilityFinding, site: ReachabilitySite | null): ArchCardVuln { + const hit: ArchCardVuln = { + advisoryId: finding.advisoryId, + package: finding.package, + tier: finding.tier as 'reachable' | 'potentially_reachable', + }; + if (finding.evidence) hit.evidence = finding.evidence; + if (site?.file) hit.file = posixPath(site.file); + if (typeof site?.line === 'number' && site.line > 0) hit.line = site.line; + if (site?.function) hit.function = site.function; + return hit; +} + /** * Attach a `vulnerabilities` badge to every card whose file matches a * reachable/potentially-reachable finding's evidence site. Returns `slice` @@ -45,8 +94,7 @@ export function withVulnBadges(slice: ArchSlice, findings: ScanReachabilityFindi const columns = slice.columns.map((col) => { let colChanged = false; const cards = col.cards.map((card) => { - const files = new Set([card.file, ...(card.members ?? []).map((m) => m.file)].filter(Boolean)); - const hits = vulnsForFiles(files, relevant); + const hits = vulnsForFiles(cardFiles(card), relevant, cardNames(card)); if (!hits.length) return card; colChanged = true; return { ...card, vulnerabilities: hits }; @@ -83,25 +131,24 @@ function vulnsForPackage(pkg: ArchPackageNode, findings: ScanReachabilityFinding if (site.file && pathUnder(site.file, pkg.path)) files.add(posixPath(site.file)); } } - return vulnsForFiles(files, findings); + return vulnsForFiles(files, findings, new Set()); } -function vulnsForFiles(files: Set, findings: ScanReachabilityFinding[]): ArchCardVuln[] { +function vulnsForFiles( + files: Set, + findings: ScanReachabilityFinding[], + names: Set, +): ArchCardVuln[] { const hits: ArchCardVuln[] = []; const seen = new Set(); const normalised = new Set([...files].map(posixPath)); for (const finding of findings) { - const matches = (finding.sites ?? []).some((site) => site.file && normalised.has(posixPath(site.file))); - if (!matches) continue; + const site = pickCardVulnSite(finding, normalised, names); + if (!site) continue; const key = `${finding.advisoryId}|${finding.package}`; if (seen.has(key)) continue; seen.add(key); - hits.push({ - advisoryId: finding.advisoryId, - package: finding.package, - tier: finding.tier as 'reachable' | 'potentially_reachable', - ...(finding.evidence ? { evidence: finding.evidence } : {}), - }); + hits.push(toCardVuln(finding, site)); } hits.sort((a, b) => (a.tier === b.tier ? 0 : a.tier === 'reachable' ? -1 : 1)); return hits.slice(0, MAX_VULNS_PER_CARD); diff --git a/src/install/content.ts b/src/install/content.ts index 7978c83..4f7cb6a 100644 --- a/src/install/content.ts +++ b/src/install/content.ts @@ -18,9 +18,11 @@ export const NUDGE_END = ''; * repos the first time a new CLI version builds there. * * History: v1 = the unversioned originals (detected by their headings); - * v2 = versioned markers + the strong MCP-first recommendation. + * v2 = versioned markers + the strong MCP-first recommendation; + * v3 = library-docs workflow resolves by package name only, and an + * `entityCount` of 0 means read `node_modules` rather than retry. */ -export const INSTALL_CONTENT_VERSION = 2; +export const INSTALL_CONTENT_VERSION = 3; const VERSION_MARKER_RE = //; @@ -99,9 +101,12 @@ When a task needs a library's API, use the docs tools before web search or training-data recall — they are official content matched to the version **this project has installed**, and they win when the two conflict. -- **Workflow:** \`resolve_library\` once per library, then \`library_docs\` with the - returned \`targetId\` and a focused query (good: "zod refine custom error - message"; bad: "zod"). Never guess a targetId. +- **Workflow:** \`resolve_library\` with the **package name only** (good: \`"zod"\`). + Then \`library_docs\` with the returned \`targetId\` and a topic (good: \`"refine + custom error message"\`). Do not send the whole phrase as the resolve query — + hosted resolve treats an unknown exact name as \`not_found\`. Never guess a + targetId. If docs return \`entityCount\` 0, the catalog has no snippet for that + version; read \`node_modules\` instead of retrying the same query. - **Budget:** at most **3 docs calls per task**. If 2 \`library_docs\` calls have not surfaced the section you need, read the package source under \`node_modules\` instead of searching again. diff --git a/src/review/capsule.test.ts b/src/review/capsule.test.ts index 72bf595..1b18bb8 100644 --- a/src/review/capsule.test.ts +++ b/src/review/capsule.test.ts @@ -300,6 +300,23 @@ describe('compileCapsule — verification', () => { expect(capsule.verification.map((v) => v.path)).toEqual([ROUTE]); }); + it('gives repository scaffolding no coverage verdict — .gitignore is never reached by a test', () => { + const change = changeSet([ + changed('.gitignore'), + changed('.dockerignore'), + changed('.gitattributes'), + changed('.editorconfig'), + changed('.nvmrc'), + changed('LICENSE'), + changed('.github/CODEOWNERS'), + changed('.vscode/settings.json'), + changed('packages/app/.npmignore'), + changed(ROUTE), + ]); + const { capsule } = compileCapsule(input({ change })); + expect(capsule.verification.map((v) => v.path)).toEqual([ROUTE]); + }); + it('has nothing to say about coverage without a graph', () => { expect(compileCapsule(input({ graph: null })).capsule.verification).toEqual([]); }); diff --git a/src/review/propose-handoff.test.ts b/src/review/propose-handoff.test.ts new file mode 100644 index 0000000..7e1b257 --- /dev/null +++ b/src/review/propose-handoff.test.ts @@ -0,0 +1,138 @@ +/** + * Findings → propose handoff: current change set, explicit findings JSON, + * then the last findings-from-diff document. No git, no model. + */ + +import { describe, expect, it } from 'vitest'; +import { FINDINGS_SCHEMA } from './schemas.js'; +import { capsule, finding, findings } from './test-fixtures.js'; +import { + PROPOSE_HANDOFF_SCHEMA, + findingsDocumentFromUnknown, + missingProposeFindingMessage, + parseProposeHandoff, + resolveProposeFinding, +} from './propose-handoff.js'; + +const blast = finding({ + id: 'blast:n1', + kind: 'correctness', + producer: 'blast_radius', + paths: ['src/services/invoices.ts'], + claim: 'listInvoices has a cross-file dependent.', +}); + +const otherCapsule = capsule({ + identity: { + repo_pseudonym: 'sha256:handoff', + language: 'typescript', + graph_schema: 'vg-graph/1.1', + analyzer_versions: { graph: '1', scanners: '1' }, + profile: 'ci-wide', + }, +}); + +describe('findingsDocumentFromUnknown', () => { + it('accepts findings-from-diff JSON (plus publishable) and a receipt wrapper', () => { + const doc = { + schema_version: FINDINGS_SCHEMA, + architecture_findings: [blast], + security_findings: [], + unknowns: [], + publishable: [{ id: blast.id, kind: 'correctness' }], + }; + expect(findingsDocumentFromUnknown(doc)?.architecture_findings[0]?.id).toBe('blast:n1'); + expect( + findingsDocumentFromUnknown({ schema_version: 'vg.review.receipt.v1', findings: doc })?.architecture_findings[0] + ?.id, + ).toBe('blast:n1'); + expect(findingsDocumentFromUnknown({ hello: true })).toBeNull(); + }); +}); + +describe('resolveProposeFinding', () => { + const empty = findings(); + const reviewed = findings({ architecture_findings: [blast] }); + + it('prefers the current change set', () => { + const r = resolveProposeFinding({ + findingId: 'blast:n1', + reviewedFindings: reviewed, + reviewedCapsule: capsule(), + handoff: { + schema_version: PROPOSE_HANDOFF_SCHEMA, + findings: findings({ architecture_findings: [finding({ id: 'blast:n1', claim: 'stale' })] }), + capsule: otherCapsule, + change: { base: 'origin/main' }, + }, + }); + expect(r?.source).toBe('change-set'); + expect(r?.finding.claim).toBe(blast.claim); + expect(r?.capsule.identity.repo_pseudonym).toBe('sha256:abc'); + }); + + it('uses --findings JSON when the current change set misses the id', () => { + const r = resolveProposeFinding({ + findingId: 'blast:n1', + reviewedFindings: empty, + reviewedCapsule: capsule(), + findingsFile: { architecture_findings: [blast], security_findings: [] }, + }); + expect(r?.source).toBe('findings-file'); + expect(r?.finding.id).toBe('blast:n1'); + }); + + it('falls back to the last findings-from-diff handoff', () => { + const r = resolveProposeFinding({ + findingId: 'blast:n1', + reviewedFindings: empty, + reviewedCapsule: capsule(), + handoff: { + schema_version: PROPOSE_HANDOFF_SCHEMA, + findings: reviewed, + capsule: otherCapsule, + change: { base: 'origin/main' }, + }, + }); + expect(r?.source).toBe('handoff'); + expect(r?.capsule.identity.profile).toBe('ci-wide'); + }); + + it('returns null when no source has the id', () => { + expect( + resolveProposeFinding({ + findingId: 'blast:missing', + reviewedFindings: empty, + reviewedCapsule: capsule(), + }), + ).toBeNull(); + }); +}); + +describe('parseProposeHandoff / miss message', () => { + it('rejects a document that is not the handoff schema', () => { + expect(parseProposeHandoff({ schema_version: FINDINGS_SCHEMA, findings: findings() })).toBeNull(); + expect( + parseProposeHandoff({ + schema_version: PROPOSE_HANDOFF_SCHEMA, + findings: findings({ architecture_findings: [blast] }), + capsule: capsule(), + change: { base: 'origin/main' }, + })?.change.base, + ).toBe('origin/main'); + }); + + it('names --base / --findings and last ids', () => { + const msg = missingProposeFindingMessage('blast:missing', { + schema_version: PROPOSE_HANDOFF_SCHEMA, + findings: findings({ architecture_findings: [blast] }), + capsule: capsule(), + change: { base: 'origin/main' }, + }); + expect(msg).toContain('blast:missing'); + expect(msg).toContain('--base origin/main'); + expect(msg).toContain('--findings'); + expect(msg).toContain('blast:n1'); + expect(msg).not.toMatch(/github app|check run/i); + }); +}); diff --git a/src/review/propose-handoff.ts b/src/review/propose-handoff.ts new file mode 100644 index 0000000..6662c04 --- /dev/null +++ b/src/review/propose-handoff.ts @@ -0,0 +1,186 @@ +/** + * Findings → propose handoff. + * + * `vg review findings-from-diff --base …` reasons over the merge-base change + * set. `vg review propose ` used to re-collect the *current* working-tree + * change set and miss those ids. This module is the smallest handoff: persist + * the last findings document + capsule, accept the same `--base` / `--diff` + * as findings-from-diff, and accept an explicit findings JSON. Propose still + * calls {@link proposeFindingFix} — no second loop. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + CAPSULE_SCHEMA, + FINDINGS_SCHEMA, + RECEIPT_SCHEMA, + type AnalysisCapsule, + type ReviewFinding, + type ReviewFindings, +} from './schemas.js'; + +export const PROPOSE_HANDOFF_SCHEMA = 'vg.review.propose-handoff.v1' as const; +export const PROPOSE_HANDOFF_REL = '.vibgrate/review-propose-handoff.json'; + +export type ProposeFindingSource = 'change-set' | 'findings-file' | 'handoff'; + +export interface ProposeHandoffChange { + base?: string; + inPlace?: boolean; + hasDiff?: boolean; +} + +export interface ProposeHandoff { + schema_version: typeof PROPOSE_HANDOFF_SCHEMA; + findings: ReviewFindings; + capsule: AnalysisCapsule; + change: ProposeHandoffChange; +} + +export interface ResolvedProposeFinding { + finding: ReviewFinding; + capsule: AnalysisCapsule; + source: ProposeFindingSource; +} + +export function listReviewFindings(findings: ReviewFindings): ReviewFinding[] { + return [...findings.architecture_findings, ...findings.security_findings]; +} + +export function findReviewFinding(findings: ReviewFindings, id: string): ReviewFinding | undefined { + return listReviewFindings(findings).find((f) => f.id === id); +} + +export function proposeHandoffPath(root: string): string { + return path.join(root, PROPOSE_HANDOFF_REL); +} + +/** Best-effort write. A failed write must not fail findings-from-diff. */ +export function writeProposeHandoff(root: string, handoff: ProposeHandoff): string | null { + try { + const dest = proposeHandoffPath(root); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, `${JSON.stringify(handoff, null, 2)}\n`, 'utf8'); + return dest; + } catch { + return null; + } +} + +export function readProposeHandoff(root: string): ProposeHandoff | null { + const dest = proposeHandoffPath(root); + if (!fs.existsSync(dest)) return null; + try { + return parseProposeHandoff(JSON.parse(fs.readFileSync(dest, 'utf8'))); + } catch { + return null; + } +} + +export function parseProposeHandoff(raw: unknown): ProposeHandoff | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + if (o.schema_version !== PROPOSE_HANDOFF_SCHEMA) return null; + const findings = findingsDocumentFromUnknown(o.findings); + const capsule = capsuleFromUnknown(o.capsule); + if (!findings || !capsule) return null; + const changeRaw = o.change && typeof o.change === 'object' ? (o.change as Record) : {}; + const change: ProposeHandoffChange = {}; + if (typeof changeRaw.base === 'string' && changeRaw.base) change.base = changeRaw.base; + if (changeRaw.inPlace === true) change.inPlace = true; + if (changeRaw.hasDiff === true) change.hasDiff = true; + return { schema_version: PROPOSE_HANDOFF_SCHEMA, findings, capsule, change }; +} + +/** Findings-from-diff JSON, a receipt, or a handoff document. */ +export function findingsDocumentFromUnknown(raw: unknown): ReviewFindings | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + if (o.schema_version === RECEIPT_SCHEMA && o.findings) { + return findingsDocumentFromUnknown(o.findings); + } + if (o.schema_version === PROPOSE_HANDOFF_SCHEMA && o.findings) { + return findingsDocumentFromUnknown(o.findings); + } + if (!Array.isArray(o.architecture_findings) && !Array.isArray(o.security_findings)) return null; + const architecture = asFindings(o.architecture_findings); + const security = asFindings(o.security_findings); + if (architecture === null || security === null) return null; + return { + schema_version: FINDINGS_SCHEMA, + change_class: Array.isArray(o.change_class) + ? (o.change_class.filter((c) => c === 'architecture' || c === 'security' || c === 'none') as ReviewFindings['change_class']) + : [], + architecture_findings: architecture, + security_findings: security, + unknowns: Array.isArray(o.unknowns) ? o.unknowns.filter((u): u is string => typeof u === 'string') : [], + required_checks: Array.isArray(o.required_checks) + ? o.required_checks.filter((u): u is string => typeof u === 'string') + : [], + }; +} + +export function capsuleFromUnknown(raw: unknown): AnalysisCapsule | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + if (o.schema_version === PROPOSE_HANDOFF_SCHEMA) return capsuleFromUnknown(o.capsule); + if (o.schema_version !== CAPSULE_SCHEMA) return null; + return o as unknown as AnalysisCapsule; +} + +function asFindings(raw: unknown): ReviewFinding[] | null { + if (raw === undefined) return []; + if (!Array.isArray(raw)) return null; + const out: ReviewFinding[] = []; + for (const row of raw) { + if (!row || typeof row !== 'object') continue; + const f = row as Partial; + if (typeof f.id !== 'string' || !f.id) continue; + out.push(row as ReviewFinding); + } + return out; +} + +/** + * Look up a finding id. Current change set first, then an explicit findings + * JSON, then the last findings-from-diff handoff. + */ +export function resolveProposeFinding(opts: { + findingId: string; + reviewedFindings: ReviewFindings; + reviewedCapsule: AnalysisCapsule; + findingsFile?: unknown; + handoff?: ProposeHandoff | null; +}): ResolvedProposeFinding | null { + const current = findReviewFinding(opts.reviewedFindings, opts.findingId); + if (current) { + return { finding: current, capsule: opts.reviewedCapsule, source: 'change-set' }; + } + if (opts.findingsFile !== undefined) { + const fileFindings = findingsDocumentFromUnknown(opts.findingsFile); + const hit = fileFindings ? findReviewFinding(fileFindings, opts.findingId) : undefined; + if (hit) { + const fileCapsule = capsuleFromUnknown(opts.findingsFile) ?? opts.handoff?.capsule ?? opts.reviewedCapsule; + return { finding: hit, capsule: fileCapsule, source: 'findings-file' }; + } + } + if (opts.handoff) { + const hit = findReviewFinding(opts.handoff.findings, opts.findingId); + if (hit) { + return { finding: hit, capsule: opts.handoff.capsule, source: 'handoff' }; + } + } + return null; +} + +/** Actionable miss — name the flags that match findings-from-diff. */ +export function missingProposeFindingMessage(findingId: string, handoff: ProposeHandoff | null): string { + const ids = handoff ? listReviewFindings(handoff.findings).map((f) => f.id) : []; + const known = ids.length ? ` Last findings-from-diff had: ${ids.slice(0, 8).join(', ')}${ids.length > 8 ? '…' : ''}.` : ''; + const baseHint = handoff?.change.base ? ` --base ${handoff.change.base}` : ''; + return ( + `no finding "${findingId}" in this change set — run \`vg review findings-from-diff\`${baseHint}` + + ` then \`vg review propose ${findingId}\`, or pass the same \`--base\` / \`--diff\` / \`--findings { expect(text).toContain(String(REVIEW_PROPOSE_LOOP_CAP)); expect(text).toMatch(/must write|before finish/i); expect(text).toMatch(/residual search\/replace/i); + expect(text).toContain('src/scan.ts'); expect(text).not.toMatch(/<<<<<<< SEARCH/); }); - it('one-shot protocol asks for SEARCH/REPLACE residual, not tools', () => { + it('one-shot protocol asks for SEARCH/REPLACE residual, not tools or function calling', () => { const text = buildReviewProposeInstruction(baseInput({ loop: false })); expect(text).toContain('<<<<<<< SEARCH'); expect(text).toContain('>>>>>>> REPLACE'); + expect(text).toContain('src/scan.ts'); expect(text).toMatch(/do not call tools/i); expect(text).toMatch(/do not call finish/i); + expect(text).toMatch(/function calling|tool_calls/i); + expect(text).toMatch(/entire reply must be the edit block/i); expect(text).toContain('const timeout = 0;'); }); }); @@ -340,6 +344,148 @@ describe('proposeFindingFix — Review contract', () => { expect(fsImpl.files['src/scan.ts']).toBe(baseFile); }); + it('path-less live residual plus cited file still yields PatchIR (not no-tools)', async () => { + const residual = ['<<<<<<< SEARCH', 'const timeout = 0;', '=======', 'const timeout = 5000;', '>>>>>>> REPLACE'].join( + '\n', + ); + const backend: Provider & { seen: number } = { + id: 'llama-cpp', + label: 'Vibgrate (local)', + local: true, + model: 'flow-pack', + supportsTools: false, + seen: 0, + async chat() { + backend.seen += 1; + return { text: residual, model: 'flow-pack', provider: 'llama-cpp' }; + }, + }; + const fsImpl = memFs({ 'src/scan.ts': baseFile }); + const r = await proposeFindingFix( + baseInput({ + loop: true, + providers: [withToolCallFallback(backend)], + fsImpl, + modelId: 'flow', + }), + ); + expect(r.ok).toBe(true); + expect(r.stopReason).toBe('finished'); + expect(r.patch).toBeTruthy(); + expect(r.toolTrace.map((t) => t.name)).toContain('edit_file'); + expect(r.steps).toBeLessThanOrEqual(REVIEW_PROPOSE_LOOP_CAP); + expect(r.applied).toBe(false); + }); + + it('live Flow PatchIR dump drives the loop (not no-tools) and yields PatchIR', async () => { + const dump = JSON.stringify({ + schemaVersion: 'patch-ir/0', + operations: [ + { op: 'replace-text', file: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }, + ], + }); + const backend: Provider & { seen: number } = { + id: 'llama-cpp', + label: 'Vibgrate (local)', + local: true, + model: 'flow', + supportsTools: false, + seen: 0, + async chat() { + backend.seen += 1; + return { text: dump, model: 'flow', provider: 'llama-cpp' }; + }, + }; + const fsImpl = memFs({ 'src/scan.ts': baseFile }); + const r = await proposeFindingFix( + baseInput({ + loop: true, + providers: [withToolCallFallback(backend)], + fsImpl, + modelId: 'flow', + }), + ); + expect(r.ok).toBe(true); + expect(r.stopReason).toBe('finished'); + expect(r.stopReason).not.toBe('no-tools'); + expect(r.patch).toBeTruthy(); + expect(validatePatchIR(r.patch!).ok).toBe(true); + expect(r.proposedDiff).toContain('const timeout = 5000'); + expect(r.toolTrace.map((t) => t.name)).toContain('edit_file'); + expect(r.steps).toBeLessThanOrEqual(REVIEW_PROPOSE_LOOP_CAP); + expect(r.applied).toBe(false); + expect(fsImpl.files['src/scan.ts']).toBe(baseFile); + }); + + it('one-shot PatchIR JSON dump still yields PatchIR (Relay no-patch hypothesis)', async () => { + const dump = JSON.stringify({ + schemaVersion: 'patch-ir/0', + operations: [ + { op: 'replace-text', file: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }, + ], + }); + const hosted: Provider = { + id: 'vibgrate-relay', + label: 'Vibgrate Relay', + local: false, + model: 'hosted-coder', + async chat() { + return { text: dump, model: 'hosted-coder', provider: 'vibgrate-relay' }; + }, + }; + const fsImpl = memFs({ 'src/scan.ts': baseFile }); + const r = await proposeFindingFix( + baseInput({ + loop: false, + modelId: 'relay:hosted-coder', + providers: [hosted], + fsImpl, + }), + ); + expect(r.ok).toBe(true); + expect(r.stopReason).toBe('finished'); + expect(r.patch).toBeTruthy(); + expect(r.proposedDiff).toContain('timeout = 5000'); + expect(fsImpl.files['src/scan.ts']).toBe(baseFile); + }); + + it('one-shot native edit_file tool calls (empty body) still yield PatchIR', async () => { + const hosted: Provider = { + id: 'vibgrate-relay', + label: 'Vibgrate Relay', + local: false, + model: 'hosted-coder', + async chat() { + return { + text: '', + model: 'hosted-coder', + provider: 'vibgrate-relay', + toolCalls: [ + { + id: 'call_1', + name: 'edit_file', + arguments: { path: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }, + }, + ], + }; + }, + }; + const fsImpl = memFs({ 'src/scan.ts': 'const timeout = 0;\n' }); + const r = await proposeFindingFix( + baseInput({ + loop: false, + modelId: 'relay:hosted-coder', + providers: [hosted], + fsImpl, + }), + ); + expect(r.ok).toBe(true); + expect(r.stopReason).toBe('finished'); + expect(r.patch).toBeTruthy(); + expect(r.proposedDiff).toContain('+const timeout = 5000;'); + expect(fsImpl.files['src/scan.ts']).toBe('const timeout = 0;\n'); + }); + it('plan-mode leakage (no mutations) is no-patch, not success', async () => { // A provider that only "plans" — no edit — must not report ok. const provider = new ScriptedProvider('m', [ diff --git a/src/review/propose.ts b/src/review/propose.ts index 85a275e..c3ad7cd 100644 --- a/src/review/propose.ts +++ b/src/review/propose.ts @@ -232,20 +232,23 @@ export function buildReviewProposeInstruction(input: ReviewProposeInput): string const evidence = capsule.evidence.filter((e) => finding.evidence_ids.includes(e.id)); const snippets = citedFileSnippets(input); const loop = input.loop !== false; + const citedPath = input.finding.paths.find(Boolean) ?? ''; const protocol = loop ? [ `You have at most ${REVIEW_PROPOSE_LOOP_CAP} steps.`, 'The finding already cites the file and evidence — do not search, list files, set_progress, or call graph_impact first.', 'You must write the cited file before finish: call edit_file (SEARCH must match the current snippet), then call finish with a short summary.', - 'A residual search/replace block is applied as edit_file — do not treat it as the final answer. Do not call finish until a write has landed.', + `A residual search/replace block, or a printed PatchIR / {path,search,replace} JSON dump, is applied as edit_file on ${citedPath} even without a path line. Do not treat residual text as the final answer. Do not call finish until a write has landed.`, 'Smallest in-place edit only. Do not add files or invent an extra service.', ].join(' ') : [ - 'This is a one-shot residual turn: reply ONLY with edit blocks. Do not call tools and do not call finish.', - 'Format:', - '', + 'This is a one-shot residual turn: reply ONLY with edit blocks in the message body.', + 'Do not call tools, do not call finish, and do not use function calling or a tool_calls array.', + 'Do not write JSON, a plan, or an explanation. The entire reply must be the edit block.', + 'Format (copy the cited lines into SEARCH so they match exactly):', + citedPath, '<<<<<<< SEARCH', - '', + '', '=======', '', '>>>>>>> REPLACE', diff --git a/src/review/review-gold.test.ts b/src/review/review-gold.test.ts index c3a0045..32697c2 100644 --- a/src/review/review-gold.test.ts +++ b/src/review/review-gold.test.ts @@ -105,6 +105,92 @@ describe('review-gold — proposeFindingFix (offline, no live model)', () => { expect(filesAfter['src/scan.ts']).toBe(fixture.files['src/scan.ts']); }); + it('happy-path-loop lifts a path-less live residual onto the cited file', async () => { + const fixture = fixtures.find((f) => f.id === 'happy-path-loop')!; + const residual = ['<<<<<<< SEARCH', 'const timeout = 0;', '=======', 'const timeout = 5000;', '>>>>>>> REPLACE'].join( + '\n', + ); + const backend: Provider = { + id: 'llama-cpp', + label: 'Vibgrate (local)', + local: true, + model: 'flow-pack', + supportsTools: false, + async chat() { + return { text: residual, model: 'flow-pack', provider: 'llama-cpp' }; + }, + }; + const { result } = await runReviewGoldFixture(fixture, { + providers: [withToolCallFallback(backend)], + }); + expect(result.ok, result.error ?? fixture.id).toBe(true); + expect(result.stopReason).toBe('finished'); + expect(result.stopReason).not.toBe('no-tools'); + expect(result.patch).toBeTruthy(); + expect(result.steps).toBeLessThanOrEqual(5); + }); + + it('happy-path-loop lifts a live Flow PatchIR dump into PatchIR (not no-tools)', async () => { + const fixture = fixtures.find((f) => f.id === 'happy-path-loop')!; + const dump = JSON.stringify({ + schemaVersion: 'patch-ir/0', + operations: [ + { op: 'replace-text', file: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }, + ], + }); + const backend: Provider = { + id: 'llama-cpp', + label: 'Vibgrate (local)', + local: true, + model: 'flow', + supportsTools: false, + async chat() { + return { text: dump, model: 'flow', provider: 'llama-cpp' }; + }, + }; + const { result, filesAfter } = await runReviewGoldFixture(fixture, { + providers: [withToolCallFallback(backend)], + }); + expect(result.ok, result.error ?? fixture.id).toBe(true); + expect(result.stopReason).toBe('finished'); + expect(result.stopReason).not.toBe('no-tools'); + expect(result.patch).toBeTruthy(); + expect(validatePatchIR(result.patch!).ok).toBe(true); + expect(result.toolTrace.map((t) => t.name)).toContain('edit_file'); + expect(result.applied).toBe(false); + expect(result.steps).toBeLessThanOrEqual(5); + expect(filesAfter['src/scan.ts']).toBe(fixture.files['src/scan.ts']); + }); + + it('happy-path-oneshot lifts a native edit_file tool call with an empty body', async () => { + const fixture = fixtures.find((f) => f.id === 'happy-path-oneshot')!; + const hosted: Provider = { + id: 'vibgrate-relay', + label: 'Vibgrate Relay', + local: false, + model: 'hosted-coder', + async chat() { + return { + text: '', + model: 'hosted-coder', + provider: 'vibgrate-relay', + toolCalls: [ + { + id: 'call_1', + name: 'edit_file', + arguments: { path: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }, + }, + ], + }; + }, + }; + const { result } = await runReviewGoldFixture(fixture, { providers: [hosted] }); + expect(result.ok, result.error ?? fixture.id).toBe(true); + expect(result.stopReason).toBe('finished'); + expect(result.patch).toBeTruthy(); + expect(result.steps).toBe(1); + }); + it('invalid-model never calls a backend (usage absent, not zero)', async () => { const fixture = fixtures.find((f) => f.id === 'invalid-model')!; const { result } = await runReviewGoldFixture(fixture); diff --git a/src/review/review.test.ts b/src/review/review.test.ts index eed9ff2..4af7383 100644 --- a/src/review/review.test.ts +++ b/src/review/review.test.ts @@ -830,6 +830,29 @@ describe('vg review command surface', () => { ); const findings = review!.commands.find((c) => c.name() === 'findings-from-diff'); expect(findings!.options.map((o) => o.long)).toEqual(expect.arrayContaining(['--base', '--diff', '--format'])); + const propose = review!.commands.find((c) => c.name() === 'propose'); + expect(propose!.options.map((o) => o.long)).toEqual( + expect.arrayContaining(['--base', '--in-place', '--diff', '--findings', '--model', '--loop', '--single']), + ); + const findingsHelp = findings!.helpInformation(); + expect(findingsHelp).toMatch(/vg\.review\.findings\.v1/); + expect(findingsHelp).toMatch(/no hosted/); + expect(findingsHelp).toMatch(/publishable/); + expect(findingsHelp).toMatch(/review-propose-handoff\.json/); + + const explainHelp = review!.commands.find((c) => c.name() === 'explain')!.helpInformation(); + expect(explainHelp).toMatch(/blast:/); + expect(explainHelp).toMatch(/arch::/); + expect(explainHelp).not.toMatch(/arch-01/); + + const proposeHelp = propose!.helpInformation(); + expect(proposeHelp).toMatch(/PatchIR/); + expect(proposeHelp).toMatch(/default branch/); + expect(proposeHelp).toMatch(/relay:/); + expect(proposeHelp).toMatch(/spark\|flow\|forge/); + expect(proposeHelp).toMatch(/--findings/); + expect(proposeHelp).toMatch(/review-propose-handoff\.json/); + expect(proposeHelp).toMatch(/same as findings-from-diff/); }); it('refuses --push under --offline so an airgap cannot upload', () => { diff --git a/src/review/run.test.ts b/src/review/run.test.ts index a94e0d1..d94a59a 100644 --- a/src/review/run.test.ts +++ b/src/review/run.test.ts @@ -136,6 +136,21 @@ describe('runReview — change class', () => { expect(receipt.versions.model).toBe('none'); }); + it('takes the quick path for a scaffolding-only change — .gitignore has no test edge to miss', async () => { + const { root, graphPath } = routeRepo(); + fs.writeFileSync(path.join(root, '.gitignore'), 'node_modules/\n'); + fs.writeFileSync(path.join(root, '.editorconfig'), 'root = true\n'); + const { receipt } = await review(root, graphPath, { + status: ' M .gitignore\n M .editorconfig\n', + numstat: '1\t0\t.gitignore\n1\t0\t.editorconfig\n', + }); + expect(receipt.change_class).toEqual(['none']); + expect(receipt.decision).toBe('pass'); + expect(receipt.quick_path).toBe(true); + expect(receipt.findings.required_checks).toEqual([]); + expect(receipt.counts).toEqual({ architecture: 0, security: 0, protected: 0, unknowns: 0 }); + }); + it('keeps a code file the classifier cannot place on the normal path — unknown is not absent', async () => { const { root, graphPath } = routeRepo(); fs.writeFileSync(path.join(root, 'src/foo.ts'), 'export const x = 1;\n'); diff --git a/src/review/surface.test.ts b/src/review/surface.test.ts new file mode 100644 index 0000000..efa036f --- /dev/null +++ b/src/review/surface.test.ts @@ -0,0 +1,122 @@ +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { langForExtension } from '../engine/languages.js'; +import { isDependencyManifest, isNonCodePath } from './surface.js'; + +describe('isNonCodePath', () => { + it.each([ + '.gitignore', + 'packages/app/.gitignore', + '.dockerignore', + '.npmignore', + '.eslintignore', + '.prettierignore', + '.vscodeignore', + '.gitattributes', + 'src/empty/.gitkeep', + '.mailmap', + '.editorconfig', + '.browserslistrc', + '.nvmrc', + '.node-version', + '.python-version', + '.tool-versions', + 'LICENSE', + 'LICENSE.md', + 'LICENCE.txt', + 'NOTICE', + 'COPYING', + '.github/CODEOWNERS', + 'AUTHORS', + 'CHANGELOG', + 'CITATION.cff', + '.vscode/settings.json', + '.idea/workspace.xml', + ])('treats repository scaffolding as non-code: %s', (p) => { + expect(isNonCodePath(p)).toBe(true); + }); + + it.each(['README.md', 'docs/guide.md', 'assets/logo.png', 'changelog/unreleased/cli/x.md', 'src/__snapshots__/a.snap'])( + 'still treats prose, assets and generated output as non-code: %s', + (p) => { + expect(isNonCodePath(p)).toBe(true); + }, + ); + + it.each([ + 'src/a.ts', + 'src/ignore.ts', + 'src/gitignore-parser.ts', + 'lib/license.ts', + 'src/changelog.ts', + // Supply-chain surface: these decide where code comes from, so they earn + // the normal path rather than a quick pass. + '.npmrc', + '.yarnrc.yml', + '.gitmodules', + '.pnpmfile.cjs', + '.github/workflows/ci.yml', + '.github/dependabot.yml', + 'Dockerfile', + 'tsconfig.json', + 'package.json', + ])('keeps everything else on the normal path: %s', (p) => { + expect(isNonCodePath(p)).toBe(false); + }); +}); + +describe('scaffolding basenames never collide with a scanned language', () => { + // The scaffolding basenames are exempt from a coverage verdict on the theory + // that no parser will ever attach a call-graph node to them — the same fact + // `engine/discover.ts` relies on when it walks the repo (a file only joins + // the graph if `langForExtension` recognises its extension). If a future + // language ever claimed one of these extensions, `isNonCodePath` and the + // scanner would quietly disagree about whether the file carries a call path. + // Pinning both signals together, the way `discover-skips.test.ts` pins + // `SKIP_DIRS` against the scanner's own list, keeps that impossible. + it.each([ + '.gitignore', + '.dockerignore', + '.npmignore', + '.eslintignore', + '.gitattributes', + '.gitkeep', + 'src/empty-dir/.keep', + '.mailmap', + '.editorconfig', + '.browserslistrc', + '.nvmrc', + '.node-version', + '.python-version', + '.ruby-version', + '.tool-versions', + 'LICENSE', + 'LICENSE.md', + 'LICENCE.txt', + 'NOTICE', + 'COPYING', + 'PATENTS', + 'CODEOWNERS', + 'AUTHORS', + 'CONTRIBUTORS', + 'CHANGELOG', + 'CITATION.cff', + ])('%s has no extension the scanner recognises as a language', (name) => { + expect(isNonCodePath(name)).toBe(true); + expect(langForExtension(path.extname(name))).toBeUndefined(); + }); +}); + +describe('isDependencyManifest', () => { + it('recognises manifests and lockfiles across ecosystems', () => { + for (const p of ['package.json', 'sub/pnpm-lock.yaml', 'go.mod', 'Cargo.lock', 'pyproject.toml', 'Gemfile.lock']) { + expect(isDependencyManifest(p)).toBe(true); + } + }); + + it('never mistakes scaffolding for a manifest', () => { + for (const p of ['.gitignore', '.npmignore', 'LICENSE', '.editorconfig']) { + expect(isDependencyManifest(p)).toBe(false); + } + }); +}); diff --git a/src/review/surface.ts b/src/review/surface.ts index cdceade..26e6f64 100644 --- a/src/review/surface.ts +++ b/src/review/surface.ts @@ -19,13 +19,29 @@ export const DEPENDENCY_MANIFEST = * the analyzer failing to recognise a file. */ const NON_CODE = /\.(md|mdx|markdown|txt|rst|adoc|png|jpe?g|gif|svg|ico|webp|woff2?|ttf|eot|pdf|csv|snap|lock)$/i; -const NON_CODE_DIRS = /(^|\/)(docs?|\.github\/ISSUE_TEMPLATE|changelog|marketing)\//i; +const NON_CODE_DIRS = /(^|\/)(docs?|\.github\/ISSUE_TEMPLATE|changelog|marketing|\.vscode|\.idea)\//i; + +/** + * Repository scaffolding, matched on the basename because most of it has no + * extension: ignore lists (`.gitignore`, `.dockerignore`, `.npmignore`, …), + * VCS attributes, editor and toolchain pins, and licence / ownership prose. + * "No test edge reaches `.gitignore`" is true of every repository and tells + * the reviewer nothing, so these get no coverage verdict and no + * `unverified_change` finding. + * + * Deliberately absent: anything that changes what code ends up in the tree or + * where it comes from — `.npmrc` / `.yarnrc` (registry routing), `.gitmodules` + * (submodule sources), `.pnpmfile.cjs` (resolution hooks), CI workflows. Those + * carry a supply-chain surface and stay on the normal path. + */ +const SCAFFOLDING = + /(^|\/)(\.[a-z0-9_-]*ignore|\.gitattributes|\.gitkeep|\.keep|\.mailmap|\.editorconfig|\.browserslistrc|\.nvmrc|\.node-version|\.python-version|\.ruby-version|\.tool-versions|(LICEN[CS]E|NOTICE|COPYING|PATENTS)(\.(md|txt|rst))?|CODEOWNERS|AUTHORS|CONTRIBUTORS|CHANGELOG|CITATION\.cff)$/i; export function isDependencyManifest(path: string): boolean { return DEPENDENCY_MANIFEST.test(path); } -/** Prose, assets and generated output — nothing here has a call path. */ +/** Prose, assets, generated output and repository scaffolding — nothing here has a call path. */ export function isNonCodePath(path: string): boolean { - return NON_CODE.test(path) || NON_CODE_DIRS.test(path); + return NON_CODE.test(path) || NON_CODE_DIRS.test(path) || SCAFFOLDING.test(path); } diff --git a/src/version.ts b/src/version.ts index 4ae6e15..d85b2c1 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Calendar version (YYYY.DDD.PATCH), shared scheme with @vibgrate/cli. -export const VERSION = '2026.917.1'; +export const VERSION = '2026.921.1';