diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml index e7e12b1..f4937e6 100644 --- a/.github/workflows/helm.yml +++ b/.github/workflows/helm.yml @@ -118,6 +118,9 @@ jobs: - name: Set up Helm uses: azure/setup-helm@v5 + - name: Set up oras + uses: oras-project/setup-oras@v1 + - name: Setup Node.js uses: actions/setup-node@v6 with: diff --git a/DOCS.md b/DOCS.md index 01217b9..41a5105 100644 --- a/DOCS.md +++ b/DOCS.md @@ -465,6 +465,8 @@ 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 findings-from-diff # deterministic graph/policy findings only +vg review propose blast: --model forge --json ``` | Flag | Default | Description | @@ -531,13 +533,11 @@ to your repository; that is the only way to stop it gating. | Finding | What it means | |---|---| -| `boundary_bypass` | A dependency breaks the declared layering, or skips a tier | -| `peer_deviation` | This file does something its peers do differently | | `unguarded_entrypoint` | **Protected.** A mutating route has no authorization guard, where its peers do | | `guard_removed` | **Protected.** A guard was deleted and nothing equivalent remains | | `known_vulnerable_dependency` | **Protected.** A changed manifest declares a package with a known advisory | -| `duplicate_implementation` | This re-implements something the repository already has | -| `unverified_change` | Changed code with no test reaching it | +| `correctness` (producer `blast_radius`) | Blast-radius fact: a changed symbol has cross-file callers or dependents — the same reverse-reachability `vg impact` reports. Severity stays at or below medium. Stable `id` (`blast:{node_id}` / `blast:{path}:{name}`) is the finding_key. | +| `correctness` (producer `architecture`) | Architecture-policy on a changed file (layer skip / boundary, peer deviation, duplicate implementation, uncovered change). Stable `id` is `arch:{rule}:{path}` using the architecture pack's rule string when one exists. Severity is `low`, `medium`, or `high` — never `critical` from version lag. | Two of these deserve a note, because they are what a linter cannot do: @@ -558,6 +558,34 @@ which by construction scores your best file worst. a file it could not parse, becomes an honest unknown — never "this route is open". Below four classified peer routes a finding is advisory and cannot gate. +#### Findings from a change — `vg review findings-from-diff` + +The same deterministic scanners `vg review` runs, printed as the +`vg.review.findings.v1` document — graph blast-radius facts for changed +symbols, plus architecture and security-control findings on the change set. +No hosted model. Use this when you want the findings (and their ids) without +the signed receipt ceremony. + +```bash +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. + #### Before you write it — `assess_change` `vg review` asks "was that change sound?". The MCP tool `assess_change` asks @@ -1686,7 +1714,7 @@ vg show arch --no-open --json # print the URL and counts; keep serving | `--focus ` | — | Open the map on this symbol | | `--no-open` | — | Print the URL without opening a browser | -The map opens on the **workspace** (one card per package), then drills into a **column slice** (UI / endpoint → application → store). Same-file functions collapse; tests stay hidden; at most 120 cards. Filters (**by job**, **by cluster**, **who calls whom**, **missing steps**, **problems**) apply inside that zoom. With the Architecture module off it is the raw graph’s kinds in the same columns — never a guess. The page is served inline from loopback with no external assets, and `q` / Ctrl-C stops it. `vg show chart` is the pre-rename spelling and still works as a silent alias for one release. See [docs/show-arch.md](./docs/show-arch.md). +The map opens on the **workspace** (one card per package), then drills into a **column slice** (UI / endpoint → application → store). Same-file functions collapse; tests stay hidden; at most 120 cards. Filters (**by job**, **by cluster**, **who calls whom**, **missing steps**, **problems**) apply inside that zoom. Overlay toggles (vulns, drift, ownership, churn) paint on that same canvas — the Health tab opens them; it is not a blank page. Missing scan, CODEOWNERS, or git history is omitted, never a healthy zero, and the map does not invent an Architecture Health Score. With the Architecture module off it is the raw graph’s kinds in the same columns — never a guess. The page is served inline from loopback with no external assets, and `q` / Ctrl-C stops it. `vg show chart` is the pre-rename spelling and still works as a silent alias for one release. The VS Code architecture board hosts the same page and payload. See [docs/show-arch.md](./docs/show-arch.md). #### vg show savings diff --git a/NOTICE b/NOTICE index bf8650b..2172bb4 100644 --- a/NOTICE +++ b/NOTICE @@ -23,7 +23,7 @@ This product bundles or depends on the following open-source components: src/vendor/fastembed is adapted from fastembed-js v2.1.0 (https://github.com/Anush008/fastembed-js); see that file's header for the changes made - - @anush008/tokenizers (MIT) — native HuggingFace tokenizers bindings + - tokenizers (Apache-2.0, © HuggingFace) — native HuggingFace tokenizers bindings - onnxruntime-node (MIT) — ONNX Runtime for local embedding inference - tar (ISC) — model archive extraction - web-tree-sitter (MIT) — WASM tree-sitter runtime diff --git a/README.md b/README.md index 469492e..d3ea195 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,13 @@ when files change — including edits the assistant itself just made — the nex tool call rebuilds it incrementally before answering, with no watcher or daemon involved. +

+ + Recorded node-turborepo/serve replay of the real Vibgrate CLI + +

+

node-turborepo/servevg serve --http starts Vibgrate AI Context. Live simulator.

+ Wire it up in one command: ```bash @@ -180,7 +187,14 @@ Prefer the hosted server over your team's scan data? **[Vibgrate Cloud MCP](http ## Understand any codebase -Build the graph once, query it continuously: +Build the graph once, query it continuously. These are recorded replays of the real CLI on sample repos — [run them live](https://vibgrate.com/cli). + +

+ + Recorded node-turborepo/build replay of the real Vibgrate CLI + +

+

node-turborepo/buildvg build maps a pnpm monorepo.

```bash vg build # index the repo (incremental; re-run after changes) @@ -192,6 +206,20 @@ vg tree src/server.ts # call tree rooted at a node vg insights # overview: hubs, hotspots, untested paths ``` +

+ + Recorded node-turborepo/ask replay of the real Vibgrate CLI + +

+

node-turborepo/askvg ask returns cited nodes, not a chat essay.

+ +

+ + Recorded node-turborepo/impact replay of the real Vibgrate CLI + +

+

node-turborepo/impact — blast radius of changing a hub before you edit.

+ The graph is byte-deterministic and reproducible — the same repo always produces the same graph on every machine. ```bash @@ -363,6 +391,13 @@ Full key reference — including `securityTier`, `capsule`, and `modelProfile` ## Measure and manage upgrade drift +

+ + Recorded calcom/scan replay of the real Vibgrate CLI + +

+

calcom/scan — a bare vg drift scan on sample Cal.com data. Live simulator.

+ ```bash vg scan # drift score + risk level + ranked priorities vg scan --push # same, and upload to Vibgrate Cloud for trend tracking @@ -489,6 +524,13 @@ Upload is opt-in — nothing leaves your machine until you run `--push`. Store t ## CI integration +

+ + Recorded java-spring/budget replay of the real Vibgrate CLI + +

+

java-spring/budgetvg scan --drift-budget 60 as a CI gate (this recording exits 0). Live simulator.

+ Drop `vg` into any pipeline to turn drift scoring into a quality gate: ```yaml @@ -534,6 +576,13 @@ AI assistants connected via MCP use `vg lib` automatically when answering questi ## SBOM and OpenVEX +

+ + Recorded strapi/sbom replay of the real Vibgrate CLI + +

+

strapi/sbomvg sbom export --format cyclonedx --out sbom.cdx.json. An inventory, not a compliance determination. Live simulator.

+ ```bash vg sbom export --format cyclonedx --out sbom.cdx.json vg sbom export --format spdx --out sbom.spdx.json @@ -541,6 +590,21 @@ vg sbom delta --from .vibgrate/baseline.json --to .vibgrate/scan_result.json -- vg vex # generate an OpenVEX document for attestation ``` +## Review a change + +**Vibgrate Review** reads the current change against the declared architecture and security-control policy. It reports change integrity, not a proof of security. + +

+ + Recorded dotnet-clean-arch/review replay of the real Vibgrate CLI + +

+

dotnet-clean-arch/reviewvg review on a Clean Architecture fixture. Live simulator.

+ +```bash +vg review +``` + --- ## Privacy & offline-first @@ -683,7 +747,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`). 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. 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 25c3f51..1a35f89 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.916.3' # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs + default: '2026.917.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 74028ea..9788971 100644 --- a/charts/vibgrate/Chart.yaml +++ b/charts/vibgrate/Chart.yaml @@ -7,7 +7,7 @@ type: application # 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.2 -appVersion: "2026.916.3" # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs +appVersion: "2026.917.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/docs/show-arch.md b/docs/show-arch.md index 13bf7a5..cf65664 100644 --- a/docs/show-arch.md +++ b/docs/show-arch.md @@ -32,6 +32,15 @@ Architecture off is the raw graph’s kinds in the same columns. Architecture on A rose mark is a finding with `line > 0`. A yellow mark is a missing step. They are not the same thing. +**Overlays** sit on the same Overview and Architecture canvas (the Health tab opens that chrome — it is not a blank page). Vulns, drift, ownership, and churn are toggles. The engine joins existing scan / CODEOWNERS / git data onto cards; missing source is omitted, never painted as a healthy zero. This is not an Architecture Health Score and not the Cloud ≈100−drift KPI. + +- **Vulns** — reachable findings from a connected `vg scan` (DSN online). No reachability → honest empty. +- **Drift** — dependency drift already on the scan artifact, joined by path (and import when the graph has it). `current` / `unknown` do not paint. +- **Ownership** — CODEOWNERS teams only. A team is not an architecture layer. +- **Churn** — relative git heat in a bounded commit window. No git → overlay disabled. + +The VS Code architecture board hosts the same page and payload as `vg show arch`. + `/api/overview` is the workspace map. `/api/slice?package=` is the column view. `/api/graph` is deprecated (it returns the overview). `/api/node/:id` is `vg show --json` plus a `view` block of the English labels. `/api/path` uses the same shortest-path engine as `vg path`. `/api/reach/:id` walks callers (`dir=up`) or callees (`dir=down`). Deep links use `#zoom=workspace` or `#zoom=slice&package=&n=`. `vg show arch --focus scanDir` opens the owning package’s slice with that symbol selected. diff --git a/package.json b/package.json index f814c37..d2ba0d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vibgrate/cli", - "version": "2026.916.3", + "version": "2026.917.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", @@ -98,7 +98,7 @@ "yaml": "^2.8.3" }, "optionalDependencies": { - "@anush008/tokenizers": "^0.6.0", + "tokenizers": "^0.23.2", "onnxruntime-node": "^1.29.0", "tar": "^7.5.21" }, diff --git a/packaging/homebrew-tap/Formula/vg.rb b/packaging/homebrew-tap/Formula/vg.rb index b27f214..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.916.3.tgz" - sha256 "d79cb7c6b9c5a3332eb31f5af130260612175d696b10262c6dcbfb01e63ab567" + 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 b0527ed..b76a0cc 100644 --- a/packaging/scoop-bucket/vg.json +++ b/packaging/scoop-bucket/vg.json @@ -1,5 +1,5 @@ { - "version": "2026.916.3", + "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/pnpm-lock.yaml b/pnpm-lock.yaml index 131a8e9..29816a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,15 +69,15 @@ importers: specifier: ^2.8.3 version: 2.9.0 optionalDependencies: - '@anush008/tokenizers': - specifier: ^0.6.0 - version: 0.6.0 onnxruntime-node: specifier: ^1.29.0 version: 1.29.0 tar: specifier: ^7.5.21 version: 7.5.22 + tokenizers: + specifier: ^0.23.2 + version: 0.23.2 devDependencies: '@tree-sitter-grammars/tree-sitter-hcl': specifier: 1.2.0 @@ -112,46 +112,6 @@ importers: packages: - '@anush008/tokenizers-darwin-universal@0.6.0': - resolution: {integrity: sha512-s27bL2x22NiDlhthi7UjGm+Is1tpAg4riLMBzna7Knzp26RdmnhbclWNEDd5UflRzFD49sdHzqv2XP0F2XFrqA==} - engines: {node: '>= 10'} - os: [darwin] - - '@anush008/tokenizers-linux-arm64-gnu@0.6.0': - resolution: {integrity: sha512-Go1ssG7AylDFpsDzLkh+nqXt1RcIGhL9pSpPH1nLF504lutxkCZcihESUt6vobtjnCONOAIwCWe1AVEoHui+9A==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@anush008/tokenizers-linux-arm64-musl@0.6.0': - resolution: {integrity: sha512-xfnDkjmOUXkVp5jNCnFzd8hTfAbei6UnoYetiadqbfX1o/K+drCV6sHzKadhHuPQlqa+erXdSyQDOtWkAAjN8Q==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@anush008/tokenizers-linux-x64-gnu@0.6.0': - resolution: {integrity: sha512-4C9KiC0tdsOJCyOE2sD23zZHW16RbytRfMf81bdkDOLOrvOy0dYT9ZTnpwEl2k7jgzSdSpk9YKCkfEpkqbVvVg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@anush008/tokenizers-linux-x64-musl@0.6.0': - resolution: {integrity: sha512-4CiqyQEFQpr7xTHv2IGZAoOKr/hIN7obTCaLvxUEjv3MHcpCGYBFpKa5RiRrcskY5k3w06dN9cRaxwsITeAHjw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@anush008/tokenizers-win32-x64-msvc@0.6.0': - resolution: {integrity: sha512-ydjCu4wLdtcS8xOr0uMbRAOHviuFNEMCFM/T/Sw4zxLb+iHS9QboSJZtxMN1VF2dOkjRchtQmLiKK0ttE1iJEQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@anush008/tokenizers@0.6.0': - resolution: {integrity: sha512-vbKFjlFM15Ljm6HMh2FSiyPq4236xHoe31XMveTMdwqowHf1/UCwySPT331wl/wRWpdHWdQ3eU5ENl5XbXnPQQ==} - engines: {node: '>= 10'} - deprecated: This package has been archived. Please use the official and updated "huggingface/tokenizers" package. - '@cacheable/memory@2.2.0': resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} @@ -513,7 +473,7 @@ packages: resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4.12.34 + hono: ^4 '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} @@ -1906,6 +1866,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tokenizers@0.23.2: + resolution: {integrity: sha512-OS99VXTXuGuyUncICcS0hEawgKQlrFVKNU+UlzRsTN03RpOeJiCmcai5nKg3JrdIvUDo7/RC5puVEjlJd2e5oA==} + engines: {node: '>= 10'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -2119,34 +2083,6 @@ packages: snapshots: - '@anush008/tokenizers-darwin-universal@0.6.0': - optional: true - - '@anush008/tokenizers-linux-arm64-gnu@0.6.0': - optional: true - - '@anush008/tokenizers-linux-arm64-musl@0.6.0': - optional: true - - '@anush008/tokenizers-linux-x64-gnu@0.6.0': - optional: true - - '@anush008/tokenizers-linux-x64-musl@0.6.0': - optional: true - - '@anush008/tokenizers-win32-x64-msvc@0.6.0': - optional: true - - '@anush008/tokenizers@0.6.0': - optionalDependencies: - '@anush008/tokenizers-darwin-universal': 0.6.0 - '@anush008/tokenizers-linux-arm64-gnu': 0.6.0 - '@anush008/tokenizers-linux-arm64-musl': 0.6.0 - '@anush008/tokenizers-linux-x64-gnu': 0.6.0 - '@anush008/tokenizers-linux-x64-musl': 0.6.0 - '@anush008/tokenizers-win32-x64-msvc': 0.6.0 - optional: true - '@cacheable/memory@2.2.0': dependencies: '@cacheable/utils': 2.5.0 @@ -3767,6 +3703,9 @@ snapshots: toidentifier@1.0.1: {} + tokenizers@0.23.2: + optional: true + tree-kill@1.2.2: {} tree-sitter-wasms@0.1.13: {} diff --git a/releases/v2026.917.1.md b/releases/v2026.917.1.md new file mode 100644 index 0000000..3494c44 --- /dev/null +++ b/releases/v2026.917.1.md @@ -0,0 +1,53 @@ +# Vibgrate CLI 2026.917.1 + +_Released 2026-09-17_ + +This release of the vg CLI introduces new features for enhanced architecture reviews and improved usability. Notable updates include the ability to view vulnerabilities and other metrics on the same canvas, as well as improvements to the public README with hosted CLI simulator GIFs. + +## What changed + +### New + +- `vg show arch` now displays vulnerabilities, drift, ownership, and churn as toggles on the Overview and Architecture canvas. +- `vg review findings-from-diff --format json` now includes architecture-policy rows alongside blast-radius as `kind: correctness`. +- `vg review findings-from-diff` provides deterministic Review findings for the current change or a saved `--diff`. + +### Improved + +- The public README now features hosted CLI simulator GIFs for main commands, enhancing user understanding. + +### Fixed + +- Local Code Mode Review now correctly writes PatchIR instead of stopping after three empty-reply steps. + +## Benchmarks + +Two-arm benchmark of this release against 2026.916.3, 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) | 25804 count | 25804 count | +| Call edges extracted (corpus total) | 17497 count | 17497 count | +| Locate accuracy (top-1) | 0.94 ratio | 0.94 ratio | +| Dependency detection (authored manifest truth) | 0.96 ratio | 0.96 ratio | +| CLI startup (--version, median) | 523.90 ms | 526.30 ms | + +6 regression(s) — published, not omitted: +- Graph build time (median per repo): 998 → 1118 (12.0%) +- Token reduction vs baseline agent (equal success): 0.37 → 0.23 (-37.3%) +- Tasks passed on both arms: 36 → 35 (-2.8%) +- Comparable-task rate (both arms passed / total): 0.95 → 0.92 (-2.8%) +- Agent tokens with vg (comparable tasks, total): 507315 → 603265 (18.9%) +- Tool calls per task with vg (median, comparable tasks): 6 → 7 (16.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.917.1 diff --git a/src/code/agent.ts b/src/code/agent.ts index b7b96bd..653e018 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 type { SymbolSpan } from './apply.js'; +import { 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'; @@ -1079,6 +1079,21 @@ export async function runAgent(options: AgentOptions): Promise { result = { ...result, text: rescued.text, toolCalls }; } } + // Local Code Modes (text-protocol) often emit the oneshot SEARCH/REPLACE + // residual instead of `` markup. On an edit-ask that is a real + // write — lift it to edit_file so the loop cannot die as no-tools after + // the empty-reply retries (3 steps). Q&A is left alone. + if ( + toolCalls.length === 0 && + (result.text ?? '').trim() && + instructionRequiresMutation(instruction) + ) { + const residual = residualEditsToToolCalls(result.text ?? ''); + if (residual.length) { + toolCalls = residual; + 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); @@ -1186,6 +1201,12 @@ export async function runAgent(options: AgentOptions): Promise { const dumpStop = looksLikeToolCallDump(fullText) || looksLikeBareJsonObject(fullText) || unknownThenBrokenDump; const stubAsk = editAskUnfulfilled && (!fullText || looksLikeFileStub(fullText)); + // A write already landed (residual rescue or a prior edit_file). Empty / + // dump replies after that are not a failed no-tools — the work is done. + if (changes.length > 0) { + const files = [...new Set(changes.map((c) => c.file))]; + return finish('finished', `Edited ${files.join(', ')}.`, step); + } return finish( 'no-tools', dumpStop @@ -1281,6 +1302,18 @@ export async function runAgent(options: AgentOptions): Promise { if (MUTATION_TOOLS.has(call.name) && !toolResult.finished) { failedMutationPending = !toolResult.mutated; } + // Stale residual after a write already landed (Code Mode re-emits the + // oneshot SEARCH/REPLACE). Do not burn the step cap — the work is done. + if ( + typeof call.id === 'string' && + call.id.startsWith('residual_') && + !toolResult.mutated && + !toolResult.finished && + changes.length > 0 + ) { + const files = [...new Set(changes.map((c) => c.file))]; + return finish('finished', `Edited ${files.join(', ')}.`, step); + } if (toolResult.failed && /\bunknown tool\b/i.test(toolResult.content)) { unknownToolFailures++; } else { @@ -1694,11 +1727,11 @@ 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` (any), or change/fix/replace plus a file path, or "so … return(s)". */ +/** Tight: `edit` / `edit_file`, 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\b/i.test(t)) return true; + if (/\bedit(?:_file)?\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 e63e2fa..083d74a 100644 --- a/src/code/apply.test.ts +++ b/src/code/apply.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseEdits, applyEdit, applyEdits, type SymbolSpan } from './apply.js'; +import { parseEdits, applyEdit, applyEdits, residualEditsToToolCalls, type SymbolSpan } from './apply.js'; describe('parseEdits', () => { it('parses a search/replace block with the file on the preceding line', () => { @@ -42,6 +42,33 @@ describe('parseEdits', () => { }); }); +describe('residualEditsToToolCalls', () => { + it('lifts SEARCH/REPLACE into edit_file and skips a path-less block', () => { + const calls = residualEditsToToolCalls( + ['src/scan.ts', '<<<<<<< SEARCH', 'const timeout = 0;', '=======', 'const timeout = 5000;', '>>>>>>> REPLACE'].join( + '\n', + ), + ); + expect(calls).toEqual([ + { + id: 'residual_0', + name: 'edit_file', + arguments: { path: 'src/scan.ts', search: 'const timeout = 0;', replace: 'const timeout = 5000;' }, + }, + ]); + expect(residualEditsToToolCalls(['<<<<<<< SEARCH', 'a', '=======', 'b', '>>>>>>> REPLACE'].join('\n'))).toEqual([]); + }); + + it('lifts CREATE and DELETE', () => { + const calls = residualEditsToToolCalls( + ['CREATE src/new.ts', 'export const y = 1;', 'END CREATE', 'DELETE src/old.ts'].join('\n'), + ); + expect(calls.map((c) => c.name)).toEqual(['create_file', 'delete_file']); + expect(calls[0].arguments).toEqual({ path: 'src/new.ts', content: 'export const y = 1;' }); + expect(calls[1].arguments).toEqual({ path: 'src/old.ts' }); + }); +}); + 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 3be7d1b..4d2a867 100644 --- a/src/code/apply.ts +++ b/src/code/apply.ts @@ -16,7 +16,7 @@ * benchmark-testable offline. */ -import type { CodeEdit, EditOutcome } from './types.js'; +import type { CodeEdit, EditOutcome, ToolCall } from './types.js'; /** A symbol span the graph knows about, used to disambiguate a SEARCH match. */ export interface SymbolSpan { @@ -120,6 +120,40 @@ export function parseEdits(text: string): CodeEdit[] { return edits; } +/** + * Lift residual SEARCH/REPLACE (the oneshot edit form) 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. + */ +export function residualEditsToToolCalls(text: string): ToolCall[] { + const edits = parseEdits(text); + const calls: ToolCall[] = []; + for (const [i, edit] of edits.entries()) { + if (!edit.file.trim()) continue; + if (edit.op === 'replace') { + calls.push({ + id: `residual_${i}`, + name: 'edit_file', + arguments: { path: edit.file, search: edit.search, replace: edit.replace }, + }); + } else if (edit.op === 'create') { + calls.push({ + id: `residual_${i}`, + name: 'create_file', + arguments: { path: edit.file, content: edit.content }, + }); + } else { + calls.push({ + id: `residual_${i}`, + name: 'delete_file', + arguments: { path: edit.file }, + }); + } + } + return calls; +} + 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 4d3acf0..3fa29dd 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('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); expect(instructionRequiresMutation('replace the greeting in src/greet.ts')).toBe(true); @@ -93,6 +94,43 @@ describe('loop-gate gold — Code Mode tool channel', () => { expect(p.id).toBe('llama-cpp'); }); + it('does not end as no-tools when a Code Mode emits residual SEARCH/REPLACE on an edit-ask', async () => { + // Live Code Mode Review (#2662): happy-path-loop died as no-tools after 3 + // steps because the pack printed the oneshot residual instead of + // markup. Lift that residual to edit_file so the loop + // produces a write within REVIEW_PROPOSE_LOOP_CAP / AGENT_EMPTY_REPLY_RETRIES. + const residual = [ + 'src/scan.ts', + '<<<<<<< SEARCH', + 'const timeout = 0;', + '=======', + 'const timeout = 5000;', + '>>>>>>> REPLACE', + ].join('\n'); + const backend = textBackend({ text: residual, model: 'forge-pack', provider: 'llama-cpp' }, false); + const events: Array<{ type: string; name?: string }> = []; + 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: '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, + 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); + expect(result.steps).toBeLessThanOrEqual(AGENT_EMPTY_REPLY_RETRIES + 1); + }); + it('does not end as silent no-tools when a Code Mode emits text-protocol markup', async () => { const backend = textBackend( { diff --git a/src/commands/arch.ts b/src/commands/arch.ts index b526ecd..e71bca1 100644 --- a/src/commands/arch.ts +++ b/src/commands/arch.ts @@ -58,7 +58,7 @@ function configure(chart: Command): void { } const sidecar = readHaileSidecar(graphPath); const provider = await loadHaileProvider(); - const overview = overviewOf(graph, sidecar, provider); + const overview = overviewOf(graph, sidecar, provider, root); const located = opts.focus ? locateInOverview(graph, opts.focus) : null; const url = located ? `${server.url}/#zoom=slice&package=${encodeURIComponent(located.packageId)}&n=${encodeURIComponent(located.nodeId)}` diff --git a/src/commands/review.ts b/src/commands/review.ts index 5cbe4a2..799e993 100644 --- a/src/commands/review.ts +++ b/src/commands/review.ts @@ -8,6 +8,7 @@ * vg review --loop review → deterministic patch → re-review (CLI only) * vg review --base origin/main merge-base of HEAD and base * vg review explain the evidence behind one finding + * vg review findings-from-diff deterministic graph/policy findings for this change (or `--diff`) * vg review propose PatchIR dry-run via the VG Code loop (`--apply --yes` to write) * vg review verify check a receipt's digest and Ed25519 signature offline * @@ -43,7 +44,8 @@ import { runReview, type RunReviewResult } from '../review/run.js'; import { resolveReviewSigningKey, verifyReceipt } from '../review/sign.js'; import { injectContextBlock, renderContext, writeContextFile } from '../review/context-file.js'; import { ensureCodeMap, reviewPolicyState, seedReviewPolicy } from '../review/prepare.js'; -import { defaultRun } from '../review/git.js'; +import { exportCorrectnessPublishRows } from '../review/finding-publish.js'; +import { changeSetFromUnifiedDiff, collectChangeSet, defaultRun } from '../review/git.js'; import { parseDsn } from '../reporting/commands/push.js'; import { resolveDsn } from '../reporting/credentials.js'; @@ -265,6 +267,75 @@ export function registerReview(program: Command): void { }); applyGlobalOptions(explain); + 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)', + ) + .option('--base ', 'review HEAD against the merge-base with (e.g. origin/main)') + .option( + '--in-place', + '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') + .action(async function ( + this: Command, + opts: { base?: string; inPlace?: boolean; diff?: string; format: string }, + ) { + const global = readGlobal(this); + const root = rootOf(global); + if (opts.format !== 'text' && opts.format !== 'json') { + throw new CliError('unknown --format (expected text | json)', ExitCode.USAGE_ERROR); + } + reportPrepare( + await ensureCodeMap({ + root, + graphPath: global.graph, + quiet: Boolean(global.quiet) || Boolean(global.json), + }), + Boolean(global.quiet) || Boolean(global.json), + ); + let change = collectChangeSet(root, opts.base, defaultRun, { inPlace: opts.inPlace }); + let diffText: string | undefined; + if (opts.diff) { + diffText = readDiffFile(opts.diff, root); + change = changeSetFromUnifiedDiff(change, diffText); + } + const reviewed = await runReview({ + root, + base: opts.base, + inPlace: opts.inPlace, + local: true, + offline: true, + graphPath: global.graph, + generatedAt: global.generatedAt, + signingKey: null, + change, + diffText, + }); + const findings = reviewed.receipt.findings; + 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. + out( + JSON.stringify( + { + ...findings, + publishable: exportCorrectnessPublishRows(findings), + }, + null, + 2, + ), + ); + return; + } + if (!global.quiet) { + info(formatFindingsFromDiff(reviewed)); + } + }); + applyGlobalOptions(findingsFromDiff); + const verify = cmd .command('verify') .description('verify a receipt offline — its content digest and its Ed25519 signature (no Vibgrate needed)') @@ -308,7 +379,7 @@ export function registerReview(program: Command): void { .description( `propose a PatchIR fix for one finding via the VG Code agent loop (dry-run; never writes the default branch)`, ) - .argument('', 'a finding id from the current change (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 ') .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) @@ -567,3 +638,65 @@ function writeAgentContext(result: RunReviewResult, opts: ReviewOpts, quiet: boo if (!quiet) info(c.dim(` managed review block updated in ${target}`)); } } + +/** Read a unified diff from a path, or stdin when the path is `-`. */ +function readDiffFile(spec: string, root: string): string { + if (spec === '-') { + return fs.readFileSync(0, 'utf8'); + } + const abs = path.resolve(root, spec); + if (!fs.existsSync(abs)) { + throw new CliError( + `no diff at ${spec} — pass a unified-diff file or \`-\` to read stdin`, + ExitCode.NOT_FOUND, + ); + } + return fs.readFileSync(abs, 'utf8'); +} + +/** + * 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 { + const all = [ + ...result.receipt.findings.architecture_findings, + ...result.receipt.findings.security_findings, + ]; + const lines: string[] = []; + lines.push(`${c.cyan('vg review findings-from-diff')} · ${all.length} finding(s)`); + lines.push( + c.dim( + ` ${result.receipt.git.dirty ? 'working tree / patch' : result.receipt.git.head_sha.slice(0, 8)} vs ${result.receipt.git.base_sha.slice(0, 8)}` + + ` · ${result.receipt.change_class.join(', ')}`, + ), + ); + lines.push(''); + if (all.length === 0) { + lines.push(c.dim(' no deterministic graph or policy findings in this change set')); + } + for (const f of all) { + const producer = f.producer && f.producer !== f.kind ? ` ${c.dim(f.producer)}` : ''; + lines.push(` ${c.bold(f.id)} ${c.dim(f.kind)}${producer} ${f.severity}`); + lines.push(` ${f.claim}`); + lines.push(c.dim(` → ${f.remediation}`)); + const range = result.capsule.evidence.find((e) => f.evidence_ids.includes(e.id) && e.start_line); + const loc = range?.start_line + ? `${f.paths[0] ?? ''}:${range.start_line}${range.end_line && range.end_line !== range.start_line ? `-${range.end_line}` : ''}` + : f.paths.slice(0, 3).join(', '); + lines.push(c.dim(` ${loc}`)); + lines.push(''); + } + if (result.receipt.findings.unknowns.length > 0) { + lines.push(c.yellow(' unknowns')); + for (const u of result.receipt.findings.unknowns) lines.push(c.dim(` · ${u}`)); + lines.push(''); + } + lines.push( + c.dim( + ` propose a PatchIR dry-run: vg review propose --model forge --json` + + (result.receipt.receipt_id ? ` · receipt ${result.receipt.receipt_id}` : ''), + ), + ); + return lines.join('\n'); +} diff --git a/src/engine/chart/arch-types.ts b/src/engine/chart/arch-types.ts index 3c41c0c..49bed3b 100644 --- a/src/engine/chart/arch-types.ts +++ b/src/engine/chart/arch-types.ts @@ -21,6 +21,53 @@ export function parseArchView(raw: string | null | undefined): ArchSliceView { return 'job'; } +export type ArchOverlayKind = 'vulns' | 'drift' | 'ownership' | 'churn'; + +/** + * One map overlay. The engine decides whether source data exists and how many + * nodes paint; the client only toggles and renders. `painted === 0` is not a + * healthy score — it is an honest empty (see `empty`). + */ +export interface ArchOverlayState { + kind: ArchOverlayKind; + /** True when the backing artifact/file/git history was found. */ + source: boolean; + /** Nodes that carry overlay marks. Absent data is omitted, never zeroed. */ + painted: number; + /** Operator-facing reason when the overlay cannot paint. */ + empty: string; +} + +export interface ArchOverlays { + vulns: ArchOverlayState; + drift: ArchOverlayState; + ownership: ArchOverlayState; + churn: ArchOverlayState; +} + +/** Worst drifted band among joined dependencies. `current` / `unknown` never paint. */ +export type ArchDriftBand = 'minor' | 'major'; + +export interface ArchDriftMark { + band: ArchDriftBand; + /** Distinct drifted dependency names, worst band first, capped. */ + packages: string[]; +} + +export interface ArchOwnershipMark { + /** CODEOWNERS teams/users for this path, last-match-wins, sorted. */ + teams: string[]; + /** Stable 0–7 colour bucket from the first team. Not a score. */ + tone: number; +} + +export interface ArchChurnMark { + /** Relative heat 1–5 among paths that have git history. Never 0. */ + heat: 1 | 2 | 3 | 4 | 5; + /** Commit touches in the bounded window. Present only when greater than 0. */ + commits: number; +} + export interface ArchPackageNode { id: string; name: string; @@ -36,6 +83,14 @@ export interface ArchPackageNode { unclassified?: number; /** Workspace lane for the board layout: 'ui' | 'app' | 'io' | 'unclassified'. */ lane: string; + /** Reachable-vulnerability hits rolled up from files under this package. */ + vulnerabilities?: ArchCardVuln[]; + /** Joined scan drift — omitted when the package has no drifted dependencies. */ + drift?: ArchDriftMark; + /** CODEOWNERS teams — omitted when no rule matches. */ + owners?: ArchOwnershipMark; + /** Bounded git churn — omitted when history is unavailable for this path. */ + churn?: ArchChurnMark; } export interface ArchPackageEdge { @@ -62,6 +117,8 @@ export interface ArchOverview { packages: ArchPackageNode[]; edges: ArchPackageEdge[]; meta: ArchOverviewMeta; + /** Overlay availability + honest empty copy. Absent when the host did not pass a repo root. */ + overlays?: ArchOverlays; } export interface ArchCardLink { @@ -119,6 +176,12 @@ export interface ArchCard { findings?: ArchCardFinding[]; /** Reachable-vulnerability hits (from `vg scan`'s local reachability query) rolled up from every member. */ vulnerabilities?: ArchCardVuln[]; + /** Joined scan drift — omitted when this card has no drifted dependencies. */ + drift?: ArchDriftMark; + /** CODEOWNERS teams — omitted when no rule matches. */ + owners?: ArchOwnershipMark; + /** Bounded git churn — omitted when history is unavailable for this card's files. */ + churn?: ArchChurnMark; } export interface ArchCardFinding { @@ -168,6 +231,8 @@ export interface ArchSlice { overflowHint?: Record; emptyHint?: string | null; focusCardId: string | null; + /** Overlay availability + honest empty copy. Absent when the host did not pass a repo root. */ + overlays?: ArchOverlays; } export interface ArchSliceSpec { diff --git a/src/engine/chart/churn-annotations.ts b/src/engine/chart/churn-annotations.ts new file mode 100644 index 0000000..864908d --- /dev/null +++ b/src/engine/chart/churn-annotations.ts @@ -0,0 +1,65 @@ +/** + * Bounded git churn rollup for architecture overlays. + * + * One `git log --name-only` (capped) — never per-file `fileCommits`. + * Paths with no history are omitted (absent ≠ zero). Heat is relative + * among paths that actually have commits on this map. + */ +import type { ArchCard, ArchChurnMark, ArchOverview, ArchSlice } from './arch-types.js'; +import { heatOf, pathUnder, posixPath, type OverlayContext } from './overlay-context.js'; + +export function withOverviewChurn(overview: ArchOverview, ctx: OverlayContext): ArchOverview { + if (!ctx.churn) return overview; + const counts = overview.packages.map((pkg) => churnCountForPrefix(pkg.path, ctx.churn!)); + const positives = counts.filter((n) => n > 0); + let changed = false; + const packages = overview.packages.map((pkg, i) => { + const mark = markFromCount(counts[i] ?? 0, positives); + if (!mark) return pkg; + changed = true; + return { ...pkg, churn: mark }; + }); + return changed ? { ...overview, packages } : overview; +} + +export function withSliceChurn(slice: ArchSlice, ctx: OverlayContext): ArchSlice { + if (!ctx.churn) return slice; + const cardFiles = slice.columns.flatMap((col) => col.cards.map((card) => cardFilesOf(card))); + const counts = cardFiles.map((files) => files.reduce((n, f) => n + (ctx.churn!.get(f) ?? 0), 0)); + const positives = counts.filter((n) => n > 0); + let changed = false; + let i = 0; + const columns = slice.columns.map((col) => { + let colChanged = false; + const cards = col.cards.map((card) => { + const mark = markFromCount(counts[i] ?? 0, positives); + i += 1; + if (!mark) return card; + colChanged = true; + return { ...card, churn: mark }; + }); + if (!colChanged) return col; + changed = true; + return { ...col, cards }; + }); + return changed ? { ...slice, columns } : slice; +} + +function cardFilesOf(card: ArchCard): string[] { + return [card.file, ...(card.members ?? []).map((m) => m.file)].filter(Boolean).map(posixPath); +} + +function churnCountForPrefix(dir: string, churn: Map): number { + const prefix = posixPath(dir); + let n = 0; + for (const [file, count] of churn) { + if (pathUnder(file, prefix)) n += count; + } + return n; +} + +function markFromCount(count: number, positives: number[]): ArchChurnMark | undefined { + const heat = heatOf(count, positives); + if (!heat) return undefined; + return { heat, commits: count }; +} diff --git a/src/engine/chart/drift-annotations.ts b/src/engine/chart/drift-annotations.ts new file mode 100644 index 0000000..8ee9e86 --- /dev/null +++ b/src/engine/chart/drift-annotations.ts @@ -0,0 +1,161 @@ +/** + * Join existing scan drift onto architecture cards and overview packages. + * + * Does not recompute DriftScore and does not invent a map-level health score. + * A package or card paints only when at least one joined dependency is + * actually behind (`minor-behind` / `major-behind`). `current` and `unknown` + * are omitted — absent ≠ zero, and "all current" is not painted as healthy. + * + * Joins: + * - path: `ProjectScan.path` ↔ package.path / card.file prefix (longest match) + * - import: graph `import` / `depends_on` edges from card/package files to a + * drifted dependency name + */ +import type { ArchCard, ArchDriftMark, ArchOverview, ArchSlice } from './arch-types.js'; +import { + MAX_OVERLAY_PACKAGES, + pathUnder, + posixPath, + type OverlayContext, + type OverlayProjectDrift, +} from './overlay-context.js'; + +export function withOverviewDrift(overview: ArchOverview, ctx: OverlayContext): ArchOverview { + if (!ctx.projects.length) return overview; + let changed = false; + const packages = overview.packages.map((pkg) => { + const mark = driftForPaths([pkg.path], ctx, pkg.path); + if (!mark) return pkg; + changed = true; + return { ...pkg, drift: mark }; + }); + return changed ? { ...overview, packages } : overview; +} + +export function withSliceDrift(slice: ArchSlice, ctx: OverlayContext): ArchSlice { + if (!ctx.projects.length && !ctx.graph) return slice; + let changed = false; + const columns = slice.columns.map((col) => { + let colChanged = false; + const cards = col.cards.map((card) => { + const mark = driftForCard(card, ctx); + if (!mark) return card; + colChanged = true; + return { ...card, drift: mark }; + }); + if (!colChanged) return col; + changed = true; + return { ...col, cards }; + }); + return changed ? { ...slice, columns } : slice; +} + +function driftForCard(card: ArchCard, ctx: OverlayContext): ArchDriftMark | undefined { + const files = [card.file, ...(card.members ?? []).map((m) => m.file)].filter(Boolean); + return driftForPaths(files, ctx); +} + +function driftForPaths(paths: string[], ctx: OverlayContext, packagePath?: string): ArchDriftMark | undefined { + const files = paths.map(posixPath).filter(Boolean); + const fromProjects = driftedFromProjects(files, ctx.projects, packagePath); + const fromImports = ctx.graph ? driftedFromImports(files, ctx) : []; + return mergeMarks([...fromProjects, ...fromImports]); +} + +function driftedFromProjects( + files: string[], + projects: OverlayProjectDrift[], + packagePath?: string, +): Array<{ package: string; band: 'minor' | 'major' }> { + const project = matchProject(files, projects, packagePath); + return project?.drifted ?? []; +} + +function matchProject( + files: string[], + projects: OverlayProjectDrift[], + packagePath?: string, +): OverlayProjectDrift | undefined { + const needles = packagePath ? [posixPath(packagePath), ...files] : files; + let best: OverlayProjectDrift | undefined; + let bestRank = -1; + for (const project of projects) { + if (!project.drifted.length) continue; + const hit = needles.some((f) => pathUnder(f, project.path) || (packagePath && pathUnder(project.path, packagePath))); + if (!hit) continue; + const rank = project.path === '.' ? 0 : project.path.split('/').filter(Boolean).length; + if (rank > bestRank) { + best = project; + bestRank = rank; + } + } + return best; +} + +function driftedFromImports( + files: string[], + ctx: OverlayContext, +): Array<{ package: string; band: 'minor' | 'major' }> { + const graph = ctx.graph; + if (!graph) return []; + const fileSet = new Set(files); + const byName = new Map(); + for (const project of ctx.projects) { + for (const dep of project.drifted) { + const prev = byName.get(dep.package); + if (!prev || (dep.band === 'major' && prev === 'minor')) byName.set(dep.package, dep.band); + } + } + if (!byName.size) return []; + + const nodeById = new Map(graph.nodes.map((n) => [n.id, n])); + const hits: Array<{ package: string; band: 'minor' | 'major' }> = []; + const seen = new Set(); + for (const edge of graph.edges) { + if (edge.kind !== 'import' && edge.kind !== 'depends_on' && edge.kind !== 'references') continue; + const src = nodeById.get(edge.src); + const dst = nodeById.get(edge.dst); + if (!src || !dst) continue; + if (!fileSet.has(posixPath(src.file))) continue; + const names = [dst.name, dst.qualifiedName].filter(Boolean); + for (const name of names) { + const band = byName.get(name) ?? scopedBand(name, byName); + if (!band) continue; + const pkg = name.includes('/') ? name : (matchName(name, byName) ?? name); + if (seen.has(pkg)) continue; + seen.add(pkg); + hits.push({ package: pkg, band }); + } + } + return hits; +} + +function scopedBand(name: string, byName: Map): 'minor' | 'major' | undefined { + const matched = matchName(name, byName); + return matched ? byName.get(matched) : undefined; +} + +function matchName(name: string, byName: Map): string | undefined { + if (byName.has(name)) return name; + for (const pkg of byName.keys()) { + if (name === pkg || name.endsWith(`/${pkg}`) || pkg.endsWith(`/${name}`)) return pkg; + } + return undefined; +} + +function mergeMarks(rows: Array<{ package: string; band: 'minor' | 'major' }>): ArchDriftMark | undefined { + if (!rows.length) return undefined; + const byPkg = new Map(); + for (const row of rows) { + const prev = byPkg.get(row.package); + if (!prev || (row.band === 'major' && prev === 'minor')) byPkg.set(row.package, row.band); + } + const packages = [...byPkg.entries()] + .sort((a, b) => (a[1] === b[1] ? a[0].localeCompare(b[0]) : a[1] === 'major' ? -1 : 1)) + .slice(0, MAX_OVERLAY_PACKAGES); + if (!packages.length) return undefined; + return { + band: packages[0]![1], + packages: packages.map(([name]) => name), + }; +} diff --git a/src/engine/chart/fallback-page.ts b/src/engine/chart/fallback-page.ts index 08c3c17..977bf2f 100644 --- a/src/engine/chart/fallback-page.ts +++ b/src/engine/chart/fallback-page.ts @@ -63,6 +63,17 @@ button { font:inherit; color:inherit; cursor:pointer; height:34px; padding:0 10p .openfile { background:none; border:none; padding:0; height:auto; color:var(--link); text-decoration:underline; cursor:pointer; font:inherit; text-align:left; } .lbl { font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--tl); margin:14px 0 6px; } a { color:var(--link); } +.overlay-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;padding:6px 12px;border-bottom:1px solid var(--line);background:var(--s1);font-size:12px} +.overlay-toggles{display:flex;flex-wrap:wrap;gap:6px} +.overlay-toggles button{height:28px;padding:0 10px} +.overlay-toggles button[aria-pressed="true"]{border-color:var(--green)} +.overlay-empty{margin:0;color:var(--tm);font-size:12px} +[data-overlay="vulns"] .pkg:not(.ov-vuln),[data-overlay="vulns"] .card:not(.ov-vuln), +[data-overlay="drift"] .pkg:not(.ov-drift),[data-overlay="drift"] .card:not(.ov-drift), +[data-overlay="ownership"] .pkg:not(.ov-owner),[data-overlay="ownership"] .card:not(.ov-owner), +[data-overlay="churn"] .pkg:not(.ov-churn),[data-overlay="churn"] .card:not(.ov-churn){opacity:.28} +.ov-drift-major,.ov-churn-5,.ov-vuln{border-left-color:#ef4444} +.ov-drift-minor,.ov-churn-3{border-left-color:#fb923c} @media (max-width:860px) { .work { grid-template-columns:1fr; } .drawer { border-left:0; border-top:1px solid var(--line); } } @@ -74,6 +85,16 @@ a { color:var(--link); }
Loading the map…
+
+ Overlays +
+ + + + +
+ +
@@ -86,7 +107,7 @@ if(HOST==="vscode"){ const light=document.body.classList.contains("vscode-light")||document.body.classList.contains("vscode-high-contrast-light"); document.documentElement.setAttribute("data-theme",light?"light":"dark"); } -const state = { zoom:"workspace", packageId:null, focus:null, overview:null, slice:null, pendingPackage:null }; +const state = { zoom:"workspace", packageId:null, focus:null, overview:null, slice:null, pendingPackage:null, overlay:null }; function esc(s){ return String(s==null?"":s).replace(/[&<>"']/g, function(c){ if (c==="&") return "&"; @@ -137,6 +158,43 @@ function boot(){ if (vscode) { vscode.postMessage({ type:"ready" }); return; } api("/api/overview", function(body){ state.overview = body; afterOverview(); }); } +function overlayClass(node){ + let cls = ""; + if (node.vulnerabilities && node.vulnerabilities.length) cls += " ov-vuln"; + if (node.drift) cls += " ov-drift ov-drift-"+node.drift.band; + if (node.owners) cls += " ov-owner"; + if (node.churn) cls += " ov-churn ov-churn-"+node.churn.heat; + return cls; +} +function overlayMeta(){ + return (state.zoom==="slice" && state.slice && state.slice.overlays) ? state.slice.overlays : (state.overview && state.overview.overlays) || null; +} +function syncOverlayBar(){ + document.documentElement.setAttribute("data-overlay", state.overlay || ""); + const overlays = overlayMeta(); + document.querySelectorAll("#overlayBar [data-overlay]").forEach(function(b){ + const kind = b.getAttribute("data-overlay"); + const meta = overlays && overlays[kind]; + b.setAttribute("aria-pressed", String(state.overlay===kind)); + b.setAttribute("aria-disabled", String(meta ? !meta.source : false)); + }); + const empty = document.getElementById("overlayEmpty"); + const selected = state.overlay && overlays ? overlays[state.overlay] : null; + if (empty) { + const show = !!(selected && selected.painted===0); + empty.hidden = !show; + empty.textContent = show ? selected.empty : ""; + } +} +document.querySelectorAll("#overlayBar [data-overlay]").forEach(function(b){ + b.onclick = function(){ + const kind = b.getAttribute("data-overlay"); + state.overlay = state.overlay===kind ? null : kind; + syncOverlayBar(); + if (state.zoom==="slice" && state.slice) drawSlice(); + else if (state.overview) drawWorkspace(); + }; +}); function drawWorkspace(){ state.zoom = "workspace"; state.slice = null; state.packageId = null; document.getElementById("back").hidden = true; @@ -149,13 +207,14 @@ function drawWorkspace(){ const grid = stage.firstChild; for (const pkg of state.overview.packages) { const b = document.createElement("button"); - b.className = "pkg"; + b.className = "pkg"+overlayClass(pkg); b.type = "button"; b.innerHTML = ""+esc(pkg.name)+""+esc(pkg.mix || (pkg.job+" · "+pkg.symbols+" symbols"))+""; b.onclick = function(){ openSlice(pkg.id); }; grid.appendChild(b); } writeHash(); + syncOverlayBar(); } function openSlice(packageId, expand){ if (vscode) { vscode.postMessage({ type:"openSlice", packageId:packageId, view:"job", focus:state.focus, arch:true, expand:!!expand }); return; } @@ -179,7 +238,7 @@ function drawSlice(){ stack.className = "stack"; for (const card of col.cards) { const b = document.createElement("button"); - b.className = "card"; + b.className = "card"+overlayClass(card); b.type = "button"; if (HOST !== "vscode" && card.color) b.style.borderLeftColor = card.color; b.innerHTML = ""+esc(card.title)+""+esc(card.subtitle)+"" @@ -203,6 +262,7 @@ function drawSlice(){ stage.innerHTML = ""; stage.appendChild(cols); writeHash(); + syncOverlayBar(); if (state.slice.focusCardId) { const card = state.slice.columns.flatMap(function(c){ return c.cards; }).concat(state.slice.guards||[]).find(function(c){ return c.id === state.slice.focusCardId; }); if (card) openCard(card); diff --git a/src/engine/chart/index.ts b/src/engine/chart/index.ts index b906441..ec5bc2b 100644 --- a/src/engine/chart/index.ts +++ b/src/engine/chart/index.ts @@ -6,6 +6,18 @@ export { projectChart, showJsonFor, pathJson, searchNodes, searchGraph, viewNode export type { ChartPayload, ChartNodeView, ChartMeta } from './model.js'; export { projectOverview, locateInOverview } from './overview.js'; export { projectSlice } from './slice.js'; -export type { ArchOverview, ArchSlice, ArchCard, ArchPackageNode } from './arch-types.js'; +export type { + ArchOverview, + ArchSlice, + ArchCard, + ArchPackageNode, + ArchOverlays, + ArchOverlayState, + ArchOverlayKind, + ArchDriftMark, + ArchOwnershipMark, + ArchChurnMark, +} from './arch-types.js'; export { parseArchView } from './arch-types.js'; +export { withArchOverviewOverlays, withArchSliceOverlays } from './overlays.js'; export { roleLabel, purposeLabel, kindLabel, policyLabel } from './labels.js'; diff --git a/src/engine/chart/labels.test.ts b/src/engine/chart/labels.test.ts index 81e0750..2483be0 100644 --- a/src/engine/chart/labels.test.ts +++ b/src/engine/chart/labels.test.ts @@ -31,6 +31,8 @@ describe('chart page chrome', () => { expect(html).not.toContain('HAILE'); expect(html).not.toContain('confidence'); expect(html).not.toContain('node_id'); + expect(html).toContain('data-overlay="vulns"'); + expect(html).not.toMatch(/Architecture Health Score/i); }); it('opens on packages, not a 29k-symbol camera', () => { diff --git a/src/engine/chart/overlay-context.ts b/src/engine/chart/overlay-context.ts new file mode 100644 index 0000000..736593e --- /dev/null +++ b/src/engine/chart/overlay-context.ts @@ -0,0 +1,277 @@ +/** + * Shared, best-effort loaders for architecture-map overlays. + * Never throws. Absent source is `null` / empty — never a fabricated zero. + */ +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { ScanArtifact, ScanReachabilityFinding } from '../../core-open/index.js'; +import type { VgGraph } from '../../schema.js'; + +export const SCAN_ARTIFACT_REL = '.vibgrate/scan_result.json'; +export const CHURN_COMMIT_CAP = 400; +export const MAX_OVERLAY_PACKAGES = 6; + +export interface OverlayProjectDrift { + path: string; + name: string; + /** Dependencies that are actually behind — never `current` / `unknown`. */ + drifted: Array<{ package: string; band: 'minor' | 'major' }>; +} + +export interface CodeOwnerRule { + pattern: string; + owners: string[]; +} + +export interface OverlayContext { + root: string; + graph: VgGraph | null; + artifact: Partial | null; + reachability: ScanReachabilityFinding[]; + projects: OverlayProjectDrift[]; + /** Null when no CODEOWNERS file exists. */ + owners: CodeOwnerRule[] | null; + /** Null when git history is unavailable. Empty map = git ok, no touches. */ + churn: Map | null; +} + +const contextCache = new Map(); + +export function posixPath(file: string): string { + return file.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''); +} + +export function pathUnder(file: string, dir: string): boolean { + const f = posixPath(file); + const d = posixPath(dir); + if (!d || d === '.') return true; + return f === d || f.startsWith(`${d}/`); +} + +export function loadOverlayContext(root: string, graph?: VgGraph | null): OverlayContext { + const key = cacheKey(root); + const hit = contextCache.get(key); + if (hit) { + if (graph && !hit.graph) hit.graph = graph; + return hit; + } + const artifact = loadScanArtifact(root); + const ctx: OverlayContext = { + root, + graph: graph ?? null, + artifact, + reachability: Array.isArray(artifact?.reachability?.findings) ? artifact!.reachability!.findings : [], + projects: projectDriftOf(artifact), + owners: loadCodeowners(root), + churn: loadPathChurn(root), + }; + contextCache.set(key, ctx); + return ctx; +} + +/** Test-only: drop the process-local overlay cache. */ +export function resetOverlayContextCache(): void { + contextCache.clear(); +} + +function cacheKey(root: string): string { + const abs = path.resolve(root); + const scan = stamp(path.join(abs, SCAN_ARTIFACT_REL)); + const owners = CODEOWNERS_RELS.map((rel) => stamp(path.join(abs, rel))).join(','); + const head = gitHead(abs); + return `${abs}|${scan}|${owners}|${head}`; +} + +function stamp(abs: string): string { + try { + const st = fs.statSync(abs); + return `${st.mtimeMs}:${st.size}`; + } catch { + return '0'; + } +} + +function gitHead(root: string): string { + try { + return execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { + timeout: 4_000, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + env: gitEnv(), + }).trim(); + } catch { + return '0'; + } +} + +export function loadScanArtifact(root: string): Partial | null { + try { + const abs = path.join(root, SCAN_ARTIFACT_REL); + if (!fs.existsSync(abs)) return null; + const parsed = JSON.parse(fs.readFileSync(abs, 'utf8')) as Partial; + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + +function projectDriftOf(artifact: Partial | null): OverlayProjectDrift[] { + if (!artifact || !Array.isArray(artifact.projects)) return []; + const out: OverlayProjectDrift[] = []; + for (const project of artifact.projects) { + if (!project || typeof project !== 'object') continue; + const projPath = typeof project.path === 'string' ? posixPath(project.path) : ''; + const name = typeof project.name === 'string' ? project.name : projPath; + const drifted: OverlayProjectDrift['drifted'] = []; + const seen = new Set(); + for (const dep of project.dependencies ?? []) { + if (!dep || typeof dep !== 'object') continue; + const band = driftBand(dep.drift); + if (!band || typeof dep.package !== 'string' || !dep.package) continue; + if (seen.has(dep.package)) continue; + seen.add(dep.package); + drifted.push({ package: dep.package, band }); + } + drifted.sort((a, b) => (a.band === b.band ? a.package.localeCompare(b.package) : a.band === 'major' ? -1 : 1)); + out.push({ path: projPath, name, drifted }); + } + out.sort((a, b) => a.path.localeCompare(b.path) || a.name.localeCompare(b.name)); + return out; +} + +export function driftBand(raw: unknown): 'minor' | 'major' | null { + if (raw === 'major-behind' || raw === 'major') return 'major'; + if (raw === 'minor-behind' || raw === 'minor' || raw === 'patch-behind' || raw === 'patch') return 'minor'; + return null; +} + +const CODEOWNERS_RELS = ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS'] as const; + +export function loadCodeowners(root: string): CodeOwnerRule[] | null { + for (const rel of CODEOWNERS_RELS) { + const abs = path.join(root, rel); + try { + if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue; + return parseCodeowners(fs.readFileSync(abs, 'utf8')); + } catch { + continue; + } + } + return null; +} + +export function parseCodeowners(text: string): CodeOwnerRule[] { + const rules: CodeOwnerRule[] = []; + for (const raw of text.split(/\r?\n/)) { + const line = raw.replace(/#.*$/, '').trim(); + if (!line) continue; + const parts = line.split(/\s+/); + const pattern = parts[0]; + if (!pattern) continue; + const owners = [...new Set(parts.slice(1).filter((p) => p.startsWith('@') || p.includes('@')))].sort((a, b) => + a.localeCompare(b), + ); + if (!owners.length) continue; + rules.push({ pattern, owners }); + } + return rules; +} + +/** + * Last-match-wins, GitHub-style. `*` / `**` / `?`, optional leading `/` (repo root), + * trailing `/` (directory prefix). Unanchored patterns match any path segment. + */ +export function matchCodeownersPattern(pattern: string, file: string): boolean { + const f = posixPath(file); + let p = pattern.replace(/\\/g, '/'); + const anchored = p.startsWith('/'); + if (anchored) p = p.slice(1); + const dirOnly = p.endsWith('/'); + if (dirOnly) p = p.slice(0, -1); + if (!p) return anchored ? true : f.length > 0; + const escaped = p + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '{{GS}}') + .replace(/\*/g, '[^/]*') + .replace(/\?/g, '[^/]') + .replace(/\{\{GS\}\}/g, '.*'); + const suffix = dirOnly ? '(?:/.*)?' : ''; + try { + if (anchored) return new RegExp(`^${escaped}${suffix}$`).test(f); + return new RegExp(`(?:^|/)${escaped}${suffix}$`).test(f); + } catch { + return false; + } +} + +export function ownersForPath(rules: CodeOwnerRule[], file: string): string[] | undefined { + let matched: string[] | undefined; + for (const rule of rules) { + if (matchCodeownersPattern(rule.pattern, file)) matched = rule.owners; + } + return matched; +} + +export function ownerTone(team: string): number { + let h = 0; + for (let i = 0; i < team.length; i++) h = (Math.imul(h, 31) + team.charCodeAt(i)) >>> 0; + return h % 8; +} + +export function loadPathChurn(root: string): Map | null { + try { + const inside = execFileSync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree'], { + timeout: 5_000, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + env: gitEnv(), + }).trim(); + if (inside !== 'true') return null; + } catch { + return null; + } + try { + const out = execFileSync( + 'git', + ['-C', root, 'log', '--name-only', '--pretty=format:', `--max-count=${CHURN_COMMIT_CAP}`, '--no-merges'], + { + timeout: 15_000, + maxBuffer: 8 * 1024 * 1024, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + env: gitEnv(), + }, + ); + const counts = new Map(); + for (const line of out.split('\n')) { + const file = posixPath(line.trim()); + if (!file) continue; + counts.set(file, (counts.get(file) ?? 0) + 1); + } + return counts; + } catch { + return new Map(); + } +} + +export function heatOf(count: number, positives: number[]): 1 | 2 | 3 | 4 | 5 | null { + if (count <= 0 || !positives.length) return null; + const sorted = [...positives].filter((n) => n > 0).sort((a, b) => a - b); + if (!sorted.length) return null; + const rank = sorted.filter((n) => n <= count).length / sorted.length; + if (rank <= 0.2) return 1; + if (rank <= 0.4) return 2; + if (rank <= 0.6) return 3; + if (rank <= 0.8) return 4; + return 5; +} + +function gitEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + GIT_OPTIONAL_LOCKS: '0', + GIT_PAGER: 'cat', + }; +} diff --git a/src/engine/chart/overlays.test.ts b/src/engine/chart/overlays.test.ts new file mode 100644 index 0000000..6e02449 --- /dev/null +++ b/src/engine/chart/overlays.test.ts @@ -0,0 +1,283 @@ +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { ArchOverview, ArchSlice } from './arch-types.js'; +import { resetOverlayContextCache } from './overlay-context.js'; +import { matchCodeownersPattern, parseCodeowners, heatOf, ownerTone } from './overlay-context.js'; +import { withArchOverviewOverlays, withArchSliceOverlays } from './overlays.js'; +import { sanitizeOverview, sanitizeSlice } from './sanitize.js'; + +let dir: string | undefined; + +afterEach(() => { + resetOverlayContextCache(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; +}); + +function tmp(): string { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-ov-')); + return dir; +} + +function writeScan(root: string, body: unknown): void { + fs.mkdirSync(path.join(root, '.vibgrate'), { recursive: true }); + fs.writeFileSync(path.join(root, '.vibgrate', 'scan_result.json'), JSON.stringify(body)); +} + +function baseOverview(): ArchOverview { + return { + magic: 'vg.arch.overview.v1', + packages: [ + { + id: 'pkg-api', + name: 'api', + path: 'packages/api', + kind: 'package', + symbols: 4, + findings: 0, + missingSteps: 0, + job: 'service', + policy: null, + lane: 'app', + }, + { + id: 'pkg-web', + name: 'web', + path: 'packages/web', + kind: 'package', + symbols: 2, + findings: 0, + missingSteps: 0, + job: 'web', + policy: null, + lane: 'ui', + }, + ], + edges: [], + meta: { + architectureLoaded: false, + policy: null, + policyLabel: null, + symbols: 6, + packages: 2, + findings: 0, + missingSteps: 0, + title: 'Code map', + }, + }; +} + +function baseSlice(): ArchSlice { + return { + magic: 'vg.arch.slice.v1', + packageId: 'pkg-api', + packageName: 'api', + policy: 'hexagonal-v1', + columns: [ + { + id: 'app', + title: 'Application', + cards: [ + { + id: 'card:billing', + title: 'BillingService', + subtitle: 'Service', + lane: 'app', + file: 'packages/api/src/Billing.ts', + line: 10, + symbolId: 'BillingService', + count: 1, + job: 'Service', + color: '#22c55e', + classified: true, + pulse: false, + missingStep: false, + }, + ], + }, + ], + guards: [], + edges: [], + overflow: {}, + focusCardId: null, + }; +} + +describe('CODEOWNERS parser', () => { + it('is last-match-wins and ownership-only', () => { + const rules = parseCodeowners(` +# comment +* @org/default +/packages/api/ @team-api +*.md @docs +`); + expect(rules).toHaveLength(3); + expect(matchCodeownersPattern('/packages/api/', 'packages/api/src/Billing.ts')).toBe(true); + expect(matchCodeownersPattern('*.md', 'docs/guide.md')).toBe(true); + expect(matchCodeownersPattern('*.md', 'packages/api/src/Billing.ts')).toBe(false); + }); + + it('hashes a team to a stable tone, not a score', () => { + expect(ownerTone('@team-api')).toBe(ownerTone('@team-api')); + expect(ownerTone('@team-api')).toBeGreaterThanOrEqual(0); + expect(ownerTone('@team-api')).toBeLessThan(8); + }); +}); + +describe('churn heat', () => { + it('omits zero and ranks only among positive counts', () => { + expect(heatOf(0, [1, 2, 3])).toBeNull(); + expect(heatOf(3, [1, 2, 3, 4, 5])).toBeGreaterThanOrEqual(1); + expect(heatOf(5, [1, 2, 3, 4, 5])).toBe(5); + }); +}); + +describe('architecture overlays', () => { + it('is honest-empty when the repo has no scan, owners, or git', () => { + const root = tmp(); + const overview = withArchOverviewOverlays(baseOverview(), root); + expect(overview.overlays?.vulns.source).toBe(false); + expect(overview.overlays?.drift.source).toBe(false); + expect(overview.overlays?.ownership.source).toBe(false); + expect(overview.overlays?.churn.source).toBe(false); + expect(overview.overlays?.vulns.painted).toBe(0); + expect(overview.packages.every((p) => !p.vulnerabilities && !p.drift && !p.owners && !p.churn)).toBe(true); + expect(overview.overlays?.vulns.empty).toMatch(/reachability/i); + expect(JSON.stringify(overview)).not.toMatch(/Architecture Health Score/i); + expect(JSON.stringify(overview)).not.toMatch(/HAILE/i); + }); + + it('paints vulns on the matching card and package, never not_reached', () => { + const root = tmp(); + writeScan(root, { + reachability: { + findings: [ + { + advisoryId: 'GHSA-xxxx', + package: 'left-pad', + tier: 'reachable', + sites: [{ file: 'packages/api/src/Billing.ts' }], + evidence: 'imported in packages/api/src/Billing.ts', + }, + { + advisoryId: 'GHSA-skip', + package: 'left-pad', + tier: 'not_reached', + sites: [{ file: 'packages/web/src/Home.tsx' }], + }, + ], + }, + }); + const slice = withArchSliceOverlays(baseSlice(), root); + expect(slice.columns[0]?.cards[0]?.vulnerabilities?.[0]?.advisoryId).toBe('GHSA-xxxx'); + expect(slice.overlays?.vulns.source).toBe(true); + expect(slice.overlays?.vulns.painted).toBe(1); + const overview = withArchOverviewOverlays(baseOverview(), root); + expect(overview.packages.find((p) => p.id === 'pkg-api')?.vulnerabilities?.[0]?.package).toBe('left-pad'); + expect(overview.packages.find((p) => p.id === 'pkg-web')?.vulnerabilities).toBeUndefined(); + }); + + it('joins scan drift by path and omits current/unknown', () => { + const root = tmp(); + writeScan(root, { + projects: [ + { + path: 'packages/api', + name: 'api', + dependencies: [ + { package: 'left-pad', drift: 'major-behind' }, + { package: 'fresh', drift: 'current' }, + { package: 'mystery', drift: 'unknown' }, + ], + }, + ], + }); + const overview = withArchOverviewOverlays(baseOverview(), root); + const api = overview.packages.find((p) => p.id === 'pkg-api'); + const web = overview.packages.find((p) => p.id === 'pkg-web'); + expect(api?.drift).toEqual({ band: 'major', packages: ['left-pad'] }); + expect(web?.drift).toBeUndefined(); + expect(overview.overlays?.drift.source).toBe(true); + expect(overview.overlays?.drift.painted).toBe(1); + const slice = withArchSliceOverlays(baseSlice(), root); + expect(slice.columns[0]?.cards[0]?.drift?.band).toBe('major'); + expect(slice.columns[0]?.cards[0]?.drift?.packages).toEqual(['left-pad']); + }); + + it('joins CODEOWNERS to cards without inventing layers', () => { + const root = tmp(); + fs.mkdirSync(path.join(root, '.github'), { recursive: true }); + fs.writeFileSync(path.join(root, '.github', 'CODEOWNERS'), '* @org/default\n/packages/api/ @team-api\n'); + const overview = withArchOverviewOverlays(baseOverview(), root); + expect(overview.packages.find((p) => p.id === 'pkg-api')?.owners?.teams).toEqual(['@team-api']); + expect(overview.packages.find((p) => p.id === 'pkg-web')?.owners?.teams).toEqual(['@org/default']); + const slice = withArchSliceOverlays(baseSlice(), root); + expect(slice.columns[0]?.cards[0]?.owners?.teams).toEqual(['@team-api']); + expect(slice.overlays?.ownership.source).toBe(true); + expect(JSON.stringify(slice)).not.toMatch(/layer/i); + }); + + it('rolls up bounded git churn and omits paths with no history', () => { + const root = tmp(); + execFileSync('git', ['init'], { cwd: root, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'dev@example.com'], { cwd: root, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.name', 'Dev'], { cwd: root, stdio: 'ignore' }); + execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: root, stdio: 'ignore' }); + fs.mkdirSync(path.join(root, 'packages/api/src'), { recursive: true }); + fs.writeFileSync(path.join(root, 'packages/api/src/Billing.ts'), 'export const n = 1;\n'); + execFileSync('git', ['add', 'packages/api/src/Billing.ts'], { cwd: root, stdio: 'ignore' }); + execFileSync('git', ['commit', '-m', 'billing'], { cwd: root, stdio: 'ignore' }); + resetOverlayContextCache(); + const slice = withArchSliceOverlays(baseSlice(), root); + expect(slice.columns[0]?.cards[0]?.churn?.commits).toBeGreaterThan(0); + expect(slice.columns[0]?.cards[0]?.churn?.heat).toBeGreaterThanOrEqual(1); + expect(slice.overlays?.churn.source).toBe(true); + expect(slice.overlays?.churn.painted).toBe(1); + }); + + it('sanitises overlay marks additively and drops a current-looking zero', () => { + const clean = sanitizeOverview({ + magic: 'vg.arch.overview.v1', + packages: [ + { + id: 'p', + name: 'api', + path: 'packages/api', + kind: 'package', + symbols: 1, + findings: 0, + missingSteps: 0, + job: 'service', + policy: null, + lane: 'app', + drift: { band: 'major', packages: ['left-pad'] }, + owners: { teams: ['@team-api'], tone: 3 }, + churn: { heat: 4, commits: 9 }, + }, + ], + edges: [], + meta: { + architectureLoaded: false, + policy: null, + policyLabel: null, + symbols: 1, + packages: 1, + findings: 0, + missingSteps: 0, + title: 'Code map', + }, + overlays: { + vulns: { kind: 'vulns', source: false, painted: 0, empty: 'No reachability from a connected scan. Run vg scan online with a workspace connection.' }, + drift: { kind: 'drift', source: true, painted: 1, empty: 'No drifted dependencies on this map.' }, + ownership: { kind: 'ownership', source: true, painted: 1, empty: 'CODEOWNERS does not match any card on this map.' }, + churn: { kind: 'churn', source: true, painted: 1, empty: 'No commit history for files on this map.' }, + }, + }); + expect(clean?.packages[0]?.drift?.band).toBe('major'); + expect(clean?.overlays?.drift.painted).toBe(1); + expect(sanitizeSlice({ ...baseSlice(), columns: [{ id: 'app', title: 'Application', cards: [{ ...baseSlice().columns[0]!.cards[0], churn: { heat: 0, commits: 0 } }] }] })?.columns[0]?.cards[0]?.churn).toBeUndefined(); + }); +}); diff --git a/src/engine/chart/overlays.ts b/src/engine/chart/overlays.ts new file mode 100644 index 0000000..0b571a5 --- /dev/null +++ b/src/engine/chart/overlays.ts @@ -0,0 +1,100 @@ +/** + * Compose architecture-map overlays. Engine computes; hosts only render. + * + * Does not invent an "Architecture Health Score". Overlay meta reports + * source + painted counts and honest empty copy — never a 0-as-healthy KPI. + */ +import type { VgGraph } from '../../schema.js'; +import type { + ArchOverlayKind, + ArchOverlayState, + ArchOverlays, + ArchOverview, + ArchSlice, +} from './arch-types.js'; +import { withOverviewChurn, withSliceChurn } from './churn-annotations.js'; +import { withOverviewDrift, withSliceDrift } from './drift-annotations.js'; +import { loadOverlayContext, type OverlayContext } from './overlay-context.js'; +import { withOverviewOwnership, withSliceOwnership } from './ownership-annotations.js'; +import { withOverviewVulnBadges, withVulnBadges } from './vuln-annotations.js'; + +const EMPTY: Record = { + vulns: { + missing: 'No reachability from a connected scan. Run vg scan online with a workspace connection.', + none: 'No reachable vulnerabilities on this map.', + }, + drift: { + missing: 'No scan drift yet. Run vg to produce a scan artifact.', + none: 'No drifted dependencies on this map.', + }, + ownership: { + missing: 'No CODEOWNERS file in this repository.', + none: 'CODEOWNERS does not match any card on this map.', + }, + churn: { + missing: 'Git history is not available.', + none: 'No commit history for files on this map.', + }, +}; + +export function withArchOverviewOverlays(overview: ArchOverview, root: string, graph?: VgGraph | null): ArchOverview { + const ctx = loadOverlayContext(root, graph); + let next = withOverviewVulnBadges(overview, ctx.reachability); + next = withOverviewDrift(next, ctx); + next = withOverviewOwnership(next, ctx); + next = withOverviewChurn(next, ctx); + return { ...next, overlays: summarizeOverview(next, ctx) }; +} + +export function withArchSliceOverlays(slice: ArchSlice, root: string, graph?: VgGraph | null): ArchSlice { + const ctx = loadOverlayContext(root, graph); + let next = withVulnBadges(slice, ctx.reachability); + next = withSliceDrift(next, ctx); + next = withSliceOwnership(next, ctx); + next = withSliceChurn(next, ctx); + return { ...next, overlays: summarizeSlice(next, ctx) }; +} + +function summarizeOverview(overview: ArchOverview, ctx: OverlayContext): ArchOverlays { + return { + vulns: state( + 'vulns', + hasVulnSource(ctx), + overview.packages.filter((p) => p.vulnerabilities?.length).length, + ), + drift: state('drift', hasDriftSource(ctx), overview.packages.filter((p) => p.drift).length), + ownership: state('ownership', ctx.owners !== null, overview.packages.filter((p) => p.owners?.teams.length).length), + churn: state('churn', ctx.churn !== null, overview.packages.filter((p) => p.churn).length), + }; +} + +function summarizeSlice(slice: ArchSlice, ctx: OverlayContext): ArchOverlays { + const cards = slice.columns.flatMap((c) => c.cards); + return { + vulns: state('vulns', hasVulnSource(ctx), cards.filter((c) => c.vulnerabilities?.length).length), + drift: state('drift', hasDriftSource(ctx), cards.filter((c) => c.drift).length), + ownership: state('ownership', ctx.owners !== null, cards.filter((c) => c.owners?.teams.length).length), + churn: state('churn', ctx.churn !== null, cards.filter((c) => c.churn).length), + }; +} + +function hasVulnSource(ctx: OverlayContext): boolean { + return ctx.artifact?.reachability != null; +} + +function hasDriftSource(ctx: OverlayContext): boolean { + if (!ctx.artifact) return false; + if (ctx.projects.length) return true; + return Array.isArray(ctx.artifact.projects); +} + +function state(kind: ArchOverlayKind, source: boolean, painted: number): ArchOverlayState { + const copy = EMPTY[kind]; + return { + kind, + source, + painted, + empty: source ? copy.none : copy.missing, + }; +} + diff --git a/src/engine/chart/ownership-annotations.ts b/src/engine/chart/ownership-annotations.ts new file mode 100644 index 0000000..43e3889 --- /dev/null +++ b/src/engine/chart/ownership-annotations.ts @@ -0,0 +1,55 @@ +/** + * CODEOWNERS → path owners for architecture overlays. + * + * Ownership only: a team on a path is never a layer, role, or policy column. + * Last matching rule wins (GitHub). Omit the mark when no rule matches. + */ +import type { ArchCard, ArchOverview, ArchOwnershipMark, ArchSlice } from './arch-types.js'; +import { ownerTone, ownersForPath, posixPath, type OverlayContext } from './overlay-context.js'; + +export function withOverviewOwnership(overview: ArchOverview, ctx: OverlayContext): ArchOverview { + if (!ctx.owners?.length) return overview; + let changed = false; + const packages = overview.packages.map((pkg) => { + const mark = markForPaths([pkg.path, `${posixPath(pkg.path)}/`], ctx); + if (!mark) return pkg; + changed = true; + return { ...pkg, owners: mark }; + }); + return changed ? { ...overview, packages } : overview; +} + +export function withSliceOwnership(slice: ArchSlice, ctx: OverlayContext): ArchSlice { + if (!ctx.owners?.length) return slice; + let changed = false; + const columns = slice.columns.map((col) => { + let colChanged = false; + const cards = col.cards.map((card) => { + const mark = markForCard(card, ctx); + if (!mark) return card; + colChanged = true; + return { ...card, owners: mark }; + }); + if (!colChanged) return col; + changed = true; + return { ...col, cards }; + }); + return changed ? { ...slice, columns } : slice; +} + +function markForCard(card: ArchCard, ctx: OverlayContext): ArchOwnershipMark | undefined { + return markForPaths([card.file, ...(card.members ?? []).map((m) => m.file)], ctx); +} + +function markForPaths(paths: string[], ctx: OverlayContext): ArchOwnershipMark | undefined { + if (!ctx.owners?.length) return undefined; + const teams = new Set(); + for (const raw of paths) { + if (!raw) continue; + const owners = ownersForPath(ctx.owners, raw); + if (owners) for (const t of owners) teams.add(t); + } + if (!teams.size) return undefined; + const list = [...teams].sort((a, b) => a.localeCompare(b)); + return { teams: list, tone: ownerTone(list[0]!) }; +} diff --git a/src/engine/chart/page.test.ts b/src/engine/chart/page.test.ts index 0162173..a23eae6 100644 --- a/src/engine/chart/page.test.ts +++ b/src/engine/chart/page.test.ts @@ -8,6 +8,9 @@ describe('architecturePageHtml', () => { expect(html).toContain('data-host="browser"'); expect(html).not.toMatch(/HAILE/i); expect(html).not.toContain('function sizeMap('); + expect(html).toContain('data-overlay="vulns"'); + expect(html).toContain('Architecture overlays'); + expect(html).not.toMatch(/Architecture Health Score/i); }); it('binds a vscode host and nonce for the editor webview', () => { diff --git a/src/engine/chart/sanitize.ts b/src/engine/chart/sanitize.ts index 857e884..f5a44da 100644 --- a/src/engine/chart/sanitize.ts +++ b/src/engine/chart/sanitize.ts @@ -8,7 +8,13 @@ import { OVERVIEW_PACKAGE_CAP, SLICE_CARD_CAP, type ArchCard, + type ArchChurnMark, + type ArchDriftMark, + type ArchOverlayKind, + type ArchOverlays, + type ArchOverlayState, type ArchOverview, + type ArchOwnershipMark, type ArchPackageEdge, type ArchPackageNode, type ArchSlice, @@ -38,6 +44,7 @@ export function sanitizeOverview(raw: unknown): ArchOverview | null { lane: typeof p.lane === 'string' ? p.lane : 'unclassified', ...(typeof p.mix === 'string' ? { mix: p.mix } : {}), ...(typeof p.unclassified === 'number' ? { unclassified: num(p.unclassified) } : {}), + ...overlayMarks(p), }); } const edges: ArchPackageEdge[] = []; @@ -63,6 +70,7 @@ export function sanitizeOverview(raw: unknown): ArchOverview | null { missingSteps: num(meta.missingSteps), title: typeof meta.title === 'string' && meta.title ? meta.title : 'Code map', }, + ...sanitizeOverlays(o.overlays), }; } @@ -123,6 +131,7 @@ export function sanitizeSlice(raw: unknown): ArchSlice | null { overflowHint, emptyHint: typeof o.emptyHint === 'string' ? o.emptyHint : null, focusCardId: typeof o.focusCardId === 'string' ? o.focusCardId : null, + ...sanitizeOverlays(o.overlays), }; } @@ -169,6 +178,7 @@ function sanitizeCard(raw: unknown): ArchCard | null { } : {}), ...(c.guard ? { guard: true } : {}), + ...overlayMarks(c), ...(Array.isArray(c.findings) ? { findings: c.findings @@ -185,6 +195,107 @@ function sanitizeCard(raw: unknown): ArchCard | null { }; } +function overlayMarks(raw: { + vulnerabilities?: unknown; + drift?: unknown; + owners?: unknown; + churn?: unknown; +}): { + vulnerabilities?: ArchCard['vulnerabilities']; + drift?: ArchDriftMark; + owners?: ArchOwnershipMark; + churn?: ArchChurnMark; +} { + const out: { + vulnerabilities?: ArchCard['vulnerabilities']; + drift?: ArchDriftMark; + owners?: ArchOwnershipMark; + churn?: ArchChurnMark; + } = {}; + if (Array.isArray(raw.vulnerabilities)) { + const vulns = raw.vulnerabilities + .filter( + (v): v is { advisoryId: string; package: string; tier: string; evidence?: string } => + Boolean( + v && + typeof v === 'object' && + typeof (v as { advisoryId?: unknown }).advisoryId === 'string' && + typeof (v as { package?: unknown }).package === 'string' && + ((v as { tier?: unknown }).tier === 'reachable' || + (v as { tier?: unknown }).tier === 'potentially_reachable'), + ), + ) + .slice(0, 6) + .map((v) => ({ + advisoryId: v.advisoryId, + package: v.package, + tier: v.tier as 'reachable' | 'potentially_reachable', + ...(typeof v.evidence === 'string' ? { evidence: v.evidence } : {}), + })); + if (vulns.length) out.vulnerabilities = vulns; + } + const drift = sanitizeDrift(raw.drift); + if (drift) out.drift = drift; + const owners = sanitizeOwners(raw.owners); + if (owners) out.owners = owners; + const churn = sanitizeChurn(raw.churn); + if (churn) out.churn = churn; + return out; +} + +function sanitizeDrift(raw: unknown): ArchDriftMark | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const d = raw as { band?: unknown; packages?: unknown }; + if (d.band !== 'minor' && d.band !== 'major') return undefined; + const packages = Array.isArray(d.packages) + ? d.packages.filter((p): p is string => typeof p === 'string' && p.length > 0).slice(0, 6) + : []; + if (!packages.length) return undefined; + return { band: d.band, packages }; +} + +function sanitizeOwners(raw: unknown): ArchOwnershipMark | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const o = raw as { teams?: unknown; tone?: unknown }; + const teams = Array.isArray(o.teams) + ? o.teams.filter((t): t is string => typeof t === 'string' && t.length > 0).slice(0, 8) + : []; + if (!teams.length) return undefined; + const tone = typeof o.tone === 'number' && Number.isFinite(o.tone) ? Math.abs(Math.floor(o.tone)) % 8 : 0; + return { teams, tone }; +} + +function sanitizeChurn(raw: unknown): ArchChurnMark | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const c = raw as { heat?: unknown; commits?: unknown }; + if (c.heat !== 1 && c.heat !== 2 && c.heat !== 3 && c.heat !== 4 && c.heat !== 5) return undefined; + if (typeof c.commits !== 'number' || !Number.isFinite(c.commits) || c.commits <= 0) return undefined; + return { heat: c.heat, commits: Math.floor(c.commits) }; +} + +function sanitizeOverlays(raw: unknown): { overlays?: ArchOverlays } { + if (!raw || typeof raw !== 'object') return {}; + const o = raw as Partial; + const vulns = sanitizeOverlayState('vulns', o.vulns); + const drift = sanitizeOverlayState('drift', o.drift); + const ownership = sanitizeOverlayState('ownership', o.ownership); + const churn = sanitizeOverlayState('churn', o.churn); + if (!vulns || !drift || !ownership || !churn) return {}; + return { overlays: { vulns, drift, ownership, churn } }; +} + +function sanitizeOverlayState(kind: ArchOverlayKind, raw: unknown): ArchOverlayState | null { + if (!raw || typeof raw !== 'object') return null; + const s = raw as Partial; + if (s.kind !== kind) return null; + return { + kind, + source: Boolean(s.source), + painted: num(s.painted), + empty: typeof s.empty === 'string' && s.empty ? s.empty : 'No overlay data.', + }; +} + function isFinding(v: unknown): v is { rule: string; severity: string; message: string; line?: unknown } { if (!v || typeof v !== 'object') return false; const f = v as { rule?: unknown; severity?: unknown; message?: unknown }; diff --git a/src/engine/chart/server.test.ts b/src/engine/chart/server.test.ts index cbee7e7..61b931d 100644 --- a/src/engine/chart/server.test.ts +++ b/src/engine/chart/server.test.ts @@ -45,6 +45,11 @@ describe('chart server', () => { expect(overview.magic).toBe('vg.arch.overview.v1'); expect(overview.packages.length).toBeGreaterThan(0); expect(overview.packages.length).toBeLessThanOrEqual(200); + expect(overview.overlays.vulns.source).toBe(false); + expect(overview.overlays.drift.source).toBe(false); + expect(overview.overlays.ownership.source).toBe(false); + expect(overview.overlays.vulns.empty).toMatch(/reachability/i); + expect(JSON.stringify(overview.overlays)).not.toMatch(/Architecture Health Score/i); const graph = await fetchJson(server.url + '/api/graph'); expect(graph.magic).toBe('vg.arch.overview.v1'); @@ -52,6 +57,8 @@ describe('chart server', () => { const slice = await fetchJson(server.url + '/api/slice?package=' + encodeURIComponent(overview.packages[0].id)); expect(slice.magic).toBe('vg.arch.slice.v1'); + expect(slice.overlays.vulns.source).toBe(false); + expect(slice.overlays.churn.kind).toBe('churn'); const painted = slice.columns.reduce((n: number, col: { cards: unknown[] }) => n + col.cards.length, 0); expect(painted).toBeLessThanOrEqual(120); diff --git a/src/engine/chart/server.ts b/src/engine/chart/server.ts index 97b9b5f..9aaf3e8 100644 --- a/src/engine/chart/server.ts +++ b/src/engine/chart/server.ts @@ -26,7 +26,7 @@ import { sanitizeOverview, sanitizeSlice } from './sanitize.js'; import { parseArchView, type ArchOverview, type ArchSlice, type ArchSliceView } from './arch-types.js'; import { defaultBoardLayout, readBoardLayout, writeBoardLayout, BOARD_LAYOUT_MAGIC, type ArchBoardLayout } from './board-layout.js'; import { loadExternalSurfaces, withExternalLane } from './external-lane.js'; -import { loadReachabilityFindings, withVulnBadges } from './vuln-annotations.js'; +import { withArchOverviewOverlays, withArchSliceOverlays } from './overlays.js'; export const DEFAULT_CHART_HOST = '127.0.0.1'; export const DEFAULT_CHART_PORT = 7420; @@ -121,11 +121,11 @@ async function handle( return; } if (req.method === 'GET' && pathName === '/api/meta') { - json(res, overviewOf(graph, sidecar, provider).meta); + json(res, overviewOf(graph, sidecar, provider, root).meta); return; } if (req.method === 'GET' && pathName === '/api/overview') { - json(res, overviewOf(graph, sidecar, provider)); + json(res, overviewOf(graph, sidecar, provider, root)); return; } if (req.method === 'GET' && pathName === '/api/slice') { @@ -158,7 +158,7 @@ async function handle( } if (req.method === 'GET' && pathName === '/api/graph') { res.setHeader('Warning', '299 vg "/api/graph is deprecated; use /api/overview"'); - json(res, overviewOf(graph, sidecar, provider)); + json(res, overviewOf(graph, sidecar, provider, root)); return; } if (req.method === 'GET' && pathName === '/api/sidecar') { @@ -209,16 +209,19 @@ export function overviewOf( graph: VgGraph, sidecar: HaileSidecar | null, provider: HaileProvider | null, + root?: string, ): ArchOverview { + let overview: ArchOverview | undefined; if (provider?.projectOverview) { try { const clean = sanitizeOverview(provider.projectOverview(graph, sidecar)); - if (clean && (clean.meta.architectureLoaded || !sidecar)) return clean; + if (clean && (clean.meta.architectureLoaded || !sidecar)) overview = clean; } catch { /* host fallback */ } } - return projectOverview(graph, sidecar); + overview ??= projectOverview(graph, sidecar); + return root ? withArchOverviewOverlays(overview, root, graph) : overview; } export function sliceOf( @@ -257,7 +260,7 @@ export function sliceOf( slice ??= projectSlice(graph, sidecar, input); if (!root) return slice; slice = withExternalLane(slice, loadExternalSurfaces(root)); - slice = withVulnBadges(slice, loadReachabilityFindings(root)); + slice = withArchSliceOverlays(slice, root, graph); return slice; } diff --git a/src/engine/chart/vuln-annotations.ts b/src/engine/chart/vuln-annotations.ts index c3e90e4..a300356 100644 --- a/src/engine/chart/vuln-annotations.ts +++ b/src/engine/chart/vuln-annotations.ts @@ -15,25 +15,21 @@ * `ReachabilitySite.file` (+ its enclosing `function` when present) is * matched against a card's own file and its members' files/names. */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import type { ScanArtifact, ScanReachabilityFinding } from '../../core-open/index.js'; -import type { ArchCardVuln, ArchSlice } from './arch-types.js'; +import type { ScanReachabilityFinding } from '../../core-open/index.js'; +import type { ArchCardVuln, ArchOverview, ArchPackageNode, ArchSlice } from './arch-types.js'; +import { loadScanArtifact, pathUnder, posixPath } from './overlay-context.js'; -const SCAN_ARTIFACT_REL = '.vibgrate/scan_result.json'; const MAX_VULNS_PER_CARD = 6; /** Best-effort load of the last scan's reachability findings. Never throws. */ export function loadReachabilityFindings(root: string): ScanReachabilityFinding[] { - try { - const abs = path.join(root, SCAN_ARTIFACT_REL); - if (!fs.existsSync(abs)) return []; - const artifact = JSON.parse(fs.readFileSync(abs, 'utf8')) as Partial; - const findings = artifact.reachability?.findings; - return Array.isArray(findings) ? findings : []; - } catch { - return []; - } + const artifact = loadScanArtifact(root); + const findings = artifact?.reachability?.findings; + return Array.isArray(findings) ? findings : []; +} + +export function relevantVulnFindings(findings: ScanReachabilityFinding[]): ScanReachabilityFinding[] { + return findings.filter((f) => f.tier === 'reachable' || f.tier === 'potentially_reachable'); } /** @@ -42,7 +38,7 @@ export function loadReachabilityFindings(root: string): ScanReachabilityFinding[ * unchanged when nothing matches (including when `findings` is empty). */ export function withVulnBadges(slice: ArchSlice, findings: ScanReachabilityFinding[]): ArchSlice { - const relevant = findings.filter((f) => f.tier === 'reachable' || f.tier === 'potentially_reachable'); + const relevant = relevantVulnFindings(findings); if (!relevant.length) return slice; let changed = false; @@ -50,26 +46,10 @@ export function withVulnBadges(slice: ArchSlice, findings: ScanReachabilityFindi 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: ArchCardVuln[] = []; - const seen = new Set(); - for (const finding of relevant) { - const matches = (finding.sites ?? []).some((site) => files.has(site.file)); - if (!matches) 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 } : {}), - }); - } + const hits = vulnsForFiles(files, relevant); if (!hits.length) return card; colChanged = true; - // Reachable outranks potentially-reachable so the worst case sorts first. - hits.sort((a, b) => (a.tier === b.tier ? 0 : a.tier === 'reachable' ? -1 : 1)); - return { ...card, vulnerabilities: hits.slice(0, MAX_VULNS_PER_CARD) }; + return { ...card, vulnerabilities: hits }; }); if (!colChanged) return col; changed = true; @@ -78,3 +58,51 @@ export function withVulnBadges(slice: ArchSlice, findings: ScanReachabilityFindi return changed ? { ...slice, columns } : slice; } + +/** + * Roll reachable findings onto overview packages by path prefix. + * Returns `overview` unchanged when nothing matches. + */ +export function withOverviewVulnBadges(overview: ArchOverview, findings: ScanReachabilityFinding[]): ArchOverview { + const relevant = relevantVulnFindings(findings); + if (!relevant.length) return overview; + let changed = false; + const packages = overview.packages.map((pkg) => { + const hits = vulnsForPackage(pkg, relevant); + if (!hits.length) return pkg; + changed = true; + return { ...pkg, vulnerabilities: hits }; + }); + return changed ? { ...overview, packages } : overview; +} + +function vulnsForPackage(pkg: ArchPackageNode, findings: ScanReachabilityFinding[]): ArchCardVuln[] { + const files = new Set(); + for (const finding of findings) { + for (const site of finding.sites ?? []) { + if (site.file && pathUnder(site.file, pkg.path)) files.add(posixPath(site.file)); + } + } + return vulnsForFiles(files, findings); +} + +function vulnsForFiles(files: Set, findings: ScanReachabilityFinding[]): 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 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.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/engine/embeddings.ts b/src/engine/embeddings.ts index 16913ac..2a6df88 100644 --- a/src/engine/embeddings.ts +++ b/src/engine/embeddings.ts @@ -338,7 +338,7 @@ export async function loadEmbedder(options: LoadEmbedderOptions = {}): Promise { + it('prefers blast:{node_id} when the graph node is known', () => { + expect(blastFindingKey({ node_id: 'scanDir', path: 'src/scan.ts', name: 'scanDir' })).toBe( + 'blast:scanDir', + ); + }); + + it('falls back to blast:{path}:{name} when node_id is absent', () => { + expect(blastFindingKey({ path: 'src/scan.ts', name: 'scanDir' })).toBe('blast:src/scan.ts:scanDir'); + expect(blastFindingKey({ node_id: ' ', path: './src/scan.ts', name: 'scanDir' })).toBe( + 'blast:src/scan.ts:scanDir', + ); + }); + + it('is stable for the same symbol across calls and strips spaces', () => { + const symbol = { node_id: 'hub', path: 'src/hub.ts', name: 'hub' }; + expect(blastFindingKey(symbol)).toBe(blastFindingKey({ ...symbol })); + expect(blastFindingKey({ path: 'src/my file.ts', name: 'my fn' })).toBe('blast:src/myfile.ts:myfn'); + expect(blastFindingKey({ path: 'src/my file.ts', name: 'my fn' })).not.toMatch(/\s/); + }); +}); + +describe('exportCorrectnessPublishRows', () => { + it('maps blast-radius rows to kind:correctness with id used as finding_key', () => { + const c = scanCapsule(); + const architecture = collectBlastRadiusFindings({ graph: fixtureGraph(), capsule: c }); + expect(architecture[0]?.id).toBe('blast:scanDir'); + expect(architecture[0]?.finding_key).toBe('blast:scanDir'); + expect(architecture[0]?.kind).toBe(CORRECTNESS_KIND); + expect(architecture[0]?.producer).toBe(BLAST_PRODUCER); + expect(architecture[0]).not.toHaveProperty('review_check_kind'); + + const first = exportCorrectnessPublishRows({ + schema_version: FINDINGS_SCHEMA, + change_class: ['architecture'], + architecture_findings: architecture, + security_findings: [], + unknowns: [], + required_checks: [], + }); + const second = exportCorrectnessPublishRows( + collectBlastRadiusFindings({ graph: fixtureGraph(), capsule: structuredClone(c) }), + ); + expect(first).toHaveLength(1); + expect(first).toEqual(second); + expect(first[0]).toMatchObject({ + id: 'blast:scanDir', + finding_key: 'blast:scanDir', + kind: CORRECTNESS_KIND, + scanner_kind: BLAST_SCANNER_KIND, + review_check: 'vibgrate/review', + severity: 'low', + source: 'scanner', + receipts: [], + suggested_fix: null, + suggested_fix_status: 'skipped_no_patch', + }); + expect(first[0]).not.toHaveProperty('review_check_kind'); + expect(first[0].suggested_fix_note).toMatch(/no computed edit/i); + expect(first[0].paths).toContain('src/scan.ts'); + expect(JSON.stringify(first)).not.toMatch(/cursor|copilot|codeql|semgrep/i); + }); + + it('keeps id identical across different head SHAs for the same symbol/node', () => { + const shaA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const shaB = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const base = scanCapsule(); + const withSha = (head: string) => ({ + ...base, + change: { ...base.change, head_sha: head }, + }); + const a = collectBlastRadiusFindings({ graph: fixtureGraph(), capsule: withSha(shaA) }); + const b = collectBlastRadiusFindings({ graph: fixtureGraph(), capsule: withSha(shaB) }); + expect(a.map((f) => f.id)).toEqual(['blast:scanDir']); + expect(a.map((f) => f.id)).toEqual(b.map((f) => f.id)); + expect(a[0].id).not.toContain(shaA); + expect(a[0].id).not.toContain(shaB); + expect(a[0].id).not.toMatch(/\s/); + expect(exportCorrectnessPublishRows(a).map((r) => r.id)).toEqual( + exportCorrectnessPublishRows(b).map((r) => r.id), + ); + expect(exportCorrectnessPublishRows(a)[0]?.finding_key).toBe('blast:scanDir'); + expect(JSON.stringify(exportCorrectnessPublishRows(a))).not.toContain(shaA); + expect(JSON.stringify(exportCorrectnessPublishRows(b))).not.toContain(shaB); + }); + + it('keeps id on the symbol when rank would move', () => { + const c = scanCapsule(); + const a = collectBlastRadiusFindings({ graph: fixtureGraph(), capsule: c }); + const reversed = capsule({ + change: { + ...c.change, + symbols: [...c.change.symbols].reverse(), + }, + }); + const b = collectBlastRadiusFindings({ graph: fixtureGraph(), capsule: reversed }); + expect(a.map((f) => f.id)).toEqual(b.map((f) => f.id)); + expect(exportCorrectnessPublishRows(a).map((r) => r.finding_key)).toEqual( + exportCorrectnessPublishRows(b).map((r) => r.finding_key), + ); + }); + + it('omits non-correctness findings and rows without a blast: or arch: id', () => { + const rows = exportCorrectnessPublishRows({ + schema_version: FINDINGS_SCHEMA, + change_class: ['architecture'], + architecture_findings: [ + { + id: 'arch-01', + kind: 'boundary_bypass', + severity: 'high', + confidence: 0.9, + claim: 'skip', + evidence_ids: ['e'], + target_alignment: 'regression', + remediation: 'route', + paths: ['src/a.ts'], + source: 'scanner', + }, + { + id: 'legacy-impact-01', + kind: 'blast_radius', + severity: 'low', + confidence: 0.6, + claim: 'old second-kind row', + evidence_ids: ['e'], + target_alignment: 'unknown', + remediation: 'review callers', + paths: ['src/a.ts'], + source: 'scanner', + }, + ], + security_findings: [], + unknowns: [], + required_checks: [], + }); + expect(rows).toEqual([]); + }); + + it('never publishes high or critical severity', () => { + expect(publishSeverity('critical')).toBe('low'); + expect(publishSeverity('high')).toBe('low'); + expect(publishSeverity('medium')).toBe('medium'); + expect(publishSeverity('low')).toBe('low'); + const rows = exportCorrectnessPublishRows([ + { + id: 'blast:hub', + kind: CORRECTNESS_KIND, + finding_key: 'blast:hub', + producer: BLAST_PRODUCER, + severity: 'critical', + confidence: 1, + claim: 'wide fan-out', + evidence_ids: ['e'], + target_alignment: 'unknown', + remediation: 'review callers', + paths: ['src/hub.ts'], + source: 'scanner', + }, + ]); + expect(rows[0]?.severity).toBe('low'); + expect(rows[0]?.kind).toBe(CORRECTNESS_KIND); + }); + + it('publishes architecture-policy rows as correctness with scanner_kind architecture', () => { + const rows = exportCorrectnessPublishRows([ + { + id: 'arch:layered:skip:routing→data-access:src/routes/x.ts:src/repositories/r.ts', + kind: CORRECTNESS_KIND, + finding_key: 'arch:layered:skip:routing→data-access:src/routes/x.ts:src/repositories/r.ts', + producer: 'architecture', + severity: 'high', + confidence: 0.93, + claim: 'A changed routing file depends directly on data-access.', + evidence_ids: ['edge:1', 'verify:no_test_covering_change:1'], + receipts: ['verify:no_test_covering_change:1'], + target_alignment: 'regression', + remediation: 'Route through the service.', + paths: ['src/routes/x.ts', 'src/repositories/r.ts'], + source: 'scanner', + }, + ]); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: 'arch:layered:skip:routing→data-access:src/routes/x.ts:src/repositories/r.ts', + finding_key: 'arch:layered:skip:routing→data-access:src/routes/x.ts:src/repositories/r.ts', + kind: CORRECTNESS_KIND, + scanner_kind: 'architecture', + severity: 'high', + receipts: ['verify:no_test_covering_change:1'], + suggested_fix: null, + suggested_fix_status: 'skipped_no_patch', + }); + expect(rows[0].id).not.toMatch(/\s/); + expect(JSON.stringify(rows)).not.toMatch(/critical/); + }); + + it('clamps architecture critical to high, never invents critical on publish', () => { + const rows = exportCorrectnessPublishRows([ + { + id: 'arch:peer_deviation:src/a.ts', + kind: CORRECTNESS_KIND, + producer: 'architecture', + severity: 'critical', + confidence: 1, + claim: 'peers differ', + evidence_ids: ['e'], + target_alignment: 'unknown', + remediation: 'follow peers', + paths: ['src/a.ts'], + source: 'scanner', + }, + ]); + expect(rows[0]?.severity).toBe('low'); + expect(rows[0]?.scanner_kind).toBe('architecture'); + }); +}); diff --git a/src/review/finding-publish.ts b/src/review/finding-publish.ts new file mode 100644 index 0000000..2eba1d4 --- /dev/null +++ b/src/review/finding-publish.ts @@ -0,0 +1,118 @@ +/** + * Publishable correctness rows for a GitHub App ingest. + * + * The App's `vg.review.findings.app.v1` currently types `kind` as + * `dependency_drift` | `correctness`. This mapper emits a parallel JSON shape + * the App can parse without importing the public CLI: + * + * id / finding_key — stable (`blast:…` / `arch:{rule}:{path}`) + * kind — `correctness` (never a second top-level kind) + * scanner_kind — `blast_radius` | `architecture` (producer metadata) + * receipts — existing capsule verification / scan / attest ids + * suggested_fix — always null here; no computed PatchIR + * + * Blast-radius facts have no deterministic edit (unlike a declared-dependency + * bump). Architecture-policy rows likewise have no computed patch here. + * `proposeFindingFix` is a model-backed dry-run, not a patch we invent. + */ + +import { BLAST_PRODUCER, CORRECTNESS_KIND } from './impact-findings.js'; +import { ARCH_PRODUCER, isArchitectureFinding } from './finding-receipts.js'; +import type { FindingSeverity, ReviewFinding, ReviewFindings } from './schemas.js'; + +export { CORRECTNESS_KIND }; +export const BLAST_SCANNER_KIND = BLAST_PRODUCER; +export const ARCH_SCANNER_KIND = ARCH_PRODUCER; +export const REVIEW_CHECK_ID = 'vibgrate/review' as const; + +export type SuggestedFixStatus = 'skipped_no_patch'; +export type PublishScannerKind = typeof BLAST_SCANNER_KIND | typeof ARCH_SCANNER_KIND; +export type PublishSeverity = 'low' | 'medium' | 'high'; + +export interface CorrectnessPublishRow { + /** Same value as `finding_key` — App ledgers store `finding.id` as the key. */ + id: string; + finding_key: string; + kind: typeof CORRECTNESS_KIND; + /** Pack/producer metadata — not a second top-level kind. */ + scanner_kind: PublishScannerKind; + review_check: typeof REVIEW_CHECK_ID; + /** Blast-radius is low|medium; architecture may be high when the pack already uses that scale. */ + severity: PublishSeverity; + confidence: number; + claim: string; + paths: string[]; + source: 'scanner'; + /** Existing capsule verification / scan / attest evidence ids. Empty when none exist. */ + receipts: string[]; + evidence_ids: string[]; + suggested_fix: null; + suggested_fix_status: SuggestedFixStatus; + suggested_fix_note: string; +} + +const BLAST_SKIP_NOTE = + 'No automatic patch — blast-radius facts have no computed edit. `vg review propose` is a model-backed dry-run, not a deterministic bump.'; + +const ARCH_SKIP_NOTE = + 'No automatic patch — architecture-policy facts have no computed edit. `vg review propose` is a model-backed dry-run, not a deterministic bump.'; + +/** Clamp blast-radius so a publish row never carries high/critical (version-lag critical is out of scope). */ +export function publishSeverity(severity: FindingSeverity): 'low' | 'medium' { + return severity === 'medium' ? 'medium' : 'low'; +} + +/** Architecture pack scale is low|medium|high. Never invent critical. */ +export function publishArchSeverity(severity: FindingSeverity): PublishSeverity { + if (severity === 'high') return 'high'; + if (severity === 'medium') return 'medium'; + return 'low'; +} + +export function isBlastRadiusFinding(finding: ReviewFinding): boolean { + if (finding.kind !== CORRECTNESS_KIND) return false; + if (finding.producer === BLAST_SCANNER_KIND) return true; + return finding.id.startsWith('blast:'); +} + +export function isPublishableCorrectnessFinding(finding: ReviewFinding): boolean { + return isBlastRadiusFinding(finding) || isArchitectureFinding(finding); +} + +/** + * Map Review findings to App-ingestible correctness rows: blast-radius and + * architecture-policy scanner rows. Other kinds stay on the CLI findings document. + */ +export function exportCorrectnessPublishRows(findings: ReviewFindings | ReviewFinding[]): CorrectnessPublishRow[] { + const list = Array.isArray(findings) + ? findings + : [...findings.architecture_findings, ...findings.security_findings]; + const rows: CorrectnessPublishRow[] = []; + for (const finding of list) { + if (!isPublishableCorrectnessFinding(finding)) continue; + const blast = isBlastRadiusFinding(finding); + const key = finding.id.trim() || finding.finding_key?.trim() || ''; + if (!key || /\s/.test(key)) continue; + if (blast && !key.startsWith('blast:')) continue; + if (!blast && !key.startsWith('arch:')) continue; + const receipts = [...(finding.receipts ?? [])].sort((a, b) => a.localeCompare(b)); + rows.push({ + id: key, + finding_key: key, + kind: CORRECTNESS_KIND, + scanner_kind: blast ? BLAST_SCANNER_KIND : ARCH_SCANNER_KIND, + review_check: REVIEW_CHECK_ID, + severity: blast ? publishSeverity(finding.severity) : publishArchSeverity(finding.severity), + confidence: finding.confidence, + claim: finding.claim, + paths: [...finding.paths], + source: 'scanner', + receipts, + evidence_ids: [...finding.evidence_ids], + suggested_fix: null, + suggested_fix_status: 'skipped_no_patch', + suggested_fix_note: blast ? BLAST_SKIP_NOTE : ARCH_SKIP_NOTE, + }); + } + return rows.sort((a, b) => a.finding_key.localeCompare(b.finding_key)); +} diff --git a/src/review/finding-receipts.test.ts b/src/review/finding-receipts.test.ts new file mode 100644 index 0000000..4312376 --- /dev/null +++ b/src/review/finding-receipts.test.ts @@ -0,0 +1,246 @@ +/** + * Offline gold: verification-receipt attach + architecture-policy findings. + * Fixture capsules only — no git, no model, no network. + */ + +import { describe, expect, it } from 'vitest'; +import { fixtureGraph } from '../code/graph-fixture.js'; +import { collectBlastRadiusFindings, CORRECTNESS_KIND } from './impact-findings.js'; +import { + ARCH_PRODUCER, + archFindingKey, + attachVerificationReceipts, + collectVerificationReceiptIds, +} from './finding-receipts.js'; +import { ARCH_SCANNER_KIND, BLAST_SCANNER_KIND, exportCorrectnessPublishRows } from './finding-publish.js'; +import { FINDINGS_SCHEMA } from './schemas.js'; +import { runScanners, type ScanInput } from './scanners.js'; +import { capsule, changeSet, config } from './test-fixtures.js'; +import { verifyFindings } from './verify.js'; + +function scanInput(overrides: Partial = {}): ScanInput { + return { + root: '/repo', + capsule: capsule(), + change: changeSet([]), + config: config(), + removedLines: new Map(), + fileText: new Map(), + vulnerablePackages: [], + ...overrides, + }; +} + +function verifiedCapsule() { + return capsule({ + change: { + ...capsule().change, + head_sha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + symbols: [ + { + node_id: 'scanDir', + name: 'scanDir', + kind: 'function', + path: 'src/scan.ts', + start_line: 5, + end_line: 20, + }, + ], + ops: [ + { path: 'src/scan.ts', op: 'modified', added_lines: 1, removed_lines: 0 }, + { path: 'src/routes/x.ts', op: 'modified', added_lines: 1, removed_lines: 0 }, + ], + added_edges: [ + { + evidence_id: 'edge:1', + kind: 'import', + from_path: 'src/routes/x.ts', + to_path: 'src/repositories/r.ts', + from_layer: 'routing', + to_layer: 'data-access', + }, + ], + }, + policies: [ + { evidence_id: 'policy:layering:1', id: 'layering-1', rule: 'outer layers flow one way', source: 'review.toml' }, + ], + verification: [ + { + evidence_id: 'verify:no_test_covering_change:1', + kind: 'no_test_covering_change', + path: 'src/routes/x.ts', + detail: 'no test edge reaches this file in the map', + }, + { + evidence_id: 'verify:test_covering_change:2', + kind: 'test_covering_change', + path: 'src/scan.ts', + detail: 'covered', + }, + ], + evidence: [ + { id: 'edge:1', kind: 'graph_edge', path: 'src/routes/x.ts', protected_finding: false }, + { id: 'policy:layering:1', kind: 'policy', protected_finding: false }, + { + id: 'verify:no_test_covering_change:1', + kind: 'graph_node', + path: 'src/routes/x.ts', + protected_finding: false, + note: 'no test edge reaches this file in the map', + }, + { + id: 'verify:test_covering_change:2', + kind: 'graph_node', + path: 'src/scan.ts', + protected_finding: false, + note: 'covered', + }, + ], + }); +} + +describe('archFindingKey', () => { + it('uses the pack rule + repo-relative path and strips spaces', () => { + expect(archFindingKey('layered:skip:routing→data-access', 'src/routes/x.ts', 'src/repositories/r.ts')).toBe( + 'arch:layered:skip:routing→data-access:src/routes/x.ts:src/repositories/r.ts', + ); + expect(archFindingKey('peer_deviation', './src/my file.ts')).toBe('arch:peer_deviation:src/myfile.ts'); + expect(archFindingKey('peer_deviation', 'src/my file.ts')).not.toMatch(/\s/); + }); + + it('is stable across head SHAs — the key never contains a commit', () => { + const sha = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const key = archFindingKey('unverified_change', 'src/a.ts'); + expect(key).toBe('arch:unverified_change:src/a.ts'); + expect(key).not.toContain(sha); + expect(archFindingKey('unverified_change', 'src/a.ts')).toBe(key); + }); +}); + +describe('attachVerificationReceipts', () => { + it('cites existing capsule verification facts for overlapping paths', () => { + const c = verifiedCapsule(); + const attached = attachVerificationReceipts( + { + id: 'arch:peer_deviation:src/routes/x.ts', + kind: CORRECTNESS_KIND, + producer: ARCH_PRODUCER, + severity: 'medium', + confidence: 0.6, + claim: 'peers differ', + evidence_ids: ['vote:role:routing'], + target_alignment: 'unknown', + remediation: 'follow peers', + paths: ['src/routes/x.ts'], + source: 'scanner', + }, + c, + ); + expect(attached.receipts).toEqual(['verify:no_test_covering_change:1']); + expect(attached.evidence_ids).toContain('verify:no_test_covering_change:1'); + expect(attached.evidence_ids).not.toContain('verify:test_covering_change:2'); + }); + + it('does not invent a receipt when the capsule has no verification for that path', () => { + const c = capsule(); + const attached = attachVerificationReceipts( + { + id: 'arch:peer_deviation:src/z.ts', + kind: CORRECTNESS_KIND, + producer: ARCH_PRODUCER, + severity: 'low', + confidence: 0.5, + claim: 'observation', + evidence_ids: ['e'], + target_alignment: 'unknown', + remediation: 'review', + paths: ['src/z.ts'], + source: 'scanner', + }, + c, + ); + expect(attached.receipts).toEqual([]); + expect(attached.evidence_ids).toEqual(['e']); + expect(collectVerificationReceiptIds(c, ['src/z.ts'])).toEqual([]); + }); +}); + +describe('architecture-policy findings (offline gold)', () => { + it('emits kind:correctness architecture rows with stable ids and attached receipts', () => { + const c = verifiedCapsule(); + const out = runScanners(scanInput({ capsule: c, graph: fixtureGraph() })); + const arch = out.architecture.filter((f) => f.producer === ARCH_PRODUCER); + const blast = out.architecture.filter((f) => f.producer === 'blast_radius'); + expect(arch.length).toBeGreaterThan(0); + expect(blast).toHaveLength(1); + expect(arch.every((f) => f.kind === CORRECTNESS_KIND)).toBe(true); + expect(arch.every((f) => f.id.startsWith('arch:'))).toBe(true); + expect(arch.every((f) => f.finding_key === f.id)).toBe(true); + expect(arch.every((f) => !/\s/.test(f.id))).toBe(true); + expect(arch.every((f) => f.severity !== 'critical')).toBe(true); + + const skip = arch.find((f) => f.id.includes('layered:skip')); + expect(skip).toBeDefined(); + expect(skip!.receipts).toEqual(['verify:no_test_covering_change:1']); + expect(skip!.evidence_ids).toContain('verify:no_test_covering_change:1'); + + const uncovered = arch.find((f) => f.id === 'arch:unverified_change:src/routes/x.ts'); + expect(uncovered).toBeDefined(); + expect(uncovered!.receipts).toEqual(['verify:no_test_covering_change:1']); + + const verified = verifyFindings( + { + schema_version: FINDINGS_SCHEMA, + change_class: ['architecture'], + architecture_findings: out.architecture, + security_findings: [], + unknowns: [], + required_checks: [], + }, + c, + ); + expect(verified.errors).toEqual([]); + expect(verified.evidence_ids_valid).toBe(true); + }); + + it('keeps architecture ids identical across different head SHAs', () => { + const base = verifiedCapsule(); + const shaB = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const a = runScanners(scanInput({ capsule: base })).architecture.filter((f) => f.producer === ARCH_PRODUCER); + const b = runScanners( + scanInput({ + capsule: { ...base, change: { ...base.change, head_sha: shaB } }, + }), + ).architecture.filter((f) => f.producer === ARCH_PRODUCER); + expect(a.map((f) => f.id)).toEqual(b.map((f) => f.id)); + expect(JSON.stringify(a.map((f) => f.id))).not.toContain(base.change.head_sha); + expect(JSON.stringify(b.map((f) => f.id))).not.toContain(shaB); + }); + + it('exports blast + architecture publishable rows with receipts when the capsule has them', () => { + const c = verifiedCapsule(); + const architecture = [ + ...runScanners(scanInput({ capsule: c })).architecture, + ...collectBlastRadiusFindings({ graph: fixtureGraph(), capsule: structuredClone(c) }), + ]; + const rows = exportCorrectnessPublishRows({ + schema_version: FINDINGS_SCHEMA, + change_class: ['architecture'], + architecture_findings: architecture, + security_findings: [], + unknowns: [], + required_checks: [], + }); + const kinds = [...new Set(rows.map((r) => r.scanner_kind))].sort(); + expect(kinds).toEqual([ARCH_SCANNER_KIND, BLAST_SCANNER_KIND].sort()); + expect(rows.every((r) => r.kind === CORRECTNESS_KIND)).toBe(true); + expect(rows.some((r) => r.scanner_kind === BLAST_SCANNER_KIND && r.severity !== 'high')).toBe(true); + const archRow = rows.find((r) => r.scanner_kind === ARCH_SCANNER_KIND && r.id.includes('layered:skip')); + expect(archRow?.receipts).toEqual(['verify:no_test_covering_change:1']); + expect(archRow?.severity).toBe('high'); + const blastRow = rows.find((r) => r.scanner_kind === BLAST_SCANNER_KIND); + expect(blastRow?.id).toBe('blast:scanDir'); + expect(blastRow?.receipts).toEqual(['verify:test_covering_change:2']); + expect(JSON.stringify(rows)).not.toMatch(/cursor|copilot|codeql|semgrep/i); + }); +}); diff --git a/src/review/finding-receipts.ts b/src/review/finding-receipts.ts new file mode 100644 index 0000000..56576ce --- /dev/null +++ b/src/review/finding-receipts.ts @@ -0,0 +1,107 @@ +/** + * Cite existing capsule verification (and already-emitted scan / attest) + * facts on a Review finding. Does not invent a receipt system — only ids + * the capsule already holds are attached. + */ + +import type { AnalysisCapsule, ReviewFinding } from './schemas.js'; + +/** Pack/producer metadata for architecture-policy rows. Not a second kind. */ +export const ARCH_PRODUCER = 'architecture' as const; + +export function normalizeFindingPath(p: string): string { + return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, ''); +} + +/** Key fragments: no whitespace (spaces would make the finding_key unstable). */ +export function keyPart(value: string): string { + return value.trim().replace(/\s+/g, ''); +} + +/** + * Stable `id` / finding_key for an architecture-policy row. + * Reuses the architecture pack's `rule` string when the scanner has one + * (`layered:skip:routing→data-access`, `clean:domain→data-access`, …); + * otherwise the scanner's own rule id (`peer_deviation`, …). + * Repo-relative path. No head SHA. No spaces. + */ +export function archFindingKey(ruleId: string, path: string, extra?: string): string { + const rule = keyPart(ruleId) || 'rule'; + const file = keyPart(normalizeFindingPath(path)); + const tail = extra ? `:${keyPart(normalizeFindingPath(extra))}` : ''; + return `arch:${rule}:${file}${tail}`; +} + +/** + * Existing capsule facts that already function as verification receipts. + * Capsule `verification[]` first; then scan/attest evidence already present. + */ +export function collectVerificationReceiptIds(capsule: AnalysisCapsule, paths: string[]): string[] { + const wanted = new Set(paths.map(normalizeFindingPath).filter(Boolean)); + const ids: string[] = []; + const push = (id: string): void => { + if (!id || ids.includes(id)) return; + ids.push(id); + }; + + for (const fact of capsule.verification) { + if (wanted.size > 0 && !wanted.has(normalizeFindingPath(fact.path))) continue; + ensureVerificationEvidence(capsule, fact); + push(fact.evidence_id); + } + + for (const evidence of capsule.evidence) { + if (!isExistingReceiptId(evidence.id)) continue; + if (wanted.size > 0 && evidence.path && !wanted.has(normalizeFindingPath(evidence.path))) continue; + if (wanted.size > 0 && !evidence.path) continue; + push(evidence.id); + } + + return ids.sort((a, b) => a.localeCompare(b)); +} + +function isExistingReceiptId(id: string): boolean { + return id.startsWith('verify:') || id.startsWith('attest:') || id.startsWith('scan:'); +} + +/** + * Mirror a capsule verification fact into `evidence[]` when it is missing. + * The id and note are the fact's own — this is not a new receipt. + */ +function ensureVerificationEvidence( + capsule: AnalysisCapsule, + fact: AnalysisCapsule['verification'][number], +): void { + if (capsule.evidence.some((e) => e.id === fact.evidence_id)) return; + capsule.evidence.push({ + id: fact.evidence_id, + kind: 'graph_node', + path: normalizeFindingPath(fact.path), + protected_finding: false, + note: fact.detail, + }); +} + +/** Attach existing verification receipts onto a finding. No-op when none exist. */ +export function attachVerificationReceipts( + finding: ReviewFinding, + capsule: AnalysisCapsule, +): ReviewFinding { + const extra = collectVerificationReceiptIds(capsule, finding.paths); + if (extra.length === 0) { + return finding.receipts?.length ? finding : { ...finding, receipts: [] }; + } + const evidenceIds = [...finding.evidence_ids]; + for (const id of extra) { + if (!evidenceIds.includes(id)) evidenceIds.push(id); + } + const receipts = [...new Set([...(finding.receipts ?? []), ...extra])].sort((a, b) => + a.localeCompare(b), + ); + return { ...finding, evidence_ids: evidenceIds, receipts }; +} + +export function isArchitectureFinding(finding: ReviewFinding): boolean { + if (finding.producer === ARCH_PRODUCER) return finding.kind === 'correctness'; + return finding.kind === 'correctness' && finding.id.startsWith('arch:'); +} diff --git a/src/review/git.ts b/src/review/git.ts index 8c02100..791eb93 100644 --- a/src/review/git.ts +++ b/src/review/git.ts @@ -154,8 +154,74 @@ function parseDiffNameStatus(out: string): Map { return map; } +/** + * Build a change-set file list from a unified diff. Used by + * `vg review findings-from-diff --diff` so a saved PR patch can stand in for + * `git diff` without inventing a second change collector. + */ +export function filesFromUnifiedDiff(diff: string): ChangedFile[] { + const hunks = parseHunks(diff); + const counts = new Map(); + let current: string | null = null; + let oldPath: string | null = null; + for (const line of diff.split('\n')) { + if (line.startsWith('--- ')) { + const p = line.slice(4).trim(); + oldPath = p === '/dev/null' ? null : p.replace(/^a\//, ''); + continue; + } + if (line.startsWith('+++ ')) { + const p = line.slice(4).trim(); + current = p === '/dev/null' ? null : p.replace(/^b\//, ''); + if (current && !counts.has(current)) { + const op: ChangedFile['op'] = oldPath === null ? 'added' : p === '/dev/null' ? 'removed' : 'modified'; + counts.set(current, { added: 0, removed: 0, op }); + } + if (!current && oldPath && !counts.has(oldPath)) { + counts.set(oldPath, { added: 0, removed: 0, op: 'removed' }); + } + continue; + } + const target = current ?? (oldPath && counts.has(oldPath) ? oldPath : null); + if (!target) continue; + const entry = counts.get(target); + if (!entry) continue; + if (line.startsWith('+') && !line.startsWith('+++')) entry.added += 1; + else if (line.startsWith('-') && !line.startsWith('---')) entry.removed += 1; + } + const paths = new Set([...hunks.keys(), ...counts.keys()]); + const files: ChangedFile[] = []; + for (const path of paths) { + if (!isReviewable(path)) continue; + const n = counts.get(path); + files.push({ + path, + op: n?.op ?? 'modified', + addedLines: n?.added ?? 0, + removedLines: n?.removed ?? 0, + hunks: hunks.get(path) ?? [], + }); + } + return files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); +} + +/** + * Overlay a unified-diff file list onto git identity (SHAs, remote, ref). + * The patch owns *what* changed; git still owns *which commit pair*. + */ +export function changeSetFromUnifiedDiff(base: ChangeSet, diff: string): ChangeSet { + const files = filesFromUnifiedDiff(diff); + const dirty = files.length > 0; + return { + ...base, + files, + dirty, + dirtyTreeHash: dirty ? dirtyTreeHash(files) : null, + }; +} + /** New-side hunk ranges from a unified diff, keyed by path. */ -function parseHunks(diff: string): Map { +export function parseHunks(diff: string): Map { const map = new Map(); let current: string | null = null; for (const line of diff.split('\n')) { diff --git a/src/review/gold-run.ts b/src/review/gold-run.ts index 6c418a8..1446140 100644 --- a/src/review/gold-run.ts +++ b/src/review/gold-run.ts @@ -219,11 +219,11 @@ export function reviewGoldResolvedModelId( export async function runReviewGoldFixture( fixture: ReviewGoldFixtureFile, - opts: { live?: boolean; modelId?: string } = {}, + opts: { live?: boolean; modelId?: string; providers?: Provider[] } = {}, ): Promise { const fsImpl = memFs({ ...fixture.files }); const liveHappy = isLiveHappyPath(fixture, opts); - const providers = liveHappy ? undefined : providersFor(fixture); + const providers = opts.providers ?? (liveHappy ? undefined : providersFor(fixture)); const result = await proposeFindingFix({ capsule: goldCapsule(fixture.capsulePolicies ?? [], fixture.capsuleEvidence ?? []), finding: goldFinding(fixture.finding), diff --git a/src/review/impact-findings.test.ts b/src/review/impact-findings.test.ts new file mode 100644 index 0000000..f18a1b5 --- /dev/null +++ b/src/review/impact-findings.test.ts @@ -0,0 +1,281 @@ +/** + * Blast-radius Review findings — offline, fixture graph only. + * + * These are the first deterministic graph findings a PR diff can produce + * for `proposeFindingFix`. No git, no model, no network. + */ + +import { describe, expect, it } from 'vitest'; +import { fixtureGraph } from '../code/graph-fixture.js'; +import { proposeFindingFix } from './propose.js'; +import { MockProvider } from '../code/providers.js'; +import type { CodeFs } from '../code/session.js'; +import { + BLAST_PRODUCER, + CORRECTNESS_KIND, + blastFindingKey, + collectBlastRadiusFindings, + MAX_IMPACT_FINDINGS, +} from './impact-findings.js'; +import { capsule, edge, graph, node } from './test-fixtures.js'; +import { verifyFindings } from './verify.js'; +import { FINDINGS_SCHEMA } from './schemas.js'; +import { PATCH_IR_SCHEMA_VERSION, validatePatchIR } from '../code/patch-ir.js'; + +function memFs(seed: Record = {}): CodeFs & { files: Record } { + const files: Record = { ...seed }; + return { + files, + read: (f) => (f in files ? files[f] : null), + write: (f, c) => { + files[f] = c; + }, + remove: (f) => { + files[f] = null; + }, + appendAudit: () => undefined, + }; +} + +function scanCapsule() { + return capsule({ + change: { + ...capsule().change, + symbols: [ + { + node_id: 'scanDir', + name: 'scanDir', + kind: 'function', + path: 'src/scan.ts', + start_line: 5, + end_line: 20, + }, + ], + ops: [{ path: 'src/scan.ts', op: 'modified', added_lines: 1, removed_lines: 0 }], + }, + verification: [ + { + evidence_id: 'verify:no_test_covering_change:1', + kind: 'no_test_covering_change', + path: 'src/scan.ts', + detail: 'no test edge reaches this file in the map', + }, + ], + evidence: [ + { + id: 'verify:no_test_covering_change:1', + kind: 'graph_node', + path: 'src/scan.ts', + protected_finding: false, + note: 'no test edge reaches this file in the map', + }, + ], + }); +} + +describe('collectBlastRadiusFindings', () => { + it('emits a Review-shaped finding for a changed symbol with a cross-file caller', () => { + const c = scanCapsule(); + const findings = collectBlastRadiusFindings({ graph: fixtureGraph(), capsule: c }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + id: 'blast:scanDir', + kind: CORRECTNESS_KIND, + finding_key: 'blast:scanDir', + producer: BLAST_PRODUCER, + severity: 'low', + source: 'scanner', + protected_finding: false, + target_alignment: 'unknown', + }); + expect(findings[0]).not.toHaveProperty('review_check_kind'); + expect(findings[0].paths).toContain('src/scan.ts'); + expect(findings[0].paths).toContain('src/report.ts'); + expect(findings[0].claim).toContain('scanDir'); + expect(findings[0].claim).toContain('formatReport'); + expect(findings[0].claim).toMatch(/1 direct/); + expect(findings[0].remediation).toMatch(/formatReport|exported contract/); + expect(findings[0].evidence_ids.length).toBeGreaterThan(0); + expect(findings[0].evidence_ids).toContain('verify:no_test_covering_change:1'); + expect(findings[0].receipts).toEqual(['verify:no_test_covering_change:1']); + expect(findings[0].confidence).toBeLessThan(0.8); + }); + + it('cites capsule evidence so the findings verifier accepts the document', () => { + const c = scanCapsule(); + const architecture = collectBlastRadiusFindings({ graph: fixtureGraph(), capsule: c }); + const verified = verifyFindings( + { + schema_version: FINDINGS_SCHEMA, + change_class: ['architecture'], + architecture_findings: architecture, + security_findings: [], + unknowns: [], + required_checks: [], + }, + c, + ); + expect(verified.errors).toEqual([]); + expect(verified.schema_valid).toBe(true); + expect(verified.evidence_ids_valid).toBe(true); + }); + + it('skips same-file-only dependents — that is not a blast radius a reviewer can act on', () => { + const g = graph( + [node('hub', 'src/a.ts', { name: 'hub' }), node('local', 'src/a.ts', { name: 'local' })], + [edge('call', 'local', 'hub')], + ); + const c = capsule({ + change: { + ...capsule().change, + symbols: [{ node_id: 'hub', name: 'hub', kind: 'function', path: 'src/a.ts', start_line: 1, end_line: 4 }], + ops: [{ path: 'src/a.ts', op: 'modified', added_lines: 1, removed_lines: 0 }], + }, + }); + expect(collectBlastRadiusFindings({ graph: g, capsule: c })).toEqual([]); + }); + + it('skips a changed symbol with no dependents rather than inventing a zero-radius finding', () => { + const g = graph([node('lonely', 'src/z.ts', { name: 'lonely' })]); + const c = capsule({ + change: { + ...capsule().change, + symbols: [ + { node_id: 'lonely', name: 'lonely', kind: 'function', path: 'src/z.ts', start_line: 1, end_line: 2 }, + ], + ops: [{ path: 'src/z.ts', op: 'modified', added_lines: 1, removed_lines: 0 }], + }, + }); + expect(collectBlastRadiusFindings({ graph: g, capsule: c })).toEqual([]); + }); + + it('is deterministic: same graph + symbols yields the same ids and claim text', () => { + const c = scanCapsule(); + const a = collectBlastRadiusFindings({ graph: fixtureGraph(), capsule: c }); + const b = collectBlastRadiusFindings({ graph: fixtureGraph(), capsule: structuredClone(c) }); + expect(a).toEqual(b); + }); + + it('keeps the same id across different head SHAs for the same symbol/node', () => { + const shaA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const shaB = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const base = scanCapsule(); + const withSha = (head: string): typeof base => ({ + ...base, + change: { ...base.change, head_sha: head, base_sha: head === shaA ? '1'.repeat(40) : '2'.repeat(40) }, + }); + const a = collectBlastRadiusFindings({ graph: fixtureGraph(), capsule: withSha(shaA) }); + const b = collectBlastRadiusFindings({ graph: fixtureGraph(), capsule: withSha(shaB) }); + expect(a).toHaveLength(1); + expect(a[0].id).toBe('blast:scanDir'); + expect(a[0].id).toBe(b[0].id); + expect(a[0].finding_key).toBe(a[0].id); + expect(a[0].id).not.toMatch(/\s/); + expect(a[0].id).not.toContain(shaA); + expect(a[0].id).not.toContain(shaB); + expect(JSON.stringify(a[0])).not.toContain(shaA); + expect(JSON.stringify(a[0])).not.toContain(shaB); + expect(a[0].kind).toBe(CORRECTNESS_KIND); + expect(Object.keys(a[0]).filter((k) => k === 'kind')).toHaveLength(1); + }); + + it('caps fan-out so a wide change cannot flood the receipt', () => { + const nodes = [node('hub', 'src/hub.ts', { name: 'hub' })]; + const edges = []; + for (let i = 0; i < MAX_IMPACT_FINDINGS + 3; i++) { + const id = `fn${String(i).padStart(2, '0')}`; + nodes.push(node(id, `src/${id}.ts`, { name: id })); + // Each extra function is itself a changed symbol with hub as a dependent? + // Rank by hub's callers: many files call hub. + edges.push(edge('call', id, 'hub')); + } + const g = graph(nodes, edges); + const c = capsule({ + change: { + ...capsule().change, + symbols: [ + { node_id: 'hub', name: 'hub', kind: 'function', path: 'src/hub.ts', start_line: 1, end_line: 8 }, + ], + ops: [{ path: 'src/hub.ts', op: 'modified', added_lines: 2, removed_lines: 0 }], + }, + }); + const findings = collectBlastRadiusFindings({ graph: g, capsule: c }); + expect(findings).toHaveLength(1); + expect(findings[0].claim).toMatch(/direct/); + expect(findings[0].severity).toBe('medium'); + }); + + it('does not treat an unknown node_id as a zero-radius hit', () => { + const c = capsule({ + change: { + ...capsule().change, + symbols: [ + { node_id: 'missing', name: 'ghost', kind: 'function', path: 'src/x.ts', start_line: 1, end_line: 2 }, + ], + ops: [{ path: 'src/x.ts', op: 'modified', added_lines: 1, removed_lines: 0 }], + }, + }); + expect(collectBlastRadiusFindings({ graph: fixtureGraph(), capsule: c })).toEqual([]); + }); +}); + +describe('blastFindingKey', () => { + it('prefers blast:{node_id} and never embeds a head SHA or spaces', () => { + expect(blastFindingKey({ node_id: 'scanDir', path: 'src/scan.ts', name: 'scanDir' })).toBe( + 'blast:scanDir', + ); + expect(blastFindingKey({ node_id: ' scan Dir ', path: 'src/scan.ts', name: 'scanDir' })).toBe( + 'blast:scanDir', + ); + const sha = 'cccccccccccccccccccccccccccccccccccccccc'; + expect(blastFindingKey({ node_id: 'hub', path: 'src/hub.ts', name: 'hub' })).not.toContain(sha); + expect(blastFindingKey({ node_id: 'hub', path: 'src/hub.ts', name: 'hub' })).not.toMatch(/\s/); + }); + + it('falls back to blast:{path}:{name} with a repo-relative path', () => { + expect(blastFindingKey({ path: './src/scan.ts', name: 'scanDir' })).toBe('blast:src/scan.ts:scanDir'); + expect(blastFindingKey({ node_id: ' ', path: '/src/scan.ts', name: 'scanDir' })).toBe( + 'blast:src/scan.ts:scanDir', + ); + expect(blastFindingKey({ path: 'src/my file.ts', name: 'my fn' })).toBe('blast:src/myfile.ts:myfn'); + expect(blastFindingKey({ path: 'src/my file.ts', name: 'my fn' })).not.toMatch(/\s/); + }); +}); + +describe('correctness finding → proposeFindingFix', () => { + it('dry-run PatchIR from a graph finding without a second loop', async () => { + const c = scanCapsule(); + const [finding] = collectBlastRadiusFindings({ graph: fixtureGraph(), capsule: c }); + expect(finding).toBeTruthy(); + const body = 'export function scanDir() {\n const timeout = 0;\n return timeout;\n}\n'; + const fsImpl = memFs({ 'src/scan.ts': body }); + const r = await proposeFindingFix({ + capsule: c, + finding, + policySnippet: finding.remediation, + modelId: 'relay:hosted-coder', + root: '/repo', + graph: fixtureGraph(), + fsImpl, + loop: false, + currentRef: 'refs/heads/feat/impact', + noCheckpoint: true, + correlationId: 'rp-impact-01', + providers: [ + new MockProvider( + 'hosted-coder', + ['src/scan.ts', '<<<<<<< SEARCH', 'const timeout = 0;', '=======', 'const timeout = 5000;', '>>>>>>> REPLACE'].join( + '\n', + ), + ), + ], + }); + expect(r.ok).toBe(true); + expect(r.applied).toBe(false); + expect(r.stopReason).toBe('finished'); + expect(r.patch?.schemaVersion).toBe(PATCH_IR_SCHEMA_VERSION); + expect(validatePatchIR(r.patch!).ok).toBe(true); + expect(r.proposedDiff).toContain('const timeout = 5000'); + expect(fsImpl.files['src/scan.ts']).toBe(body); + }); +}); diff --git a/src/review/impact-findings.ts b/src/review/impact-findings.ts new file mode 100644 index 0000000..a2435e3 --- /dev/null +++ b/src/review/impact-findings.ts @@ -0,0 +1,225 @@ +/** + * Deterministic blast-radius findings for a Review change set. + * + * Reuses {@link impactOf} — the same reverse-reachability `vg impact` uses — + * over the capsule's changed symbols. Findings are `vg.review.findings.v1` + * rows so {@link proposeFindingFix} can attach a PatchIR proposal without a + * second agent loop. + * + * These are graph facts, not architecture-policy verdicts and not a hosted + * model. Severity stays at or below `medium` so a blast-radius observation + * cannot trip the high-severity gate on its own. + */ + +import { impactOf, type ImpactItem, type ImpactResult } from '../engine/impact.js'; +import { testsToRun } from '../engine/test-query.js'; +import type { VgGraph } from '../schema.js'; +import { attachVerificationReceipts } from './finding-receipts.js'; +import type { AnalysisCapsule, CapsuleChangeSymbol, ReviewFinding } from './schemas.js'; + +/** Same default depth as `vg impact`. */ +export const IMPACT_FINDING_DEPTH = 4; + +/** Cap so a wide PR cannot flood the receipt. Ranked by fan-out. */ +export const MAX_IMPACT_FINDINGS = 8; + +/** Callers named in the claim / evidence (the rest stay in the count). */ +const MAX_NAMED_CALLERS = 4; + +export interface BlastRadiusScanInput { + graph: VgGraph; + capsule: AnalysisCapsule; + depth?: number; +} + +/** Top-level `kind` for blast-radius/impact rows. Not a second kind. */ +export const CORRECTNESS_KIND = 'correctness' as const; + +/** Pack/producer metadata only — never a second top-level `kind`. */ +export const BLAST_PRODUCER = 'blast_radius' as const; + +/** + * Stable `id` used as finding_key. Same symbol/node → same key across + * runs, rank, and head SHAs. Prefer the graph node id; else path + name. + * Repo-relative path. No head SHA. No spaces. + */ +export function blastFindingKey(symbol: Pick): string { + const nodeId = keyPart(symbol.node_id ?? ''); + if (nodeId) return `blast:${nodeId}`; + return `blast:${keyPart(normalize(symbol.path))}:${keyPart(symbol.name)}`; +} + +/** + * Emit one finding per changed symbol that has a cross-file dependent. + * Same-file-only fan-out is not a blast radius a reviewer can act on. + * Deterministic: symbols and dependents are sorted before emission. + * `id` is the stable blast key, not a rank-local display id. + */ +export function collectBlastRadiusFindings(input: BlastRadiusScanInput): ReviewFinding[] { + const { graph, capsule } = input; + const depth = input.depth ?? IMPACT_FINDING_DEPTH; + + const ranked: RankedImpact[] = []; + for (const symbol of sortSymbols(capsule.change.symbols)) { + if (!symbol.node_id) continue; + if (!graph.nodes.some((n) => n.id === symbol.node_id)) continue; + const impact = impactOf(graph, symbol.node_id, { depth }); + const crossFile = impact.affected.filter((a) => normalize(a.file) !== normalize(symbol.path)); + if (crossFile.length === 0) continue; + ranked.push({ symbol, impact, crossFile }); + } + + ranked.sort(compareRanked); + + const findings: ReviewFinding[] = []; + for (const row of ranked.slice(0, MAX_IMPACT_FINDINGS)) { + findings.push(toFinding(capsule, graph, row)); + } + return findings; +} + +interface RankedImpact { + symbol: CapsuleChangeSymbol; + impact: ImpactResult; + crossFile: ImpactItem[]; +} + +function sortSymbols(symbols: CapsuleChangeSymbol[]): CapsuleChangeSymbol[] { + return [...symbols].sort( + (a, b) => + a.path.localeCompare(b.path) || + a.name.localeCompare(b.name) || + (a.node_id ?? '').localeCompare(b.node_id ?? ''), + ); +} + +function compareRanked(a: RankedImpact, b: RankedImpact): number { + const aDirect = a.crossFile.filter((x) => x.depth === 1).length; + const bDirect = b.crossFile.filter((x) => x.depth === 1).length; + if (bDirect !== aDirect) return bDirect - aDirect; + if (b.crossFile.length !== a.crossFile.length) return b.crossFile.length - a.crossFile.length; + return ( + a.symbol.path.localeCompare(b.symbol.path) || + a.symbol.name.localeCompare(b.symbol.name) || + (a.symbol.node_id ?? '').localeCompare(b.symbol.node_id ?? '') + ); +} + +function toFinding( + capsule: AnalysisCapsule, + graph: VgGraph, + row: RankedImpact, +): ReviewFinding { + const { symbol, crossFile } = row; + const id = blastFindingKey(symbol); + const named = crossFile.slice(0, MAX_NAMED_CALLERS); + const direct = crossFile.filter((a) => a.depth === 1).length; + const transitive = crossFile.filter((a) => a.depth > 1).length; + const files = [...new Set(crossFile.map((a) => normalize(a.file)))].sort(); + const callerNames = named.map((a) => `${shortName(a.name)} in ${normalize(a.file)}`); + const extra = crossFile.length - named.length; + + const evidenceIds = impactEvidence(capsule, symbol, named); + const untested = capsule.verification.some( + (v) => v.kind === 'no_test_covering_change' && normalize(v.path) === normalize(symbol.path), + ); + + let coveringTests = 0; + if (symbol.node_id) { + coveringTests = testsToRun(graph, symbol.node_id, IMPACT_FINDING_DEPTH).affectedTestFiles.length; + } + + const more = extra > 0 ? ` (and ${extra} more)` : ''; + const testNote = untested + ? ' No test edge reaches this file in the map.' + : coveringTests > 0 + ? ` ${coveringTests} covering test file(s) reach the blast radius.` + : ''; + + return attachVerificationReceipts( + { + id, + kind: CORRECTNESS_KIND, + finding_key: id, + producer: BLAST_PRODUCER, + severity: direct >= 3 ? 'medium' : 'low', + confidence: confidenceOf(named), + claim: + `Changing ${symbol.name} in ${normalize(symbol.path)} reaches ${direct} direct and ${transitive} ` + + `transitive dependents across ${files.length} file(s): ${callerNames.join(', ') || '(none)'}${more}.` + + testNote, + evidence_ids: evidenceIds, + target_alignment: 'unknown', + remediation: untested + ? `Add a test that exercises the highest-fan-out caller (${shortName(named[0]?.name) || symbol.name}) before merging, or keep the exported contract of ${symbol.name} compatible with those callers.` + : `Keep the exported contract of ${symbol.name} compatible with the listed callers, or extend the covering tests so that contract is locked.`, + paths: [normalize(symbol.path), ...files].filter((p, i, all) => all.indexOf(p) === i).slice(0, 8), + protected_finding: false, + source: 'scanner', + }, + capsule, + ); +} + +function impactEvidence( + capsule: AnalysisCapsule, + symbol: CapsuleChangeSymbol, + callers: ImpactItem[], +): string[] { + const ids: string[] = []; + const rootId = `impact:${symbol.node_id ?? symbol.name}`; + upsertEvidence(capsule, { + id: rootId, + kind: 'graph_node', + path: normalize(symbol.path), + start_line: symbol.start_line, + end_line: symbol.end_line, + protected_finding: false, + note: `changed ${symbol.kind} ${symbol.name}`, + }); + ids.push(rootId); + for (const caller of callers) { + const id = `impact:${symbol.node_id ?? symbol.name}:dep:${caller.id}`; + upsertEvidence(capsule, { + id, + kind: 'graph_node', + path: normalize(caller.file), + start_line: caller.line, + end_line: caller.line, + protected_finding: false, + note: `d${caller.depth} ${caller.name} depends on ${symbol.name}`, + }); + ids.push(id); + } + return ids; +} + +function upsertEvidence( + capsule: AnalysisCapsule, + evidence: AnalysisCapsule['evidence'][number], +): void { + if (capsule.evidence.some((e) => e.id === evidence.id)) return; + capsule.evidence.push(evidence); +} + +function confidenceOf(callers: ImpactItem[]): number { + if (callers.length === 0) return 0.7; + const min = Math.min(...callers.map((c) => c.confidence)); + // Calibrated below the high-severity gate (0.8) even when every edge is 1.0. + return Math.min(0.75, Math.max(0.55, Math.round(min * 1e3) / 1e3)); +} + +function shortName(name: string | undefined): string { + if (!name) return ''; + const tail = name.split(':').pop() ?? name; + return tail.split('/').pop() ?? tail; +} + +function normalize(p: string): string { + return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, ''); +} + +/** Key fragments: no whitespace (spaces would make the finding_key unstable). */ +function keyPart(value: string): string { + return value.trim().replace(/\s+/g, ''); +} diff --git a/src/review/propose.test.ts b/src/review/propose.test.ts index 5fbafac..3951098 100644 --- a/src/review/propose.test.ts +++ b/src/review/propose.test.ts @@ -10,6 +10,7 @@ import { AGENT_NO_PROGRESS_STOP_AT } from '../code/agent.js'; import { fixtureGraph } from '../code/graph-fixture.js'; import { PATCH_IR_SCHEMA_VERSION, validatePatchIR } from '../code/patch-ir.js'; import { MockProvider, ScriptedProvider } from '../code/providers.js'; +import { withToolCallFallback } from '../code/text-tool-protocol.js'; import type { CodeFs } from '../code/session.js'; import type { Provider, ToolCall } from '../code/types.js'; import { @@ -93,6 +94,8 @@ describe('buildReviewProposeInstruction', () => { expect(text).toContain('edit_file'); expect(text).toContain('finish'); 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).not.toMatch(/<<<<<<< SEARCH/); }); @@ -291,6 +294,52 @@ describe('proposeFindingFix — Review contract', () => { expect(r.error).toMatch(/fallback backend/i); }); + it('Code Mode residual SEARCH/REPLACE drives the loop (not no-tools) and yields PatchIR', async () => { + // Deterministic llama-cpp-shaped mock: supportsTools false, residual + // oneshot block, no tags. happy-path-loop must not regress + // to stopReason no-tools after AGENT_EMPTY_REPLY_RETRIES (3 steps). + const residual = [ + 'src/scan.ts', + '<<<<<<< SEARCH', + 'const timeout = 0;', + '=======', + 'const timeout = 5000;', + '>>>>>>> REPLACE', + ].join('\n'); + const backend: Provider & { seen: number } = { + id: 'llama-cpp', + label: 'Vibgrate (local)', + local: true, + model: 'forge-pack', + supportsTools: false, + seen: 0, + async chat() { + backend.seen += 1; + return { text: residual, model: 'forge-pack', provider: 'llama-cpp' }; + }, + }; + const fsImpl = memFs({ 'src/scan.ts': baseFile }); + const r = await proposeFindingFix( + baseInput({ + loop: true, + providers: [withToolCallFallback(backend)], + fsImpl, + modelId: 'forge', + }), + ); + 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).toBeGreaterThanOrEqual(1); + expect(r.steps).toBeLessThanOrEqual(REVIEW_PROPOSE_LOOP_CAP); + expect(r.applied).toBe(false); + expect(fsImpl.files['src/scan.ts']).toBe(baseFile); + }); + 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 72305aa..85a275e 100644 --- a/src/review/propose.ts +++ b/src/review/propose.ts @@ -236,7 +236,8 @@ export function buildReviewProposeInstruction(input: ReviewProposeInput): string ? [ `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.', - 'Call edit_file on the cited path (SEARCH must match the current snippet). Then call finish with a short summary.', + '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.', 'Smallest in-place edit only. Do not add files or invent an extra service.', ].join(' ') : [ @@ -533,8 +534,9 @@ function finalizePropose(args: { error = args.finalText || `agent stopped (${args.stopped}) (ref ${correlationId})`; } else if (args.stopped === 'no-tools' || args.stopped === 'no-progress' || args.stopped === 'max-steps') { // Edit-ask gate turns plan-only `finish` (0 writes) into agent no-tools. - // Review's contract for that is no-patch, not a leaked no-tools. Empty - // replies and no-progress stay themselves. + // Review's contract for that is no-patch, not a leaked no-tools. + // A write that already landed (residual rescue / edit_file) is PatchIR + // success — do not leak no-tools when the patch validates. const planOnlyFinish = args.stopped === 'no-tools' && (!patch || !validation.ok) && @@ -542,6 +544,9 @@ function finalizePropose(args: { if (planOnlyFinish) { stopReason = 'no-patch'; error = `the model finished without a patch (ref ${correlationId})`; + } else if (patch && validation.ok) { + stopReason = 'finished'; + ok = !args.applyRequested || args.persist; } else { error = args.finalText || `agent stopped (${args.stopped}) (ref ${correlationId})`; } diff --git a/src/review/review-gold.test.ts b/src/review/review-gold.test.ts index dc52acf..c3a0045 100644 --- a/src/review/review-gold.test.ts +++ b/src/review/review-gold.test.ts @@ -10,6 +10,8 @@ import { describe, it, expect } from 'vitest'; import { PATCH_IR_SCHEMA_VERSION, validatePatchIR } from '../code/patch-ir.js'; +import { withToolCallFallback } from '../code/text-tool-protocol.js'; +import type { Provider } from '../code/types.js'; import { loadReviewGoldFixtureFile, reviewGoldResolvedModelId, @@ -68,6 +70,41 @@ describe('review-gold — proposeFindingFix (offline, no live model)', () => { expect(result.usage).toEqual({ promptTokens: expect.any(Number), completionTokens: expect.any(Number) }); }); + it('happy-path-loop cannot regress to no-tools when a Code Mode emits residual SEARCH/REPLACE', async () => { + // Deterministic llama-cpp-shaped mock (supportsTools: false, no + // tags). The live Code Mode miss was stopReason no-tools after 3 steps. + const fixture = fixtures.find((f) => f.id === 'happy-path-loop')!; + const residual = [ + 'src/scan.ts', + '<<<<<<< SEARCH', + 'const timeout = 0;', + '=======', + 'const timeout = 5000;', + '>>>>>>> REPLACE', + ].join('\n'); + const backend: Provider = { + id: 'llama-cpp', + label: 'Vibgrate (local)', + local: true, + model: 'forge-pack', + supportsTools: false, + async chat() { + return { text: residual, model: 'forge-pack', 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(filesAfter['src/scan.ts']).toBe(fixture.files['src/scan.ts']); + }); + 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 ca31a6c..eed9ff2 100644 --- a/src/review/review.test.ts +++ b/src/review/review.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest'; import { parseReviewConfig, DEFAULT_REVIEW_CONFIG, loadReviewConfig, type ReviewConfig } from './config.js'; import { + changeSetFromUnifiedDiff, collectChangeSet, dirtyTreeHash, + filesFromUnifiedDiff, isReviewable, normalizeRemote, readBaseFileTexts, @@ -637,6 +639,59 @@ describe('change set', () => { ].join('\n'); expect(removedLinesFromDiff(diff).get('src/api.ts')).toEqual([' requireAuth(req)']); }); + + it('filesFromUnifiedDiff reads paths, hunks, and line counts from a patch', () => { + const diff = [ + '--- a/src/api.ts', + '+++ b/src/api.ts', + '@@ -1,3 +1,4 @@', + ' keep', + '-old', + '+new', + '+extra', + ].join('\n'); + expect(filesFromUnifiedDiff(diff)).toEqual([ + { path: 'src/api.ts', op: 'modified', addedLines: 2, removedLines: 1, hunks: [{ start: 1, end: 4 }] }, + ]); + }); + + it('filesFromUnifiedDiff records an added file and skips .vibgrate artifacts', () => { + const diff = [ + '--- /dev/null', + '+++ b/src/new.ts', + '@@ -0,0 +1,2 @@', + '+export const x = 1', + '+export const y = 2', + '--- a/.vibgrate/graph.json', + '+++ b/.vibgrate/graph.json', + '@@ -1 +1 @@', + '-{}', + '+{"ok":true}', + ].join('\n'); + expect(filesFromUnifiedDiff(diff)).toEqual([ + { path: 'src/new.ts', op: 'added', addedLines: 2, removedLines: 0, hunks: [{ start: 1, end: 2 }] }, + ]); + }); + + it('changeSetFromUnifiedDiff overlays the patch onto git identity', () => { + const base = collectChangeSet('/repo', undefined, (args) => { + const key = args.join(' '); + if (key === 'rev-parse HEAD') return { stdout: 'c'.repeat(40), status: 0 }; + if (key === 'rev-parse --show-toplevel') return { stdout: '/repo', status: 0 }; + if (key === 'rev-parse --abbrev-ref HEAD') return { stdout: 'feat/x', status: 0 }; + return { stdout: '', status: 0 }; + }); + const overlaid = changeSetFromUnifiedDiff( + base, + ['--- a/src/api.ts', '+++ b/src/api.ts', '@@ -2,1 +2,2 @@', '-a', '+b', '+c'].join('\n'), + ); + expect(overlaid.headSha).toBe('c'.repeat(40)); + expect(overlaid.ref).toBe('refs/heads/feat/x'); + expect(overlaid.files).toHaveLength(1); + expect(overlaid.files[0].path).toBe('src/api.ts'); + expect(overlaid.dirty).toBe(true); + expect(overlaid.dirtyTreeHash).toMatch(/^sha256:/); + }); }); // ── layer-skip rule ───────────────────────────────────────────────────────── @@ -768,9 +823,13 @@ describe('vg review command surface', () => { expect(review!.options.find((o) => o.long === '--include-snippets')!.attributeName()).toBe('includeSnippets'); }); - it('registers `vg review explain `', () => { + it('registers `vg review explain`, `findings-from-diff`, `propose`, and `verify`', () => { const review = buildProgram().commands.find((c) => c.name() === 'review'); - expect(review!.commands.map((c) => c.name())).toContain('explain'); + expect(review!.commands.map((c) => c.name())).toEqual( + expect.arrayContaining(['explain', 'findings-from-diff', 'propose', 'verify']), + ); + const findings = review!.commands.find((c) => c.name() === 'findings-from-diff'); + expect(findings!.options.map((o) => o.long)).toEqual(expect.arrayContaining(['--base', '--diff', '--format'])); }); 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 e913a41..a94e0d1 100644 --- a/src/review/run.test.ts +++ b/src/review/run.test.ts @@ -232,12 +232,67 @@ describe('runReview — pipeline wiring', () => { node('copy', 'src/orders.ts', { name: 'computeBillSum', span: { start: 1, end: 8 } }), ]); const { receipt } = await review(root, mapFile(root, g), { status: '?? src/orders.ts\n', numstat: '8\t0\tsrc/orders.ts\n' }); - const dup = receipt.findings.architecture_findings.find((f) => f.kind === 'duplicate_implementation'); + const dup = receipt.findings.architecture_findings.find( + (f) => f.producer === 'architecture' && f.id.includes('duplicate_implementation'), + ); expect(dup).toBeDefined(); expect(dup!.claim).toContain('computeBillSum in src/orders.ts'); expect(dup!.claim).toContain('calculateInvoiceTotal in src/billing.ts:1'); }); + it('emits a correctness finding when a changed symbol has a cross-file dependent', async () => { + const { root, graphPath } = routeRepo(); + const { receipt, capsule } = await review(root, graphPath, modified('src/services/invoices.ts')); + const impact = receipt.findings.architecture_findings.find((f) => f.producer === 'blast_radius'); + expect(impact).toBeDefined(); + expect(impact!.id).toMatch(/^blast:/); + expect(impact!.id).not.toMatch(/\s/); + expect(impact!.finding_key).toBe(impact!.id); + expect(impact!.producer).toBe('blast_radius'); + expect(impact!).not.toHaveProperty('review_check_kind'); + expect(impact!.paths).toEqual(expect.arrayContaining(['src/services/invoices.ts', ROUTE])); + expect(impact!.claim).toMatch(/direct/); + expect(impact!.source).toBe('scanner'); + expect(impact!.protected_finding).toBe(false); + expect(['low', 'medium']).toContain(impact!.severity); + expect(capsule.evidence.some((e) => impact!.evidence_ids.includes(e.id))).toBe(true); + expect(receipt.verification.evidence_ids_valid).toBe(true); + }); + + it('accepts an injected unified-diff change set (findings-from-diff --diff)', async () => { + const { root, graphPath } = routeRepo(); + const diff = [ + `--- a/src/services/invoices.ts`, + `+++ b/src/services/invoices.ts`, + '@@ -1,1 +1,2 @@', + ' export const listInvoices = () => [];', + '+export const extra = 1;', + ].join('\n'); + const { receipt } = await review(root, graphPath, modified('src/services/invoices.ts'), { + change: { + topLevel: root, + baseSha: HEAD, + headSha: HEAD, + mergeBase: null, + ref: 'refs/heads/feat/x', + dirty: true, + dirtyTreeHash: 'sha256:test', + files: [ + { + path: 'src/services/invoices.ts', + op: 'modified', + addedLines: 1, + removedLines: 0, + hunks: [{ start: 1, end: 2 }], + }, + ], + remote: null, + }, + diffText: diff, + }); + expect(receipt.findings.architecture_findings.map((f) => f.kind)).toContain('correctness'); + }); + it('reads the declared intent and the working-tree policy from the repository root', async () => { const { root, graphPath } = routeRepo(); fs.writeFileSync(path.join(root, 'CLAUDE.md'), '# Rules\n\nWe use a layered architecture.\n'); diff --git a/src/review/run.ts b/src/review/run.ts index a36bc70..25a970d 100644 --- a/src/review/run.ts +++ b/src/review/run.ts @@ -90,6 +90,13 @@ export interface RunReviewOptions { run?: GitRunner; /** Injected in tests so the explain path does not need a real model. */ explainImpl?: typeof import('./explain.js').explainFindings; + /** + * Injected change set. Used by tests and by `vg review findings-from-diff + * --diff` so a saved unified diff can stand in for `git diff`. + */ + change?: ChangeSet; + /** Unified diff text used for guard-removal (overrides the git diff read). */ + diffText?: string; } export interface RunReviewResult { @@ -168,7 +175,7 @@ export async function runReview(opts: RunReviewOptions): Promise f.path)); diff --git a/src/review/scanners.test.ts b/src/review/scanners.test.ts index afc4065..dff451a 100644 --- a/src/review/scanners.test.ts +++ b/src/review/scanners.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { fixtureGraph } from '../code/graph-fixture.js'; import { DEFAULT_REVIEW_CONFIG } from './config.js'; import type { DominanceVote } from './dominance.js'; import { runScanners, vulnerablePackagesFromScan, type ScanInput } from './scanners.js'; @@ -62,16 +63,19 @@ describe('boundary_bypass', () => { const out = runScanners(input({ capsule: c })); expect(out.architecture).toHaveLength(1); expect(out.architecture[0]).toMatchObject({ - id: 'arch-01', - kind: 'boundary_bypass', + id: 'arch:layered:skip:routing→data-access:src/routes/x.ts:src/repositories/r.ts', + kind: 'correctness', + finding_key: 'arch:layered:skip:routing→data-access:src/routes/x.ts:src/repositories/r.ts', + producer: 'architecture', severity: 'high', confidence: 0.93, target_alignment: 'regression', protected_finding: false, source: 'scanner', paths: ['src/routes/x.ts', 'src/repositories/r.ts'], - evidence_ids: ['edge:1', 'policy:layering:1'], }); + expect(out.architecture[0].evidence_ids).toEqual(expect.arrayContaining(['edge:1', 'policy:layering:1'])); + expect(out.architecture[0].id).not.toMatch(/\s/); expect(out.architecture[0].claim).toContain('skipping middleware, services'); expect(out.architecture[0].remediation).toContain('application service'); expect(out.security).toEqual([]); @@ -118,7 +122,9 @@ describe('boundary_bypass', () => { patterns: patterns('clean', 'clean'), }); const [f] = runScanners(input({ capsule: c })).architecture; - expect(f.kind).toBe('boundary_bypass'); + expect(f.kind).toBe('correctness'); + expect(f.producer).toBe('architecture'); + expect(f.id).toBe('arch:clean:domain→data-access:src/domain/d.ts:src/repositories/r.ts'); expect(f.claim).toContain('clean:domain→data-access'); expect(f.remediation).toContain('application service'); }); @@ -398,8 +404,10 @@ describe('peer_deviation', () => { const out = runScanners(input(deviating)); expect(out.architecture).toHaveLength(1); expect(out.architecture[0]).toMatchObject({ - id: 'arch-01', - kind: 'peer_deviation', + id: 'arch:peer_deviation:src/routes/x.ts', + kind: 'correctness', + finding_key: 'arch:peer_deviation:src/routes/x.ts', + producer: 'architecture', severity: 'high', confidence: 0.9, target_alignment: 'regression', @@ -491,8 +499,10 @@ function sendWelcomeEmail(user, template) { expect(out.architecture).toHaveLength(1); const [f] = out.architecture; expect(f).toMatchObject({ - id: 'arch-01', - kind: 'duplicate_implementation', + id: 'arch:duplicate_implementation:src/orders.ts:computeBillSum', + kind: 'correctness', + finding_key: 'arch:duplicate_implementation:src/orders.ts:computeBillSum', + producer: 'architecture', severity: 'medium', target_alignment: 'regression', paths: ['src/orders.ts', 'src/billing.ts'], @@ -542,7 +552,7 @@ function sendWelcomeEmail(user, template) { // ── 8. unverified change ──────────────────────────────────────────────────── describe('unverified_change', () => { - it('rolls every uncovered file into one finding and requires a call-path test', () => { + it('emits one stable architecture finding per uncovered changed file', () => { const c = capsule({ verification: [ { evidence_id: 'verify:no_test_covering_change:1', kind: 'no_test_covering_change', path: 'src/a.ts', detail: 'no test edge' }, @@ -551,16 +561,22 @@ describe('unverified_change', () => { ], }); const out = runScanners(input({ capsule: c })); - expect(out.architecture).toHaveLength(1); + expect(out.architecture).toHaveLength(2); + expect(out.architecture.map((f) => f.id)).toEqual([ + 'arch:unverified_change:src/a.ts', + 'arch:unverified_change:src/c.ts', + ]); expect(out.architecture[0]).toMatchObject({ - kind: 'unverified_change', + kind: 'correctness', + producer: 'architecture', severity: 'medium', confidence: 0.7, target_alignment: 'unknown', - paths: ['src/a.ts', 'src/c.ts'], - evidence_ids: ['verify:no_test_covering_change:1', 'verify:no_test_covering_change:3'], + paths: ['src/a.ts'], + evidence_ids: expect.arrayContaining(['verify:no_test_covering_change:1']), + receipts: ['verify:no_test_covering_change:1'], }); - expect(out.architecture[0].claim).toContain('2 changed file(s)'); + expect(out.architecture[0].claim).toContain('src/a.ts'); expect(out.requiredChecks).toEqual(['changed-call-path-test']); }); @@ -574,6 +590,40 @@ describe('unverified_change', () => { }); }); +describe('correctness blast-radius (graph wiring)', () => { + it('emits blast:{node_id} when the code map is supplied and a changed symbol has a cross-file caller', () => { + const c = capsule({ + change: { + ...capsule().change, + symbols: [ + { node_id: 'scanDir', name: 'scanDir', kind: 'function', path: 'src/scan.ts', start_line: 5, end_line: 20 }, + ], + ops: [{ path: 'src/scan.ts', op: 'modified', added_lines: 1, removed_lines: 0 }], + }, + }); + const out = runScanners(input({ capsule: c, graph: fixtureGraph() })); + const hit = out.architecture.find((f) => f.producer === 'blast_radius'); + expect(hit?.kind).toBe('correctness'); + expect(hit?.id).toBe('blast:scanDir'); + expect(hit?.producer).toBe('blast_radius'); + expect(hit?.source).toBe('scanner'); + }); + + it('emits no correctness blast-radius row when the map is omitted — absent is not zero-radius', () => { + const c = capsule({ + change: { + ...capsule().change, + symbols: [ + { node_id: 'scanDir', name: 'scanDir', kind: 'function', path: 'src/scan.ts', start_line: 5, end_line: 20 }, + ], + ops: [{ path: 'src/scan.ts', op: 'modified', added_lines: 1, removed_lines: 0 }], + }, + }); + const out = runScanners(input({ capsule: c })); + expect(out.architecture.filter((f) => f.producer === 'blast_radius')).toEqual([]); + }); +}); + // ── cross-rule bookkeeping ────────────────────────────────────────────────── describe('runScanners bookkeeping', () => { @@ -590,7 +640,11 @@ describe('runScanners bookkeeping', () => { vulnerablePackages: [{ package: 'lodash', detail: 'advisory' }], }), ); - expect(out.architecture.map((f) => f.id)).toEqual(['arch-01', 'arch-02']); + expect(out.architecture.map((f) => f.id).sort()).toEqual([ + 'arch:layered:skip:routing→data-access:src/routes/x.ts:src/repositories/r.ts', + 'arch:unverified_change:src/routes/x.ts', + ]); + expect(out.architecture.every((f) => f.kind === 'correctness' && f.producer === 'architecture')).toBe(true); expect(out.security.map((f) => [f.id, f.kind])).toEqual([ ['sec-01', 'guard_removed'], ['sec-02', 'known_vulnerable_dependency'], diff --git a/src/review/scanners.ts b/src/review/scanners.ts index 87940dd..d2f3d81 100644 --- a/src/review/scanners.ts +++ b/src/review/scanners.ts @@ -23,9 +23,16 @@ import { routesForFile } from './routes.js'; import { isComparable, SimilarityIndex, type FunctionBody } from './similarity.js'; import { isDependencyManifest } from './surface.js'; import type { ArchitectureLayer } from '../core-open/types.js'; +import type { VgGraph } from '../schema.js'; import type { ReviewConfig } from './config.js'; import type { ChangeSet } from './git.js'; -import type { AnalysisCapsule, ReviewFinding, TargetAlignment } from './schemas.js'; +import { collectBlastRadiusFindings, CORRECTNESS_KIND } from './impact-findings.js'; +import { + ARCH_PRODUCER, + archFindingKey, + attachVerificationReceipts, +} from './finding-receipts.js'; +import type { AnalysisCapsule, FindingSeverity, ReviewFinding, TargetAlignment } from './schemas.js'; const EXEMPT_LAYERS = new Set(['config', 'shared', 'testing']); @@ -73,6 +80,11 @@ export interface ScanInput { similarity?: SimilarityIndex; /** Function bodies introduced or modified by this change. */ changedBodies?: FunctionBody[]; + /** + * Built code map. When present, scanners emit blast-radius findings for + * changed symbols that have cross-file dependents (`vg impact`). + */ + graph?: VgGraph | null; } export interface ScanOutput { @@ -105,7 +117,6 @@ export function runScanners(input: ScanInput): ScanOutput { // ── 1. Boundary bypass ─────────────────────────────────────────────────── const profile = capsule.patterns.declared_target_pattern ?? capsule.patterns.observed_dominant_pattern; if (profile) { - let n = 0; for (const edge of capsule.change.added_edges) { const from = edge.from_layer as ArchitectureLayer | undefined; const to = edge.to_layer as ArchitectureLayer | undefined; @@ -119,24 +130,23 @@ export function runScanners(input: ScanInput): ScanOutput { if (!illegal.illegal && !skip.skipped) continue; const align = alignment(capsule, from, to); const declared = Boolean(capsule.patterns.declared_target_pattern); - n++; - architecture.push({ - id: `arch-${String(n).padStart(2, '0')}`, - kind: 'boundary_bypass', - // A declared target makes this a regression against a stated intent; - // without one it is an observation about the repo's own majority. - severity: declared ? 'high' : 'medium', - confidence: declared ? 0.93 : 0.6, - claim: skip.skipped - ? `A changed ${from} file depends directly on ${to}, skipping ${skip.bypassed.join(', ')} (${edge.from_path} → ${edge.to_path}).` - : `A changed ${from} file depends on ${to}, against the ${illegal.rule} rule (${edge.from_path} → ${edge.to_path}).`, - evidence_ids: [edge.evidence_id, ...policyEvidenceIds(capsule)], - target_alignment: align, - remediation: `Route the operation through the ${intermediateLayer(from, to)} layer instead of calling ${to} directly.`, - paths: [edge.from_path, edge.to_path], - protected_finding: false, - source: 'scanner', - }); + const ruleId = skip.skipped ? skip.rule || 'layer-skip' : illegal.rule || 'boundary_bypass'; + architecture.push( + architectureFinding(capsule, { + ruleId, + path: edge.from_path, + extra: edge.to_path, + severity: declared ? 'high' : 'medium', + confidence: declared ? 0.93 : 0.6, + claim: skip.skipped + ? `A changed ${from} file depends directly on ${to}, skipping ${skip.bypassed.join(', ')} (${edge.from_path} → ${edge.to_path}).` + : `A changed ${from} file depends on ${to}, against the ${illegal.rule} rule (${edge.from_path} → ${edge.to_path}).`, + evidence_ids: [edge.evidence_id, ...policyEvidenceIds(capsule)], + target_alignment: align, + remediation: `Route the operation through the ${intermediateLayer(from, to)} layer instead of calling ${to} directly.`, + paths: [edge.from_path, edge.to_path], + }), + ); } } @@ -319,25 +329,25 @@ export function runScanners(input: ScanInput): ScanOutput { if (!isRegression(actual, vote.dominant as DataAccessPattern)) continue; const declared = Boolean(capsule.patterns.declared_target_pattern); - architecture.push({ - id: `arch-${String(architecture.length + 1).padStart(2, '0')}`, - kind: 'peer_deviation', - severity: declared ? 'high' : 'medium', - // The vote's own share is the calibration: a 100%-consistent group of - // 20 peers is far stronger evidence than a bare 70% of 3. - confidence: Math.min(0.95, vote.share * Math.min(1, vote.size / 8)), - claim: - `${filePath} ${describePattern(actual)}, while ${(vote.share * 100).toFixed(0)}% of its ` - + `${vote.size} ${vote.groupKind} peers ${describePattern(vote.dominant)}.`, - evidence_ids: [`vote:${vote.group}`, ...policyEvidenceIds(capsule)].filter((id) => - capsule.evidence.some((e) => e.id === id), - ), - target_alignment: alignment(capsule, undefined, undefined), - remediation: `Follow the pattern its peers use — see ${vote.exemplars.slice(0, 2).join(', ') || 'the peer group'}.`, - paths: [filePath], - protected_finding: false, - source: 'scanner', - }); + architecture.push( + architectureFinding(capsule, { + ruleId: 'peer_deviation', + path: filePath, + severity: declared ? 'high' : 'medium', + // The vote's own share is the calibration: a 100%-consistent group of + // 20 peers is far stronger evidence than a bare 70% of 3. + confidence: Math.min(0.95, vote.share * Math.min(1, vote.size / 8)), + claim: + `${filePath} ${describePattern(actual)}, while ${(vote.share * 100).toFixed(0)}% of its ` + + `${vote.size} ${vote.groupKind} peers ${describePattern(vote.dominant)}.`, + evidence_ids: [`vote:${vote.group}`, ...policyEvidenceIds(capsule)].filter((id) => + capsule.evidence.some((e) => e.id === id), + ), + target_alignment: alignment(capsule, undefined, undefined), + remediation: `Follow the pattern its peers use — see ${vote.exemplars.slice(0, 2).join(', ') || 'the peer group'}.`, + paths: [filePath], + }), + ); } } @@ -359,45 +369,56 @@ export function runScanners(input: ScanInput): ScanOutput { reported.add(key); const best = hits[0]; - architecture.push({ - id: `arch-${String(architecture.length + 1).padStart(2, '0')}`, - kind: 'duplicate_implementation', - severity: 'medium', - confidence: Math.min(0.9, best.score), - claim: - `${body.name} in ${body.file} is structurally ${(best.score * 100).toFixed(0)}% the same as ` - + `${best.name} in ${best.file}:${best.startLine}` - + (hits.length > 1 ? ` (and ${hits.length - 1} other near-match(es))` : '') - + '.', - evidence_ids: [duplicateEvidence(capsule, body, best)], - // Reusing what exists is what the architecture wants; this is a - // deviation from that, but never a *security* regression. - target_alignment: capsule.patterns.declared_target_pattern ? 'regression' : 'unknown', - remediation: `Call ${best.name} instead, or extract the shared behaviour if the two genuinely differ.`, - paths: [body.file, best.file], - protected_finding: false, - source: 'scanner', - }); + architecture.push( + architectureFinding(capsule, { + ruleId: 'duplicate_implementation', + path: body.file, + extra: body.name, + severity: 'medium', + confidence: Math.min(0.9, best.score), + claim: + `${body.name} in ${body.file} is structurally ${(best.score * 100).toFixed(0)}% the same as ` + + `${best.name} in ${best.file}:${best.startLine}` + + (hits.length > 1 ? ` (and ${hits.length - 1} other near-match(es))` : '') + + '.', + evidence_ids: [duplicateEvidence(capsule, body, best)], + // Reusing what exists is what the architecture wants; this is a + // deviation from that, but never a *security* regression. + target_alignment: capsule.patterns.declared_target_pattern ? 'regression' : 'unknown', + remediation: `Call ${best.name} instead, or extract the shared behaviour if the two genuinely differ.`, + paths: [body.file, best.file], + }), + ); } } // ── 8. Changed behaviour with no covering test ─────────────────────────── - const untested = capsule.verification.filter((v) => v.kind === 'no_test_covering_change'); - if (untested.length > 0) { - architecture.push({ - id: `arch-${String(architecture.length + 1).padStart(2, '0')}`, - kind: 'unverified_change', - severity: 'medium', - confidence: 0.7, - claim: `${untested.length} changed file(s) have no test edge reaching them in the code map.`, - evidence_ids: untested.slice(0, 8).map((v) => v.evidence_id), - target_alignment: 'unknown', - remediation: 'Add a test that exercises the changed call path, or point `vg build` at the coverage report that already covers it.', - paths: untested.map((v) => v.path).slice(0, 25), - protected_finding: false, - source: 'scanner', - }); - requiredChecks.push('changed-call-path-test'); + const untested = capsule.verification + .filter((v) => v.kind === 'no_test_covering_change') + .sort((a, b) => a.path.localeCompare(b.path)); + for (const fact of untested) { + architecture.push( + architectureFinding(capsule, { + ruleId: 'unverified_change', + path: fact.path, + severity: 'medium', + confidence: 0.7, + claim: `${fact.path} has no test edge reaching it in the code map.`, + evidence_ids: [fact.evidence_id], + target_alignment: 'unknown', + remediation: + 'Add a test that exercises the changed call path, or point `vg build` at the coverage report that already covers it.', + paths: [fact.path], + }), + ); + } + if (untested.length > 0) requiredChecks.push('changed-call-path-test'); + + // ── 9. Blast radius of changed symbols (graph facts, not policy) ──────── + // Same reverse-reachability as `vg impact`. Findings are Review-shaped so + // `vg review propose` can attach PatchIR without a second loop. + if (input.graph) { + architecture.push(...collectBlastRadiusFindings({ graph: input.graph, capsule })); } return { @@ -408,6 +429,47 @@ export function runScanners(input: ScanInput): ScanOutput { }; } +/** + * Architecture-policy row: top-level `kind` is `correctness` only. + * `producer` / pack rule live in metadata + the stable `arch:{rule}:{path}` id. + * Severity stays on the pack's low|medium|high scale — never `critical`. + */ +function architectureFinding( + capsule: AnalysisCapsule, + input: { + ruleId: string; + path: string; + extra?: string; + severity: Extract; + confidence: number; + claim: string; + evidence_ids: string[]; + target_alignment: TargetAlignment; + remediation: string; + paths: string[]; + }, +): ReviewFinding { + const id = archFindingKey(input.ruleId, input.path, input.extra); + return attachVerificationReceipts( + { + id, + kind: CORRECTNESS_KIND, + finding_key: id, + producer: ARCH_PRODUCER, + severity: input.severity, + confidence: input.confidence, + claim: input.claim, + evidence_ids: input.evidence_ids, + target_alignment: input.target_alignment, + remediation: input.remediation, + paths: input.paths, + protected_finding: false, + source: 'scanner', + }, + capsule, + ); +} + function policyEvidenceIds(capsule: AnalysisCapsule): string[] { return capsule.policies.slice(0, 2).map((p) => p.evidence_id); } diff --git a/src/review/schemas.ts b/src/review/schemas.ts index a0158de..3861634 100644 --- a/src/review/schemas.ts +++ b/src/review/schemas.ts @@ -232,6 +232,23 @@ export interface ReviewFinding { protected_finding?: boolean; /** Which producer emitted it — deterministic scanners, or the local model. */ source?: 'scanner' | 'model'; + /** + * Same value as `id` on blast-radius/impact rows. App ledgers store + * `finding.id` as `finding_key`. Prefer `blast:{node_id}`, else + * `blast:{path}:{name}` (repo-relative path, no head SHA, no spaces). + */ + finding_key?: string; + /** + * Optional pack/producer tag. Blast-radius rows set `blast_radius` here; + * architecture-policy rows set `architecture`. Not a second top-level `kind` + * — those rows use `kind: 'correctness'`. + */ + producer?: string; + /** + * Existing capsule verification / scan / attest evidence ids cited by this + * finding. Only ids the capsule already emitted — never a new receipt system. + */ + receipts?: string[]; } export type ChangeClass = 'architecture' | 'security' | 'none'; diff --git a/src/vendor/fastembed/index.ts b/src/vendor/fastembed/index.ts index 4aa3aa0..837a5b0 100644 --- a/src/vendor/fastembed/index.ts +++ b/src/vendor/fastembed/index.ts @@ -22,7 +22,7 @@ * monorepo previously carried as a pnpm patch. * * Loaded lazily via dynamic import (see engine/embeddings.ts): the modules it - * needs (`onnxruntime-node`, `@anush008/tokenizers`, `tar`) are OPTIONAL + * needs (`onnxruntime-node`, `tokenizers`, `tar`) are OPTIONAL * dependencies, so this module must never be imported statically from any * always-loaded path — and it must not import them statically either. They are * loaded on demand by `ensureNativeDeps()`, which prefers a host-supplied @@ -35,13 +35,13 @@ import * as https from 'node:https'; import { createRequire } from 'node:module'; import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { Tokenizer } from '@anush008/tokenizers'; +import type { Tokenizer } from 'tokenizers'; import type { InferenceSession, Tensor } from 'onnxruntime-node'; /** The lazily-loaded optional modules the dense backend runs on. */ export interface NativeDeps { ort: typeof import('onnxruntime-node'); - tokenizers: typeof import('@anush008/tokenizers'); + tokenizers: typeof import('tokenizers'); tar: typeof import('tar'); } @@ -90,7 +90,7 @@ export async function ensureNativeDeps(): Promise { if (nativeDeps) return nativeDeps; const [ortMod, tokMod, tarMod] = await Promise.all([ importNative('onnxruntime-node'), - importNative('@anush008/tokenizers'), + importNative('tokenizers'), importNative('tar'), ]); const ort = unwrapCjs(ortMod, 'InferenceSession'); diff --git a/src/version.ts b/src/version.ts index f03e77e..4ae6e15 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.916.3'; +export const VERSION = '2026.917.1'; diff --git a/test/vendor-fastembed-hostdir.test.ts b/test/vendor-fastembed-hostdir.test.ts index 5c142d7..56036e3 100644 --- a/test/vendor-fastembed-hostdir.test.ts +++ b/test/vendor-fastembed-hostdir.test.ts @@ -30,12 +30,12 @@ describe('vendored backend native-dep resolution', () => { }), 'node_modules/onnxruntime-node/index.cjs': "module.exports = { InferenceSession: { hostMarker: true }, Tensor: class {}, env: { logLevel: 'warning' } };", - 'node_modules/@anush008/tokenizers/package.json': JSON.stringify({ - name: '@anush008/tokenizers', + 'node_modules/tokenizers/package.json': JSON.stringify({ + name: 'tokenizers', version: '0.0.0-test', main: 'index.cjs', }), - 'node_modules/@anush008/tokenizers/index.cjs': + 'node_modules/tokenizers/index.cjs': 'module.exports = { Tokenizer: class {}, AddedToken: class {} };', 'node_modules/tar/package.json': JSON.stringify({ name: 'tar', version: '0.0.0-test', main: 'index.cjs' }), 'node_modules/tar/index.cjs': 'module.exports = { x: async () => {} };', diff --git a/tsup.config.ts b/tsup.config.ts index e05efcb..8f86cf7 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -22,13 +22,13 @@ export default defineConfig({ // Keep these external so they load from node_modules at runtime: // - web-tree-sitter / tree-sitter-wasms ship their own .wasm assets. // - typescript uses CommonJS require() internally; bundling breaks the resolver. - // - onnxruntime-node / @anush008/tokenizers carry native binaries (lazy, + // - onnxruntime-node / tokenizers carry native binaries (lazy, // optional — used by the vendored src/vendor/fastembed backend). // - tar is a declared (optional) runtime dep of that backend; leave it in // node_modules rather than bundling a copy. // - yaml's CJS modules call require("process"); bundling into ESM turns that // into a dynamic-require shim that throws at runtime. - external: ['web-tree-sitter', 'tree-sitter-wasms', 'typescript', '@anush008/tokenizers', 'onnxruntime-node', 'tar', 'yaml'], + external: ['web-tree-sitter', 'tree-sitter-wasms', 'typescript', 'tokenizers', 'onnxruntime-node', 'tar', 'yaml'], minify: false, treeshake: true, sourcemap: true,