From 27e5f57f367ca7b0a0f337d024ce75946dc419a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 11:20:03 +0000 Subject: [PATCH] chore: sync from vibgrate-cli monorepo (v2026.1003.2) Outbound mirror of packages/vibgrate-cli-public. Passed the public-surface leak gate. --- CHANGELOG.md | 10 + DOCS.md | 56 +++- README.md | 4 +- action.yml | 2 +- charts/vibgrate/Chart.yaml | 2 +- docs/public/SCORING-METHODOLOGY-PUBLIC.md | 4 +- package.json | 2 +- packaging/homebrew-tap/Formula/vg.rb | 4 +- packaging/scoop-bucket/vg.json | 2 +- releases/v2026.1003.2.md | 50 ++++ src/commands/path.ts | 27 +- src/commands/show-flow.ts | 46 +++ src/commands/show-scratchpad.ts | 42 +++ src/commands/show.ts | 9 +- src/core-open/formatters/markdown.ts | 17 +- src/core-open/formatters/text.ts | 35 ++- src/core-open/run-core-scan.ts | 13 +- src/core-open/scoring/drift-score.ts | 28 +- src/core-open/types.ts | 22 +- src/core-open/utils/mermaid.ts | 4 +- src/engine/cache.ts | 4 +- src/engine/duties.test.ts | 39 +++ src/engine/duties.ts | 13 + src/engine/export.test.ts | 35 +++ src/engine/export.ts | 28 +- src/lsp/server.ts | 63 ++-- src/mcp/review-tools.test.ts | 54 ++++ src/mcp/review-tools.ts | 101 ++++++- src/reporting/commands/baseline.ts | 3 +- src/reporting/commands/fix-e2e.test.ts | 2 + src/reporting/commands/fix.ts | 1 + src/reporting/commands/sbom.test.ts | 109 ++++++- src/reporting/commands/sbom.ts | 223 +++++++++++--- src/reporting/commands/scan.ts | 50 ++-- src/reporting/drift-budget-gate.test.ts | 23 +- src/reporting/drift-budget-gate.ts | 25 ++ src/reporting/formatters/formatters.test.ts | 74 +++++ src/reporting/formatters/markdown.ts | 17 +- src/reporting/formatters/text.ts | 34 ++- src/reporting/planning/expected-drift.test.ts | 9 +- src/reporting/planning/expected-drift.ts | 2 +- src/reporting/scoring/drift-score.test.ts | 166 ++++++++++- src/reporting/scoring/drift-score.ts | 20 +- src/reporting/types.ts | 16 +- src/reporting/utils/ingest-id-output.test.ts | 9 + src/reporting/utils/ingest-id-output.ts | 6 +- src/review/explain-doc.test.ts | 68 ++++- src/review/explain-doc.ts | 117 +++++++- src/review/scratchpad.test.ts | 129 ++++++++ src/review/scratchpad.ts | 277 ++++++++++++++++++ src/version.ts | 2 +- 51 files changed, 1895 insertions(+), 203 deletions(-) create mode 100644 releases/v2026.1003.2.md create mode 100644 src/commands/show-flow.ts create mode 100644 src/commands/show-scratchpad.ts create mode 100644 src/review/scratchpad.test.ts create mode 100644 src/review/scratchpad.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2ec89..9762ee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,16 @@ backward compatible. ### Fixed +- **`vg sbom export` no longer turns a package name that cannot be a Package URL + into a purl-shaped string.** A space, a non-ASCII character, or an empty path + segment used to be percent-encoded (`pkg:npm/foo%20bar@1.0.0`) and shipped as + if it were a real purl. The component stays in the CycloneDX and SPDX + documents. The purl (and the SPDX purl externalRef) is left off, the row is + marked `vibgrate:purlStatus=unavailable`, and the command prints a warning + that names the package and ecosystem. The same rule applies to npm components + in `vg export`'s CycloneDX output. An ecosystem this exporter does not know + is not reported as npm. + - **`vg show arch` clipped the map to a fixed viewport.** Columns that ran off the bottom of the window could not be scrolled or zoomed; the canvas is now a pannable, zoomable map (scroll or drag, pinch / Ctrl-scroll, + / −). diff --git a/DOCS.md b/DOCS.md index 6ad775e..6b2a39b 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1054,7 +1054,15 @@ Every component also carries a [purl](https://github.com/package-url/purl-spec) (`pkg:npm/@`, scoped names as their own namespace segment) — as the CycloneDX `purl` field and `bom-ref`, and as the SPDX `externalRefs` PACKAGE-MANAGER reference — so a vulnerability scanner can match components without re-deriving an -identifier. When the lockfile format resolves real dependency edges (npm +identifier. When a package name cannot be a Package URL (a space, a non-ASCII +character, or an empty path segment), that component stays in the document and +the purl is omitted. CycloneDX sets `vibgrate:purlStatus` to `unavailable` and +records the reason on `vibgrate:purlWarning`. SPDX omits the purl externalRef, +records `purlStatus=unavailable` on the package annotation, and repeats the +reason in a second annotation. `vg sbom export` prints the same warning on +stderr. The warning names the package and its ecosystem. + +When the lockfile format resolves real dependency edges (npm `package-lock.json` v2/v3 today; pnpm and yarn report components without edges), the SBOM also carries the resolved dependency graph: CycloneDX's top-level `dependencies` array, or SPDX `DEPENDS_ON` relationships. Where edges aren't resolvable, that section @@ -1994,11 +2002,24 @@ vg path handler insert --calls | `--calls` | Follow call edges only; show the call-site line of each hop | | `--pick-a ` | Pick the nth candidate for A | | `--pick-b ` | Pick the nth candidate for B | +| `--diagram` | Draw the path as a pinned call path instead of text | +| `--format ` | With `--diagram`: `md` (default) or `json` (a `vg.review.doc.v1` document with `kind: "explain"`) | With `--json`, `steps` lists each hop's edge kind, resolver, call-site line and `awaited` flag. The `find_path` MCP tool returns the same as `hops`, and takes `calls_only: true` for the call-only path. +With `--diagram`, the path becomes a document you can read or hand to an agent: + +```bash +vg path placeOrder audit --calls --diagram +``` + +- **What it is:** each hop in order, with the line that makes it and whether the call is awaited. +- **How it works:** one call path, caller first. Each step is linked to the lines that declare it, and each hop to its call site. + +Every element comes from the code map and is pinned to lines in the working tree. A step with no code in the repository (a library function) is left out of the call path and named in the notes. + --- ### vg savings @@ -2111,6 +2132,37 @@ vg show src/orders/service.ts:42 --diagram --pick 1 --format json Every element is pinned to lines in the working tree and comes from the code map, so nothing is marked new or edited and the call path has no before side. When nothing in the code map calls the code, the flow leads instead. The document is the same `vg.review.doc.v1` that `vg review doc` writes, so the same renderers and checks apply. In VS Code, **Vibgrate: Explain This Code with Diagrams** opens it for the function under the cursor. +#### vg show flow + +What a function does, step by step, as one pinned flow diagram: the explain view of `vg show --diagram` with only its flows. + +```bash +vg show flow UpdateProductCommandHandler.Handle +vg show flow src/orders/service.ts:42 --format json +``` + +Each step is a statement the code map recorded (a query, a write, a call, a branch or an error path), linked to its line. When the code map records no steps for the function, `vg show flow` says so and exits 3. It needs the Architecture module (`vg module install arch`). + +Agents get the same documents over MCP: under `vg serve --review`, the `review_doc` tool's `explain` op takes `symbol` (and `to` for a call path). It saves nothing unless asked to keep it. + +#### vg show scratchpad + +One explain scratchpad per repository, for understanding code rather than reviewing a change. Every explanation you keep lands on top, newest first: + +```bash +vg show OrderService.save --diagram --keep +vg path placeOrder audit --calls --diagram --keep +vg show scratchpad +vg show scratchpad --clear +``` + +- **Newest on top.** Each entry starts with a heading. Keeping the same explanation again moves it to the top instead of adding a copy. The 30 newest entries are kept. +- **Agents write here too.** The `review_doc` tool's `explain` op with `keep: true` adds an entry. Ops `get`, `patch`, `check` and `clear` with `doc_id: "scratchpad"` read it, redraw a block by id, and empty it. What an agent writes is marked as written by an agent. +- **Pinned to the working tree.** Code moves under a scratchpad, so a block whose lines no longer exist is reported, not deleted. A patch is refused only when it breaks something that was fine. +- **Local.** It is stored in `.vibgrate/review-docs/scratchpad.json`, never committed, and deleted after 365 days without an update. + +In VS Code, **Vibgrate: Explain This Code with Diagrams** keeps its result on the scratchpad, and **Vibgrate: Open Explain Scratchpad** opens it. The tab updates as an agent writes to it. + #### vg show arch Open a local, interactive architecture map of the same graph in your browser. @@ -2814,7 +2866,7 @@ This makes drift a formal quality gate (fitness function), not just reporting. The DriftScore is a deterministic, versioned metric (0–100) that represents how far behind your codebase is relative to the current stable ecosystem baseline. -**Lower score = healthier upgrade posture.** 0 means no drift (fully current); 100 means maximum drift. Higher is worse. +**Lower score = healthier upgrade posture.** 0 means no drift (fully current); 100 means maximum drift. Higher is worse. A component that was not measured is `null` in JSON and `n/a` in text, not 0. When nothing was measured, the overall score is null as well, and `--drift-budget` does not compare it. The methodology is published: see the [public scoring specification](./docs/public/SCORING-METHODOLOGY-PUBLIC.md) in this repository and the overview at [vibgrate.com/driftscore](https://vibgrate.com/driftscore). diff --git a/README.md b/README.md index 3346e40..1541253 100644 --- a/README.md +++ b/README.md @@ -684,7 +684,9 @@ Under each set, commands are listed A–Z. A short **typical path** (usual order | `vg map` / `vg hubs` / `vg areas` / `vg oddities` | Map insights: overview, most-depended-on code, natural groupings, cross-area smells | | `vg models` | Code Modes (Spark / Flow / Forge) + local fleet (Ollama / LM Studio / gguf); `install` / `pull` by default (`--dry-run` to preview) | | `vg module` | Manage optional local modules (`relevance`, `hcs`): `status`, `install`, `remove` | -| `vg path ` | How A connects to B (shortest path) | +| `vg path ` | How A connects to B (shortest path); `--diagram` draws it as a pinned call path | +| `vg show flow ` | What a function does, step by step, as a pinned flow diagram | +| `vg show scratchpad` | The explain scratchpad: explanations kept with `--keep` or by an agent, newest on top | | `vg savings` | Local report of tokens/$ saved — the grep baseline for map queries, and context compression by window, model and client (estimates) | | `vg watch` | Rebuild the map when files change | | `vg serve` | Start **Vibgrate AI Context** (local-first MCP: code map + drift + version-correct docs) | diff --git a/action.yml b/action.yml index e3cc54f..757ff27 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.1003.1' # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs + default: '2026.1003.2' # 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 96bcb29..9b8a614 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.1003.1" # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs +appVersion: "2026.1003.2" # 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/public/SCORING-METHODOLOGY-PUBLIC.md b/docs/public/SCORING-METHODOLOGY-PUBLIC.md index 01badde..0ca19ed 100644 --- a/docs/public/SCORING-METHODOLOGY-PUBLIC.md +++ b/docs/public/SCORING-METHODOLOGY-PUBLIC.md @@ -42,7 +42,9 @@ RiskScore's job). Four weighted pillars, computed on a health scale and emitted as drift (0 = no drift). Weight is redistributed across whichever pillars have data, so a scan -with no runtime metadata is not unfairly penalised. +with no runtime metadata is not unfairly penalised. A pillar with no input is +`null` (shown as `n/a`), not drift 0. When no pillar has data, the overall +DriftScore is null rather than 0. | Pillar | Weight | Input | |---|---:|---| diff --git a/package.json b/package.json index ca39cdf..b13049b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vibgrate/cli", - "version": "2026.1003.1", + "version": "2026.1003.2", "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", diff --git a/packaging/homebrew-tap/Formula/vg.rb b/packaging/homebrew-tap/Formula/vg.rb index e743409..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.1003.1.tgz" - sha256 "656f2b6ffc7fb8d025805949e5ed2aea22d5cf3141f2fe4117fe758e71933a7a" + 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 dc90e51..b76a0cc 100644 --- a/packaging/scoop-bucket/vg.json +++ b/packaging/scoop-bucket/vg.json @@ -1,5 +1,5 @@ { - "version": "2026.1003.1", + "version": "2026.914.1", "description": "Deterministic, no-API-key code graph for AI assistants (vg)", "homepage": "https://vibgrate.com", "license": "Apache-2.0", diff --git a/releases/v2026.1003.2.md b/releases/v2026.1003.2.md new file mode 100644 index 0000000..6234f64 --- /dev/null +++ b/releases/v2026.1003.2.md @@ -0,0 +1,50 @@ +# Vibgrate CLI 2026.1003.2 + +_Released 2026-10-03_ + +This release of the Vibgrate CLI introduces new features for visualizing code flow and improves handling of DriftScores. Several fixes enhance the reliability of the SBOM export and the scratchpad functionality. + +## What changed + +### New + +- `vg path --diagram` provides a call path visualization linking code steps to their declarations. +- `vg show flow ` displays a step-by-step flow diagram of a function's operations. +- A scratchpad feature allows for a repository-specific space to understand code, with the newest entries on top. + +### Changed + +- `vg show --diagram --keep` and `vg path --diagram --keep` now move explanations instead of copying them. + +### Fixed + +- `vg scan` now distinguishes between an unmeasured DriftScore and a real score of 0. +- `vg sbom export` correctly retains components with names that cannot be a Package URL. + +## Benchmarks + +Two-arm benchmark of this release against 2026.1003.1, interleaved on one runner against the pinned corpus (236 metrics compared). + +| Metric | Previous | This release | +| --- | --- | --- | +| Languages with extraction | 19 count | 19 count | +| Definitions extracted (corpus total) | 26296 count | 26296 count | +| Call edges extracted (corpus total) | 18432 count | 18432 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) | 728.10 ms | 732.90 ms | + +2 regression(s) — published, not omitted: +- Tasks passed on both arms: 36 → 34 (-5.6%) +- Comparable-task rate (both arms passed / total): 0.95 → 0.89 (-5.6%) + +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.1003.2 diff --git a/src/commands/path.ts b/src/commands/path.ts index 57f7f97..6e8c72b 100644 --- a/src/commands/path.ts +++ b/src/commands/path.ts @@ -6,7 +6,10 @@ import { applyGlobalOptions, readGlobal } from '../cli-options.js'; import { requireGraph, rootOf } from './util.js'; import { ambiguityError } from './ambiguity.js'; import { CliError, ExitCode } from '../util/exit.js'; -import { c, info, json } from '../util/output.js'; +import { c, info, json, out } from '../util/output.js'; +import { buildPathDoc, ExplainEmpty } from '../review/explain-doc.js'; +import { renderReviewDocMarkdown } from '../review/doc.js'; +import { keepInScratchpad } from '../review/scratchpad.js'; /** * `vg path ` (VG-CLI-SPEC §4.1) — how A connects to B (shortest path). @@ -20,9 +23,15 @@ export function registerPath(program: Command): void { .option('--pick-a ', 'pick the nth candidate for A when ambiguous') .option('--pick-b ', 'pick the nth candidate for B when ambiguous') .option('--calls', 'follow call edges only, and show the call-site line of each hop') - .action(function (this: Command, a: string, b: string, opts: { pickA?: string; pickB?: string; calls?: boolean }) { + .option('--diagram', 'draw the path as a pinned call path: each step linked to its code and each hop to the line that makes it') + .option('--format ', 'with --diagram: output format (md | json)', 'md') + .option('--keep', 'with --diagram: also keep it on top of the explain scratchpad (`vg show scratchpad`)') + .action(function (this: Command, a: string, b: string, opts: { pickA?: string; pickB?: string; calls?: boolean; diagram?: boolean; format: string; keep?: boolean }) { const global = readGlobal(this); - const { graph } = requireGraph(global); + const { root, graph } = requireGraph(global); + if (opts.diagram && opts.format !== 'md' && opts.format !== 'json') { + throw new CliError('unknown --format (expected md | json)', ExitCode.USAGE_ERROR); + } const ra = resolveOne(graph, a, opts.pickA ? Number(opts.pickA) : undefined); if (!ra.node) throw ambiguityError(`"${a}" ${ra.candidates.length ? 'is ambiguous' : 'not found'}`, ra.candidates, '--pick-a'); @@ -61,6 +70,18 @@ export function registerPath(program: Command): void { ); } + if (opts.diagram) { + try { + const { doc } = buildPathDoc({ root, graph, path: result, callsOnly: Boolean(opts.calls) }); + if (opts.keep) keepInScratchpad(root, doc); + out(Boolean(global.json) || opts.format === 'json' ? JSON.stringify(doc, null, 2) : renderReviewDocMarkdown(doc).replace(/\n$/, '')); + } catch (err) { + if (err instanceof ExplainEmpty) throw new CliError(err.message, ExitCode.NOT_FOUND); + throw err; + } + return; + } + const byId = new Map(graph.nodes.map((n) => [n.id, n] as const)); const names = result.ids.map((id) => byId.get(id)?.qualifiedName ?? id); const steps = describeHops(graph, result.ids, result.direction); diff --git a/src/commands/show-flow.ts b/src/commands/show-flow.ts new file mode 100644 index 0000000..6cfe4f1 --- /dev/null +++ b/src/commands/show-flow.ts @@ -0,0 +1,46 @@ +/** + * `vg show flow ` — what a function does, step by step, as a pinned + * flow diagram drawn from the code map. + * + * Nested under `vg show` (FEATURE-DESIGN-PRINCIPLES P1): it is the explain + * view of `vg show --diagram` narrowed to its flows, so the schema, + * validator and renderers are the same. + */ +import type { Command } from 'commander'; +import { applyGlobalOptions, readGlobal } from '../cli-options.js'; +import { loadHaileProvider } from '../engine/haile/haile-provider.js'; +import { resolveOne } from '../engine/lookup.js'; +import { renderReviewDocMarkdown } from '../review/doc.js'; +import { buildExplainDoc, ExplainEmpty } from '../review/explain-doc.js'; +import { CliError, ExitCode } from '../util/exit.js'; +import { out } from '../util/output.js'; +import { ambiguityError } from './ambiguity.js'; +import { requireGraph } from './util.js'; + +export function registerShowFlow(show: Command): void { + const cmd = show + .command('flow') + .description('what a function does, step by step, as a pinned flow diagram from the code map (needs the Architecture module)') + .argument('', 'the function: qualified name, short name, file:line, glob or id') + .option('--pick ', 'pick the nth candidate when ambiguous') + .option('--format ', 'output format (md | json)', 'md') + .action(async function (this: Command, entry: string, opts: { pick?: string; format: string }) { + const global = readGlobal(this); + if (opts.format !== 'md' && opts.format !== 'json') throw new CliError('unknown --format (expected md | json)', ExitCode.USAGE_ERROR); + const { root, graph } = requireGraph(global); + const { node, candidates } = resolveOne(graph, entry, opts.pick ? Number(opts.pick) : undefined); + if (!node) throw ambiguityError(candidates.length === 0 ? `no node matches "${entry}"` : `"${entry}" is ambiguous`, candidates); + const provider = await loadHaileProvider(); + if (!provider) { + throw new CliError('`vg show flow` needs the Architecture module — install it with `vg module install arch`', ExitCode.ENGINE_UNAVAILABLE); + } + try { + const { doc } = buildExplainDoc({ root, graph, node, graphPath: global.graph, provider, only: ['flow'] }); + out(Boolean(global.json) || opts.format === 'json' ? JSON.stringify(doc, null, 2) : renderReviewDocMarkdown(doc).replace(/\n$/, '')); + } catch (err) { + if (err instanceof ExplainEmpty) throw new CliError(err.message, ExitCode.NOT_FOUND); + throw err; + } + }); + applyGlobalOptions(cmd); +} diff --git a/src/commands/show-scratchpad.ts b/src/commands/show-scratchpad.ts new file mode 100644 index 0000000..a196ff1 --- /dev/null +++ b/src/commands/show-scratchpad.ts @@ -0,0 +1,42 @@ +/** + * `vg show scratchpad` — the explain scratchpad: every explanation kept with + * `--keep` (or by an agent over MCP), newest on top (review/scratchpad.ts). + */ +import type { Command } from 'commander'; +import { applyGlobalOptions, readGlobal } from '../cli-options.js'; +import { renderReviewDocMarkdown } from '../review/doc.js'; +import { clearScratchpad, getScratchpad } from '../review/scratchpad.js'; +import { CliError, ExitCode } from '../util/exit.js'; +import { c, info, out } from '../util/output.js'; +import { rootOf } from './util.js'; + +export function registerShowScratchpad(show: Command): void { + const cmd = show + .command('scratchpad') + .description('the explain scratchpad: explanations kept with --keep, or by an agent, newest on top') + .option('--format ', 'output format (md | json)', 'md') + .option('--clear', 'empty the scratchpad') + .action(function (this: Command, opts: { format: string; clear?: boolean }) { + const global = readGlobal(this); + if (opts.format !== 'md' && opts.format !== 'json') throw new CliError('unknown --format (expected md | json)', ExitCode.USAGE_ERROR); + const root = rootOf(global); + if (opts.clear) { + clearScratchpad(root); + if (global.json) out(JSON.stringify({ cleared: true })); + else info(c.dim(' the scratchpad is empty')); + return; + } + const pad = getScratchpad(root); + if (Boolean(global.json) || opts.format === 'json') { + out(JSON.stringify(pad, null, 2)); + return; + } + if (!pad.doc) { + info(c.dim(' the scratchpad is empty — keep an explanation with `vg show --diagram --keep` or `vg path --diagram --keep`')); + return; + } + out(renderReviewDocMarkdown(pad.doc).replace(/\n$/, '')); + for (const s of pad.stale) info(c.yellow(` ${s.block}: ${s.message} — the code moved since it was kept`)); + }); + applyGlobalOptions(cmd); +} diff --git a/src/commands/show.ts b/src/commands/show.ts index 08b2835..940c875 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -10,12 +10,15 @@ import { c, info, json, out } from '../util/output.js'; import { CliError, ExitCode } from '../util/exit.js'; import { loadHaileProvider } from '../engine/haile/haile-provider.js'; import { buildExplainDoc } from '../review/explain-doc.js'; +import { keepInScratchpad } from '../review/scratchpad.js'; +import { registerShowScratchpad } from './show-scratchpad.js'; import { renderReviewDocMarkdown } from '../review/doc.js'; import { resolveGraphPath } from '../engine/artifacts.js'; import { findHaileSymbol, formatHaileLines, haileJsonFields, readHaileSidecar } from '../engine/haile/index.js'; import { registerShowArch } from './arch.js'; import { registerShowSavings } from './show-savings.js'; import { registerShowSurfaces } from './show-surfaces.js'; +import { registerShowFlow } from './show-flow.js'; /** * `vg show ` (VG-CLI-SPEC §3.3) — the richest single-node view: what it @@ -34,13 +37,16 @@ export function registerShow(program: Command): void { registerShowArch(cmd); registerShowSavings(cmd); registerShowSurfaces(cmd); + registerShowFlow(cmd); + registerShowScratchpad(cmd); cmd .argument('', 'qualified name, short name, file:line, glob, or id') .option('--pick ', 'pick the nth candidate when ambiguous') .option('--diagram', 'explain it with pinned diagrams: how it is reached, its flow, the data it reads and writes, where it sits (needs the Architecture module)') .option('--format ', 'with --diagram: output format (md | json)', 'md') - .action(async function (this: Command, name: string, opts: { pick?: string; diagram?: boolean; format: string }) { + .option('--keep', 'with --diagram: also keep it on top of the explain scratchpad (`vg show scratchpad`)') + .action(async function (this: Command, name: string, opts: { pick?: string; diagram?: boolean; format: string; keep?: boolean }) { const global = readGlobal(this); const { root, graph } = requireGraph(global); const { node, candidates } = resolveOne(graph, name, opts.pick ? Number(opts.pick) : undefined); @@ -57,6 +63,7 @@ export function registerShow(program: Command): void { throw new CliError('unknown --format (expected md | json)', ExitCode.USAGE_ERROR); } const { doc } = buildExplainDoc({ root, graph, node, graphPath: global.graph, provider: await loadHaileProvider() }); + if (opts.keep) keepInScratchpad(root, doc); const asJson = Boolean(global.json) || opts.format === 'json'; out(asJson ? JSON.stringify(doc, null, 2) : renderReviewDocMarkdown(doc).replace(/\n$/, '')); return; diff --git a/src/core-open/formatters/markdown.ts b/src/core-open/formatters/markdown.ts index be83d21..284af60 100644 --- a/src/core-open/formatters/markdown.ts +++ b/src/core-open/formatters/markdown.ts @@ -16,6 +16,11 @@ function formatBillable(value: number): string { return String(Number(value.toFixed(2))); } +/** A measured zero stays `0`. An unmeasured component is `n/a`, never `0`. */ +function markdownDriftCell(score: number | null): string { + return score === null ? 'n/a' : String(score); +} + /** Generate a Markdown report from scan artifact */ export function formatMarkdown(artifact: ScanArtifact): string { const lines: string[] = []; @@ -28,8 +33,8 @@ export function formatMarkdown(artifact: ScanArtifact): string { lines.push(''); lines.push(`| Metric | Value |`); lines.push(`|--------|-------|`); - lines.push(`| **DriftScore** | ${artifact.drift.score}/100 |`); - lines.push(`| **Risk Level** | ${artifact.drift.riskLevel.toUpperCase()} |`); + lines.push(`| **DriftScore** | ${artifact.drift.score === null ? 'n/a' : `${artifact.drift.score}/100`} |`); + lines.push(`| **Risk Level** | ${artifact.drift.riskLevel ? artifact.drift.riskLevel.toUpperCase() : 'n/a'} |`); lines.push(`| **Projects** | ${artifact.projects.length} |`); if (billing) { // Per-size billable contribution (count ÷ ratio) to 1–2 dp, so tiny projects @@ -64,10 +69,10 @@ export function formatMarkdown(artifact: ScanArtifact): string { lines.push(''); lines.push(`| Component | Score |`); lines.push(`|-----------|-------|`); - lines.push(`| Runtime | ${artifact.drift.components.runtimeScore} |`); - lines.push(`| Frameworks | ${artifact.drift.components.frameworkScore} |`); - lines.push(`| Dependencies | ${artifact.drift.components.dependencyScore} |`); - lines.push(`| EOL Risk | ${artifact.drift.components.eolScore} |`); + lines.push(`| Runtime | ${markdownDriftCell(artifact.drift.components.runtimeScore)} |`); + lines.push(`| Frameworks | ${markdownDriftCell(artifact.drift.components.frameworkScore)} |`); + lines.push(`| Dependencies | ${markdownDriftCell(artifact.drift.components.dependencyScore)} |`); + lines.push(`| EOL Risk | ${markdownDriftCell(artifact.drift.components.eolScore)} |`); lines.push(''); // Per project diff --git a/src/core-open/formatters/text.ts b/src/core-open/formatters/text.ts index 9f1ed28..448737b 100644 --- a/src/core-open/formatters/text.ts +++ b/src/core-open/formatters/text.ts @@ -156,14 +156,11 @@ export function formatText(artifact: ScanArtifact, opts: FormatTextOptions = {}) lines.push(''); } - // Score summary - const scoreColor = artifact.drift.score <= 30 ? chalk.green : - artifact.drift.score <= 60 ? chalk.yellow : chalk.red; - + // Score summary. A null score is unmeasured, not a perfect 0. lines.push(...titleBox('DriftScore Summary')); lines.push(''); - lines.push(chalk.bold(' DriftScore: ') + scoreColor.bold(`${artifact.drift.score}/100`)); - lines.push(chalk.bold(' Risk Level: ') + riskBadge(artifact.drift.riskLevel)); + lines.push(chalk.bold(' DriftScore: ') + formatHeadlineScore(artifact.drift.score)); + lines.push(chalk.bold(' Risk Level: ') + formatHeadlineRisk(artifact.drift.riskLevel)); lines.push(chalk.bold(' Projects: ') + `${artifact.projects.length}`); // Project classification breakdown + billable projects ("micro-project pricing"). @@ -217,13 +214,12 @@ export function formatText(artifact: ScanArtifact, opts: FormatTextOptions = {}) lines.push(''); - // Score breakdown - const m = new Set(artifact.drift.measured ?? ['runtime', 'framework', 'dependency', 'eol']); + // Score breakdown. Null components render as n/a; a measured 0 still renders as 0. lines.push(' ' + chalk.bold.underline('Score Breakdown')); - lines.push(` Runtime: ${m.has('runtime') ? scoreBar(artifact.drift.components.runtimeScore) : chalk.dim('n/a')}`); - lines.push(` Frameworks: ${m.has('framework') ? scoreBar(artifact.drift.components.frameworkScore) : chalk.dim('n/a')}`); - lines.push(` Dependencies: ${m.has('dependency') ? scoreBar(artifact.drift.components.dependencyScore) : chalk.dim('n/a')}`); - lines.push(` EOL Risk: ${m.has('eol') ? scoreBar(artifact.drift.components.eolScore) : chalk.dim('n/a')}`); + lines.push(` Runtime: ${formatComponentScore(artifact.drift.components.runtimeScore)}`); + lines.push(` Frameworks: ${formatComponentScore(artifact.drift.components.frameworkScore)}`); + lines.push(` Dependencies: ${formatComponentScore(artifact.drift.components.dependencyScore)}`); + lines.push(` EOL Risk: ${formatComponentScore(artifact.drift.components.eolScore)}`); lines.push(''); const scannedParts: string[] = [`Scanned at ${artifact.timestamp}`]; @@ -389,6 +385,21 @@ function riskBadge(level: string): string { } } +function formatHeadlineScore(score: number | null): string { + if (score === null) return chalk.dim('n/a'); + const scoreColor = score <= 30 ? chalk.green : score <= 60 ? chalk.yellow : chalk.red; + return scoreColor.bold(`${score}/100`); +} + +function formatHeadlineRisk(level: string | null): string { + if (!level) return chalk.dim('n/a'); + return riskBadge(level); +} + +function formatComponentScore(score: number | null): string { + return score === null ? chalk.dim('n/a') : scoreBar(score); +} + function scoreBar(score: number): string { // Drift bar: the fill shows how much drift exists (0 = empty/best, 100 = full/worst). // Sub-cell gradient fill (green → the score's own risk colour) for a smoother read. diff --git a/src/core-open/run-core-scan.ts b/src/core-open/run-core-scan.ts index 5375ff9..27d6515 100644 --- a/src/core-open/run-core-scan.ts +++ b/src/core-open/run-core-scan.ts @@ -718,7 +718,12 @@ export async function runCoreScan( // ── Step: Drift score ── progress.startStep('drift'); const drift = computeDriftScore(allProjects); - progress.completeStep('drift', `${drift.score}/100 — ${drift.riskLevel} risk`); + progress.completeStep( + 'drift', + drift.score === null || drift.riskLevel === null + ? 'n/a — not measured' + : `${drift.score}/100 — ${drift.riskLevel} risk`, + ); // ── Step: Findings ── progress.startStep('findings'); @@ -823,7 +828,11 @@ export async function runCoreScan( // baseline outside the repo degrades to its basename for the same reason. const relBaseline = path.relative(rootDir, baselinePath); artifact.baseline = !relBaseline || relBaseline.startsWith('..') ? path.basename(baselinePath) : relBaseline; - artifact.delta = artifact.drift.score - baseline.drift.score; + const headScore = artifact.drift.score; + const baseScore = baseline.drift?.score; + if (typeof headScore === 'number' && typeof baseScore === 'number') { + artifact.delta = headScore - baseScore; + } } catch { console.error(chalk.yellow(`Warning: Could not read baseline file: ${baselinePath}`)); } diff --git a/src/core-open/scoring/drift-score.ts b/src/core-open/scoring/drift-score.ts index 9ee6bff..45a99b3 100644 --- a/src/core-open/scoring/drift-score.ts +++ b/src/core-open/scoring/drift-score.ts @@ -201,18 +201,19 @@ export function computeDriftScore(projects: ProjectScan[]): DriftScore { // DriftScore v2 convention: 0 = no drift (best), 100 = maximum drift (worst). // Components are computed internally on a "health" scale (higher = healthier) - // and inverted here so every emitted number reads as drift. + // and inverted here so every emitted number reads as drift. A null health + // value stays null: `?? 100` would invert to drift 0 and look like "no drift". + // A measured health of 0 (runtime lag of 4 or more) still inverts to drift 100. const toDrift = (health: number) => 100 - health; + const healthToDrift = (health: number | null): number | null => + health === null ? null : toDrift(Math.round(health)); - const buildComponents = (): DriftScore['components'] => { - const c: DriftScore['components'] = { - runtimeScore: toDrift(Math.round(rs ?? 100)), - frameworkScore: toDrift(Math.round(fs ?? 100)), - dependencyScore: toDrift(Math.round(ds ?? 100)), - eolScore: toDrift(Math.round(es ?? 100)), - }; - return c; - }; + const buildComponents = (): DriftScore['components'] => ({ + runtimeScore: healthToDrift(rs), + frameworkScore: healthToDrift(fs), + dependencyScore: healthToDrift(ds), + eolScore: healthToDrift(es), + }); // Score envelope (§6.3): the dependency pillar's provenance (`mode`) and its // v3 detail (`p95`/`unsupportedShare`/`coverage`/ranked `top`), for @@ -238,11 +239,12 @@ export function computeDriftScore(projects: ProjectScan[]): DriftScore { const active = components.filter((c) => c.score !== null); if (active.length === 0) { - // No data at all — neutral score (no measurable drift) + // Nothing was measured. Absent is not a perfect score. return { - score: 0, - riskLevel: 'low', + score: null, + riskLevel: null, components: buildComponents(), + measured: [], methodologyVersion: DRIFT_SCORE_METHODOLOGY_VERSION, ...(confidence !== undefined ? { confidence } : {}), ...envelope, diff --git a/src/core-open/types.ts b/src/core-open/types.ts index 07f0335..454bd59 100644 --- a/src/core-open/types.ts +++ b/src/core-open/types.ts @@ -285,18 +285,20 @@ export interface MermaidDiagram { export interface DriftScore { /** - * DriftScore (`driftscore-2.0`): 0–100 where **0 = no drift (best)** and - * **100 = maximum drift (worst)**. Higher is worse — consistent with - * RiskScore and the "drift budget" model. Components below are also drift - * (0 = fully current). + * DriftScore: 0–100 where **0 = no drift (best)** and **100 = maximum drift + * (worst)**. Higher is worse — consistent with RiskScore and the "drift + * budget" model. `null` means nothing was measured. A missing score is never + * stored as 0, which would read as no drift. */ - score: number; - riskLevel: RiskLevel; + score: number | null; + /** `null` when `score` was not measured. */ + riskLevel: RiskLevel | null; + /** Per-component drift (0 = fully current). `null` means that component had no input. */ components: { - runtimeScore: number; - frameworkScore: number; - dependencyScore: number; - eolScore: number; + runtimeScore: number | null; + frameworkScore: number | null; + dependencyScore: number | null; + eolScore: number | null; /** * Libyear-based dependency-freshness sub-score as drift (0–100, 0 = fresh). * Optional/additive: only present when release-date data was available, so diff --git a/src/core-open/utils/mermaid.ts b/src/core-open/utils/mermaid.ts index b035e62..7ca660b 100644 --- a/src/core-open/utils/mermaid.ts +++ b/src/core-open/utils/mermaid.ts @@ -11,8 +11,8 @@ function escapeLabel(input: string): string { return input.replace(/"/g, '\\"'); } -function scoreClass(score: number | undefined): 'scoreHigh' | 'scoreModerate' | 'scoreLow' | 'scoreUnknown' { - if (score === undefined || Number.isNaN(score)) return 'scoreUnknown'; +function scoreClass(score: number | null | undefined): 'scoreHigh' | 'scoreModerate' | 'scoreLow' | 'scoreUnknown' { + if (typeof score !== 'number' || Number.isNaN(score)) return 'scoreUnknown'; // Match dashboard thresholds: >= 80 green, >= 50 amber, < 50 red if (score >= 80) return 'scoreHigh'; if (score >= 50) return 'scoreModerate'; diff --git a/src/engine/cache.ts b/src/engine/cache.ts index 1e5a5c2..9233a48 100644 --- a/src/engine/cache.ts +++ b/src/engine/cache.ts @@ -27,7 +27,9 @@ import type { FileParse } from './types.js'; // Bumped to /4: optional mtime+size fingerprint for stat-skip fast path. // /6: RawCall carries `awaited`; /5 parses lack it. -const CACHE_VERSION = 'vg-parse-cache/6'; +// /7: Prisma model-delegate writes (`prisma.post.update`) now yield `persist` +// duties, so /6 parses of such files differ. +const CACHE_VERSION = 'vg-parse-cache/7'; interface CacheEntry { hash: string; diff --git a/src/engine/duties.test.ts b/src/engine/duties.test.ts index 8c98836..4615060 100644 --- a/src/engine/duties.test.ts +++ b/src/engine/duties.test.ts @@ -188,6 +188,45 @@ class UserService: expect(d[1]).toMatchObject({ k: 'persist', o: 'User', via: 'db.add' }); }); + it('Prisma model writes through an imported, untyped client persist the PascalCased model', async () => { + const ts = ` +import { prisma } from './db'; +export async function publish(id: string) { + const post = await prisma.post.findUnique({ where: { id } }); + const updated = await prisma.post.update({ where: { id }, data: { published: true } }); + await prisma.post.create({ data: { title: 'x' } }); + await prisma.post.upsert({ where: { id }, create: {}, update: {} }); + await prisma.post.delete({ where: { id } }); + await prisma.post.createMany({ data: [] }); + await prisma.post.updateMany({ where: {}, data: {} }); + await prisma.post.deleteMany({ where: {} }); + return updated ?? post; +}`; + const d = await dutiesOf('ts', 'src/posts.ts', ts, 'publish'); + expect(d.map((x) => [x.k, x.o, x.via])).toEqual([ + ['query', 'Post', 'findUnique'], + ['persist', 'Post', 'post.update'], + ['persist', 'Post', 'post.create'], + ['persist', 'Post', 'post.upsert'], + ['persist', 'Post', 'post.delete'], + ['persist', 'Post', 'post.createMany'], + ['persist', 'Post', 'post.updateMany'], + ['persist', 'Post', 'post.deleteMany'], + ]); + }); + + it('Prisma model writes through a NestJS PrismaService and a multi-word model', async () => { + const ts = ` +export class PostsService { + constructor(private readonly prisma: PrismaService) {} + async archive(id: string) { + await this.prisma.blogPost.update({ where: { id }, data: { archived: true } }); + } +}`; + const d = await dutiesOf('ts', 'src/posts.service.ts', ts, 'archive'); + expect(d).toEqual([expect.objectContaining({ k: 'persist', o: 'BlogPost', via: 'blogPost.update' })]); + }); + it('Prisma $transaction and a Java Spring repository save', async () => { const ts = ` export async function moveStock(prisma: PrismaClient, from: string, to: string) { diff --git a/src/engine/duties.ts b/src/engine/duties.ts index 31bf75e..9805ba2 100644 --- a/src/engine/duties.ts +++ b/src/engine/duties.ts @@ -167,6 +167,9 @@ const WRITE = /^(?:save\w*|insert\w*|create\w*|update\w*|upsert\w*|delete\w*|rem /** ActiveRecord class-level finders and writers on a bare model constant. */ const RAILS_READ = /^(?:find|find_by|where|all|first|last|exists|count|pluck|order|includes|select|take|find_each|find_in_batches|joins|distinct|limit|sum|average|maximum|minimum|ids|find_or_initialize_by|find_sole_by|sole)$/; const RAILS_WRITE = /^(?:create|update|destroy|destroy_all|delete|delete_all|update_all|insert|insert_all|upsert|upsert_all|find_or_create_by|create_or_find_by|touch_all|increment_counter|decrement_counter|update_counters)$/; +/** Prisma model-delegate methods (`prisma.post.update`). */ +const PRISMA_WRITE = /^(?:create|createMany|createManyAndReturn|update|updateMany|updateManyAndReturn|upsert|delete|deleteMany)$/; +const PRISMA_READ = /^(?:findUnique|findUniqueOrThrow|findFirst|findFirstOrThrow|findMany|count|aggregate|groupBy)$/; /** Unit-of-work verbs: a write with no object of its own. */ const UOW = /^(?:savechanges(?:async)?|commit|flush|\$transaction|transaction|begin_transaction|begintransaction|save_changes)$/i; /** `execute` / `query` / `raw`: a read unless the statement text says otherwise. */ @@ -533,6 +536,16 @@ function classifySite(site: Site, def: Node, langId: string, bindings: Bindings, if (UOW.test(lower) && (cls === 'store' || cls === 'unknown')) { return mk('persist', undefined, viaOf()); } + // Prisma model delegates: `prisma.post.update(…)`. The client is usually + // imported (`import { prisma } from './db'`, so untyped here) or injected as + // a `PrismaService` (a "service" by suffix); either way `client.model.verb` + // with a Prisma verb is the store, and the model segment is the object. + const prismaModel = /^(?:this\.|self\.)?(\w+)\.([a-z]\w*)$/.exec(receiver); + if (prismaModel && (/^(?:_?prisma|tx|trx)$/i.test(prismaModel[1]!) || /prisma/i.test(declaredShort ?? ''))) { + const model = prismaModel[2]![0]!.toUpperCase() + prismaModel[2]!.slice(1); + if (PRISMA_WRITE.test(verb)) return mk('persist', model, viaOf()); + if (PRISMA_READ.test(verb)) return mk('query', model, callee); + } // Strong, receiver-independent verbs. if (/^(?:saveandflush|saveall|insertmany|insertone|createmany|updatemany|deletemany|bulk_create|bulk_update|get_or_create|update_or_create|executeupdate|find_or_create_by)$/i.test(lower) || /^(?:save|update|create|destroy)!$/.test(callee)) { // `@post.update!(published_at: Time.current)`: the instance is the object, diff --git a/src/engine/export.test.ts b/src/engine/export.test.ts index 28b814c..499dba5 100644 --- a/src/engine/export.test.ts +++ b/src/engine/export.test.ts @@ -114,3 +114,38 @@ describe('sql export', () => { expect(a).toBe(b); }); }); + +describe('cyclonedx export purl', () => { + it('keeps an npm component whose name cannot be a Package URL and omits the purl', () => { + const base = ctx(makeGraph(false)); + const exported = exportGraph('cyclonedx', { + ...base, + deps: [ + { name: 'chalk', ecosystem: 'npm', declared: '^5.0.0', installed: '5.3.0' }, + { name: 'foo bar', ecosystem: 'npm', declared: '1.0.0', installed: '1.0.0' }, + { name: 'requests', ecosystem: 'pypi', declared: '2.31.0', installed: '2.31.0' }, + ], + }); + expect(exported).toBe( + exportGraph('cyclonedx', { + ...base, + deps: [ + { name: 'chalk', ecosystem: 'npm', declared: '^5.0.0', installed: '5.3.0' }, + { name: 'foo bar', ecosystem: 'npm', declared: '1.0.0', installed: '1.0.0' }, + { name: 'requests', ecosystem: 'pypi', declared: '2.31.0', installed: '2.31.0' }, + ], + }), + ); + const bom = JSON.parse(exported) as { + components: Array<{ name: string; purl?: string; properties?: Array<{ name: string; value: string }> }>; + }; + expect(bom.components.find((c) => c.name === 'chalk')?.purl).toBe('pkg:npm/chalk@5.3.0'); + const bad = bom.components.find((c) => c.name === 'foo bar')!; + expect(bad.purl).toBeUndefined(); + expect(bad.properties?.find((p) => p.name === 'vibgrate:purlStatus')?.value).toBe('unavailable'); + expect(exported).not.toContain('foo%20bar'); + expect(exported).not.toContain('pkg:npm/foo'); + // Non-npm rows still carry no guessed npm purl. + expect(bom.components.find((c) => c.name === 'requests')?.purl).toBeUndefined(); + }); +}); diff --git a/src/engine/export.ts b/src/engine/export.ts index af05e47..f6ac8d5 100644 --- a/src/engine/export.ts +++ b/src/engine/export.ts @@ -2,6 +2,7 @@ import { serializeGraph, slimGraphForExport } from './serialize.js'; import { renderReport } from './report.js'; import { renderHtml } from './html.js'; import type { DepRecord } from './drift.js'; +import { resolvePurl } from '../reporting/commands/sbom.js'; import type { LocalModel } from './models.js'; import type { VgGraph } from '../schema.js'; @@ -247,12 +248,27 @@ function cyclonedx(ctx: ExportContext): string { // timestamps beyond the pinned generatedAt. const components: unknown[] = []; for (const d of ctx.deps ?? []) { - components.push({ - type: 'library', - name: d.name, - version: d.installed ?? d.declared, - purl: d.ecosystem === 'npm' ? `pkg:npm/${d.name}@${d.installed ?? ''}` : undefined, - }); + const version = d.installed ?? d.declared; + if (d.ecosystem !== 'npm') { + components.push({ type: 'library', name: d.name, version, purl: undefined }); + continue; + } + // Same rule as `vg sbom`: a name that cannot be a Package URL is kept, + // and the purl field is omitted rather than filled with a purl-shaped string. + const resolved = resolvePurl('npm', d.name, d.installed ?? ''); + if (resolved.purl) { + components.push({ type: 'library', name: d.name, version, purl: resolved.purl }); + } else { + components.push({ + type: 'library', + name: d.name, + version, + properties: [ + { name: 'vibgrate:purlStatus', value: 'unavailable' }, + { name: 'vibgrate:purlWarning', value: resolved.warning }, + ], + }); + } } for (const m of ctx.models ?? []) { components.push({ type: 'machine-learning-model', name: m.name, properties: [{ name: 'vg:runtime', value: m.runtime }] }); diff --git a/src/lsp/server.ts b/src/lsp/server.ts index f59d95f..4e60bc4 100644 --- a/src/lsp/server.ts +++ b/src/lsp/server.ts @@ -101,6 +101,12 @@ import type { VgGraph } from '../schema.js'; /** DriftScore band. Mirrors the engine — clients must never re-derive it. */ export type Band = 'low' | 'moderate' | 'high'; +/** Map an engine risk level onto the wire band. Null stays null — never `low`. */ +function driftBand(level: string | null | undefined): Band | null { + if (level === 'low' || level === 'moderate' || level === 'high') return level; + return null; +} + /** * `vibgrate/scanArtifact` — full Drift scan artifact as prettified JSON for * Output ▸ Vibgrate Scan. Kept off the main log channel so operational noise @@ -991,22 +997,15 @@ export class VibgrateLanguageServer { // `riskLevel` is what `driftscore-2.0` ships; v3 renames it `band` (§5 // envelope). We normalise to `band` on the wire so clients are already // speaking v3 and need no change when the engine catches up. - const band = (a.drift.riskLevel ?? 'low') as Band; - - // History + drift diff (plan §5.6/§5.8): diff against the last recorded - // entry, then record this one. Both engine-side — clients only render. - const historyEntry: ScoreHistoryEntry = { - ts: a.timestamp, - score: a.drift.score, - band, - mode: a.drift.mode ?? (hasReleaseDates(a) ? 'verified' : 'estimated'), - methodology: a.drift.methodologyVersion ?? 'unknown', - }; - const delta = deltaFrom(lastEntry(this.opts.root), historyEntry); - recordScore(this.opts.root, historyEntry); + // A null risk level is unmeasured — do not coerce it to `low`. + const score = a.drift.score; + const band = driftBand(a.drift.riskLevel); + const mode = a.drift.mode ?? (hasReleaseDates(a) ? 'verified' : 'estimated'); + const methodology = a.drift.methodologyVersion ?? 'unknown'; // Per-dependency state for the inline/hover surfaces — all O(deps), once // per scan, never in a hover or decoration hot path (coverage plan §5). + // This runs even when the aggregate score is absent: inventory is not a score. this.ignores = readIgnores(this.opts.root); const driftedKeys: string[] = []; for (const proj of a.projects ?? []) { @@ -1016,12 +1015,27 @@ export class VibgrateLanguageServer { } } } - const snapshot = { ts: a.timestamp, methodology: historyEntry.methodology, drifted: driftedKeys }; + const snapshot = { ts: a.timestamp, methodology, drifted: driftedKeys }; this.newDrift = newlyDrifted(readInventory(this.opts.root), snapshot); recordInventory(this.opts.root, snapshot); + // History + drift diff (plan §5.6/§5.8): diff against the last recorded + // entry, then record this one. Both engine-side — clients only render. + // An unmeasured score is not recorded and not pushed: clients render a + // missing notification as "no score", and a pushed 0 would read as perfect. + if (typeof score !== 'number' || band === null) return; + const historyEntry: ScoreHistoryEntry = { + ts: a.timestamp, + score, + band, + mode, + methodology, + }; + const delta = deltaFrom(lastEntry(this.opts.root), historyEntry); + recordScore(this.opts.root, historyEntry); + const payload: ScoreNotification = { - score: a.drift.score, + score, band, // v3 §2.4: `estimated` means "no timestamps at all" — it is NOT an // offline marker. An air-gapped scan against a dated snapshot is Verified. @@ -1246,9 +1260,13 @@ export class VibgrateLanguageServer { (project.drift?.mode ?? a.drift.mode ?? (hasReleaseDates(a) ? 'verified' : 'estimated')) === 'estimated'; const mode = estimated ? '~' : ''; - const score = project.drift?.score ?? a.drift.score; - const band = project.drift?.riskLevel ?? a.drift.riskLevel; - const title = `Vibgrate · drift ${mode}${score} (${band}) · ${behind} behind · ${eol} EOL`; + // `??` would treat an explicit null (unmeasured) as missing and fall through + // to the workspace score. Use the project's own score when it has one. + const score = project.drift ? project.drift.score : a.drift.score; + const band = project.drift ? project.drift.riskLevel : a.drift.riskLevel; + const scoreLabel = typeof score === 'number' ? `${mode}${score}` : 'n/a'; + const bandLabel = driftBand(band) ?? 'n/a'; + const title = `Vibgrate · drift ${scoreLabel} (${bandLabel}) · ${behind} behind · ${eol} EOL`; return [ { @@ -2151,8 +2169,8 @@ function buildProjectRefs(rootDir: string, projects: ProjectScan[]): ProjectRef[ name: rel, manifestPath: manifestRelativePath(p), ...(lockfilePath ? { lockfilePath } : {}), - score: p.drift.score, - band: (p.drift.riskLevel ?? 'low') as Band, + ...(typeof p.drift.score === 'number' ? { score: p.drift.score } : {}), + ...(driftBand(p.drift.riskLevel) ? { band: driftBand(p.drift.riskLevel) as Band } : {}), mode: (p.drift.mode ?? 'verified') as 'verified' | 'estimated', }); } @@ -2201,9 +2219,12 @@ function scoreForProject(rootDir: string, a: ScanArtifact, proj: ProjectScan): S ).length; const hasDates = (proj.dependencies ?? []).some((d) => d.ageDays !== null && d.ageDays !== undefined); + if (typeof proj.drift.score !== 'number') return null; + const band = driftBand(proj.drift.riskLevel); + if (!band) return null; return { score: proj.drift.score, - band: (proj.drift.riskLevel ?? 'low') as Band, + band, mode: proj.drift.mode ?? (hasDates ? 'verified' : 'estimated'), methodology: proj.drift.methodologyVersion ?? a.drift.methodologyVersion ?? 'unknown', scale: '0 best, 100 worst', diff --git a/src/mcp/review-tools.test.ts b/src/mcp/review-tools.test.ts index 4b6828f..f5652c0 100644 --- a/src/mcp/review-tools.test.ts +++ b/src/mcp/review-tools.test.ts @@ -150,3 +150,57 @@ describe('review_doc, as an agent uses it', () => { expect(String(res.message)).toMatch(/needs a git repository/); }); }); + +describe('review_doc op "explain"', () => { + let root: string; + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + const fnNode = (id: string, file: string, start: number, end: number) => ({ + id, kind: 'function', name: id, qualifiedName: id, file, span: { start, end }, lang: 'ts', importance: 0.1, + centrality: { degree: 0, pagerank: 0, betweenness: 0, eigenvector: 0 }, area: 0, isHub: false, + }); + const graph = { + schemaVersion: 'vg-graph/1.1', + nodes: [fnNode('main', 'src/app.ts', 1, 3), fnNode('save', 'src/store.ts', 2, 4)], + edges: [{ id: 'call:main>save', kind: 'call', src: 'main', dst: 'save', resolution: 'tsc', confidence: 1, sites: [2] }], + areas: [{ id: 0, label: 'app' }], + } as unknown as VgGraph; + const explain = (args: Record, g: VgGraph = graph) => + Promise.resolve(tool.handler(g, { op: 'explain', ...args }, { root })) as Promise>; + + function setup(): void { + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'vg-review-explain-'))); + fs.mkdirSync(path.join(root, 'src')); + fs.writeFileSync(path.join(root, 'src/app.ts'), 'a\nb\nc\n'); + fs.writeFileSync(path.join(root, 'src/store.ts'), 'a\nb\nc\nd\n'); + } + + it('returns the call path between two symbols, read-only and unsaved', async () => { + setup(); + const res = await explain({ symbol: 'main', to: 'save' }); + expect(res).toMatchObject({ doc_id: null, version: null, saved: false }); + expect((res.doc as { kind: string; title: string }).title).toBe('Path: main → save'); + expect(fs.existsSync(path.join(root, '.vibgrate', 'review-docs'))).toBe(false); + }); + + it('keeps an explanation on the scratchpad, which the agent then reads and patches by id', async () => { + setup(); + const kept = await explain({ symbol: 'main', to: 'save', keep: true }); + expect(kept).toMatchObject({ doc_id: 'scratchpad', version: 1, saved: true, kept: 'Path: main → save' }); + const call2 = (args: Record) => Promise.resolve(tool.handler(graph, args, { root })) as Promise>; + const got = await call2({ op: 'get', doc_id: 'scratchpad' }); + const blocks = (got.doc as { sections: { blocks: { id: string; type: string; text?: string }[] }[] }).sections[0]!.blocks; + expect(blocks[0]!.text).toBe('#### Path: main → save'); + const res = await call2({ op: 'patch', doc_id: 'scratchpad', version: 1, ops: [{ op: 'set_text', block: blocks[0]!.id, text: '#### How main saves' }] }); + expect(res).toMatchObject({ saved: true, version: 2 }); + expect(await call2({ op: 'history', doc_id: 'scratchpad' })).toMatchObject({ error: 'bad_request' }); + expect(await call2({ op: 'clear', doc_id: 'scratchpad' })).toEqual({ cleared: true, was_version: 2 }); + }); + + it('says what is missing: a code map, a symbol, or a path', async () => { + setup(); + expect(await explain({ symbol: 'main' }, { ...graph, nodes: [] } as VgGraph)).toMatchObject({ error: 'no_code_map' }); + expect(await explain({})).toMatchObject({ error: 'bad_request' }); + expect(await explain({ symbol: 'nope' })).toMatchObject({ error: 'not_found' }); + expect(await explain({ symbol: 'save', to: 'main' })).toMatchObject({ doc: expect.objectContaining({ title: 'Path: main → save' }) }); + }); +}); diff --git a/src/mcp/review-tools.ts b/src/mcp/review-tools.ts index ef017d8..0d98f84 100644 --- a/src/mcp/review-tools.ts +++ b/src/mcp/review-tools.ts @@ -17,6 +17,9 @@ * `destructiveHint: false`, as for `compress_content` and `memory_save`. * `openWorldHint: true` because ops `comments` and `reply` read and answer * the comments people left on the pushed document in Vibgrate Cloud. + * + * Op "explain" with `keep` and doc_id "scratchpad" reach the explain + * scratchpad (`review/scratchpad.ts`), stored beside the review documents. */ import type { DocScope } from '../review/doc-build.js'; @@ -32,6 +35,12 @@ import { restoreVersion, } from '../review/doc-store.js'; import type { VgTool } from './tools.js'; +import type { GraphNode, VgGraph } from '../schema.js'; +import { resolveOne } from '../engine/lookup.js'; +import { callPath, shortestPath } from '../engine/paths.js'; +import { loadHaileProvider } from '../engine/haile/haile-provider.js'; +import { buildExplainDoc, buildPathDoc, ExplainEmpty } from '../review/explain-doc.js'; +import { clearScratchpad, getScratchpad, keepInScratchpad, patchScratchpad, SCRATCHPAD_ID, type Scratchpad } from '../review/scratchpad.js'; import { cloudDsn, fetchComments, replyToComment, targetOf } from '../review/doc-comments.js'; /** Inline the whole document only below this size; above it the outline plus `get` by block keeps every result inside the token budget. */ @@ -45,6 +54,7 @@ const DESCRIPTION = [ 'Patches are all or nothing and name the version they were written against; a pin that does not land, or a stale version, saves nothing and says why.', 'Whatever you write is recorded as origin "agent"; only unchanged graph-derived elements stay "graph".', 'After the document is pushed (`vg review doc --push`), op "comments" lists what people asked on its blocks in Vibgrate Cloud, and op "reply" answers a thread (comment_id, text); replies are shown as written by an agent.', + 'To explain code as it is rather than a change, op "explain" with `symbol` returns the same kind of document (kind "explain": what it is, how it is reached, its flow, the data it reads and writes, where it sits); add `to` for the call path from symbol to `to`. It is not saved unless you pass `keep: true`, which puts it on top of the scratchpad (doc_id "scratchpad"): the always-present explain canvas the person sees in VS Code, newest on top. Patch the scratchpad by block id like a review document (ops get, patch, check, clear); start your own entry with a markdown block whose text begins "#### ". Reply to the person in one line and let the scratchpad carry the explanation.', ].join(' '); const PIN = { @@ -61,14 +71,18 @@ const PIN = { const SCHEMA = { type: 'object', properties: { - op: { type: 'string', enum: ['open', 'get', 'patch', 'check', 'history', 'restore', 'comments', 'reply'] }, - doc_id: { type: 'string', description: 'from open (rd_…); required for every op but open' }, + op: { type: 'string', enum: ['open', 'get', 'patch', 'check', 'history', 'restore', 'comments', 'reply', 'explain', 'clear'] }, + doc_id: { type: 'string', description: 'from open (rd_…), or "scratchpad"; required for every op but open and explain' }, base: { type: 'string', description: 'open: review HEAD against the merge-base with this ref (default: working tree vs HEAD)' }, in_place: { type: 'boolean', description: 'open: with base, include the working tree' }, session: { type: 'string', description: 'open: a VG Code chat id, or "latest" — only the files it touched, its requests as requirements' }, fresh: { type: 'boolean', description: 'open: rebuild from the change as a new version instead of reusing the saved one' }, base_graph: { type: 'boolean', description: 'open: also map the base commit for before/after call paths (slower)' }, block: { type: 'string', description: 'get: return one block by id' }, + symbol: { type: 'string', description: 'explain: the code to explain — qualified name, short name, file:line or id' }, + to: { type: 'string', description: 'explain: draw the path from symbol to this one instead' }, + calls_only: { type: 'boolean', description: 'explain with to: follow call edges only (default true)' }, + keep: { type: 'boolean', description: 'explain: also put it on top of the scratchpad (doc_id "scratchpad")' }, comment_id: { type: 'string', description: 'reply: the comment (rdc_…) to answer, from op "comments"' }, text: { type: 'string', description: 'reply: your answer, plain text, at most 4000 characters' }, version: { type: 'integer', minimum: 1, description: 'patch: the version you read (required); get/restore: which version' }, @@ -90,7 +104,7 @@ function str(v: unknown): string | undefined { } /** The document, or only its outline when inlining it would crowd the agent's context. */ -function view(doc_id: string, version: number, doc: ReviewDoc): Record { +function view(doc_id: string | null, version: number | null, doc: ReviewDoc): Record { const json = JSON.stringify(doc); return { doc_id, @@ -106,14 +120,84 @@ function scopeFrom(args: Record): DocScope { return session ? { kind: 'session', session, base } : { kind: 'change', base, in_place: args.in_place === true }; } -async function run(root: string, args: Record): Promise { +/** + * op "explain": the explain document for a symbol, or for the path between + * two. Read-only, not saved: it describes code as it is, so there is nothing + * to version against. + */ +async function explain(root: string, graph: VgGraph, args: Record): Promise { + if (graph.nodes.length === 0) return { error: 'no_code_map', message: 'explain needs a code map — run `vg` in the repository first' }; + const symbol = str(args.symbol); + if (!symbol) return { error: 'bad_request', message: 'explain needs symbol' }; + const pick = (name: string): { node: GraphNode } | { error: string; message: string; candidates: string[] } => { + const r = resolveOne(graph, name); + return r.node ? { node: r.node } : { error: r.candidates.length ? 'ambiguous' : 'not_found', message: `"${name}" ${r.candidates.length ? 'is ambiguous' : 'matches no node'}`, candidates: r.candidates.slice(0, 10).map((n) => n.qualifiedName) }; + }; + const from = pick(symbol); + if (!('node' in from)) return from; + const toName = str(args.to); + try { + const answer = (doc: ReviewDoc) => { + if (args.keep !== true) return { ...view(null, null, doc), saved: false }; + const pad = keepInScratchpad(root, doc); + return { ...view(pad.doc_id, pad.version, pad.doc ?? doc), saved: true, kept: doc.title }; + }; + if (!toName) { + const { doc } = buildExplainDoc({ root, graph, node: from.node, provider: await loadHaileProvider() }); + return answer(doc); + } + const to = pick(toName); + if (!('node' in to)) return to; + const callsOnly = args.calls_only !== false; + const found = callsOnly ? callPath(graph, from.node.id, to.node.id) : shortestPath(graph, from.node.id, to.node.id); + if (!found) return { error: 'not_found', message: `no ${callsOnly ? 'call ' : ''}path between ${from.node.qualifiedName} and ${to.node.qualifiedName}` }; + const { doc } = buildPathDoc({ root, graph, path: found, callsOnly }); + return answer(doc); + } catch (err) { + if (err instanceof ExplainEmpty) return { error: 'not_found', message: err.message }; + throw err; + } +} + +/** Ops on the explain scratchpad (review/scratchpad.ts): one document per repository, no history. */ +function scratchpadOp(root: string, op: string | undefined, args: Record): unknown { + const shown = (pad: Scratchpad) => ({ ...(pad.doc ? view(pad.doc_id, pad.version, pad.doc) : { doc_id: pad.doc_id, version: pad.version, doc: null }), stale: pad.stale }); + switch (op) { + case 'get': { + const pad = getScratchpad(root); + const block = str(args.block); + if (!block || !pad.doc) return shown(pad); + const b = pad.doc.sections.flatMap((s) => s.blocks).find((x) => x.id === block); + return b ? { doc_id: pad.doc_id, version: pad.version, block: b } : { error: 'not_found', message: `no block ${block} in version ${pad.version}`, outline: outline(pad.doc) }; + } + case 'check': { + const pad = getScratchpad(root); + return { doc_id: pad.doc_id, version: pad.version, valid: pad.stale.length === 0, stale: pad.stale }; + } + case 'patch': { + const version = typeof args.version === 'number' ? args.version : undefined; + if (version === undefined) return { error: 'bad_request', message: 'patch needs version: the version you read' }; + const res = patchScratchpad(root, version, args.ops); + if (!res.ok) return { saved: false, doc_id: SCRATCHPAD_ID, ...res }; + return { saved: true, notes: res.notes, ...shown(res.scratchpad) }; + } + case 'clear': + return { cleared: true, was_version: clearScratchpad(root) }; + default: + return { error: 'bad_request', message: 'the scratchpad takes ops get, patch, check and clear; explain with keep: true adds to it' }; + } +} + +async function run(root: string, graph: VgGraph, args: Record): Promise { const op = str(args.op); + if (op === 'explain') return explain(root, graph, args); if (op === 'open') { const opened = await openDocument(root, scopeFrom(args), { fresh: args.fresh === true, baseGraph: args.base_graph === true }); return { ...view(opened.doc_id, opened.version, opened.doc), built: opened.built, ...(opened.reason ? { rebuilt_because: opened.reason } : {}) }; } const id = str(args.doc_id); if (!id) return { error: 'bad_request', message: `op "${op ?? ''}" needs doc_id — call op "open" first` }; + if (id === SCRATCHPAD_ID) return scratchpadOp(root, op, args); const version = typeof args.version === 'number' ? args.version : undefined; switch (op) { case 'get': { @@ -156,7 +240,7 @@ async function run(root: string, args: Record): Promise { + handler: async (graph, args, ctx) => { try { - return await run(ctx.root, args); + return await run(ctx.root, graph, args); } catch (err) { return { error: 'review_doc_failed', message: (err as Error).message }; } diff --git a/src/reporting/commands/baseline.ts b/src/reporting/commands/baseline.ts index c944422..aa39608 100644 --- a/src/reporting/commands/baseline.ts +++ b/src/reporting/commands/baseline.ts @@ -17,7 +17,8 @@ export async function runBaseline(rootDir: string): Promise { const baselinePath = path.join(rootDir, '.vibgrate', 'baseline.json'); await writeJsonFile(baselinePath, artifact); console.log(chalk.green('✔') + ` Baseline saved to ${chalk.bold('.vibgrate/baseline.json')}`); - console.log(chalk.dim(` Baseline score: ${artifact.drift.score}/100`)); + const baselineScore = artifact.drift.score === null ? 'n/a' : `${artifact.drift.score}/100`; + console.log(chalk.dim(` Baseline score: ${baselineScore}`)); } export const baselineCommand = new Command('baseline') diff --git a/src/reporting/commands/fix-e2e.test.ts b/src/reporting/commands/fix-e2e.test.ts index 1e0e0dc..9b58742 100644 --- a/src/reporting/commands/fix-e2e.test.ts +++ b/src/reporting/commands/fix-e2e.test.ts @@ -187,6 +187,7 @@ describe('vg fix — end to end on real repos', () => { expect(rendered.currentDriftScore).toBe(artifact.drift.score); const safe = rendered.plans.find((p) => p.tier === 'safe')!; expect(safe.expectedDriftScore).toBe(0); // upgrading lodash to current clears all drift + if (artifact.drift.score === null) throw new Error('expected a measured DriftScore'); expect(safe.driftDelta).toBe(-artifact.drift.score); // strictly better }); @@ -316,6 +317,7 @@ describe('vg fix — end to end on real repos', () => { // …and a fresh scan proves the drift is gone, matching the pre-apply estimate. const after = await scan(root); + if (before.drift.score === null) throw new Error('expected a measured DriftScore'); expect(after.drift.score).toBeLessThan(before.drift.score); expect(after.drift.score).toBe(0); expect(after.projects[0].dependencyAgeBuckets).toMatchObject({ current: 2, twoPlusBehind: 0 }); diff --git a/src/reporting/commands/fix.ts b/src/reporting/commands/fix.ts index 23fa9b4..d4c03d1 100644 --- a/src/reporting/commands/fix.ts +++ b/src/reporting/commands/fix.ts @@ -340,6 +340,7 @@ export const fixCommand = new Command('fix') for (const plan of response.plans) { const upgraded = new Set(plan.upgrades.map((u) => u.package)); const expected = estimateDriftScore(artifact, upgraded); + if (typeof expected !== 'number') continue; plan.expectedDriftScore = expected; plan.driftDelta = expected - currentDrift; } diff --git a/src/reporting/commands/sbom.test.ts b/src/reporting/commands/sbom.test.ts index 4625967..79fa88f 100644 --- a/src/reporting/commands/sbom.test.ts +++ b/src/reporting/commands/sbom.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { toCycloneDx, toSpdx, formatDeltaText, npmPurl, purlFor, collectLockfileGraph } from './sbom.js'; +import { toCycloneDx, toSpdx, formatDeltaText, npmPurl, purlFor, collectLockfileGraph, collectPurlWarnings, describeUnavailablePurl } from './sbom.js'; import type { ProjectScan, ScanArtifact } from '../types.js'; import type { LockfileGraph } from '../../engine/lockfile.js'; @@ -232,6 +232,113 @@ describe('sbom helpers', () => { expect(sbom.components[0]!.purl).toBe('pkg:npm/chalk@5.3.0'); }); + /** + * A name that cannot be a purl name. `encodeURIComponent` used to turn the + * space into `pkg:npm/foo%20bar@1.0.0`, which is a purl-shaped string, and + * an empty path segment (`@scope/`) used to become `pkg:npm/%40scope/`. + * The component stays; the purl is omitted; the status is explicit. + */ + it('keeps a component whose name cannot be a Package URL and marks the purl unavailable', () => { + const artifact = makeArtifact('5.3.0', 90); + artifact.rootPath = '/var/private/checkout'; + const chalk = artifact.projects[0]!.dependencies[0]!; + artifact.projects[0]!.dependencies.push( + { ...chalk, package: 'foo bar', currentSpec: '1.0.0', resolvedVersion: '1.0.0' }, + { ...chalk, package: '@scope/', currentSpec: '2.0.0', resolvedVersion: '2.0.0' }, + { ...chalk, package: 'café', currentSpec: '3.0.0', resolvedVersion: '3.0.0' }, + ); + + const cyclone = toCycloneDx(artifact) as { + components: Array<{ + name: string; + version: string; + purl?: string; + 'bom-ref': string; + properties: Array<{ name: string; value: string }>; + }>; + }; + const again = JSON.stringify(toCycloneDx(artifact)); + expect(JSON.stringify(cyclone)).toBe(again); + expect(again).not.toContain('foo%20bar'); + expect(again).not.toContain('pkg:npm/foo'); + expect(again).not.toContain('%40scope/'); + expect(again).not.toContain('%C3%A9'); + + expect(cyclone.components.map((c) => c.name)).toEqual(['chalk', 'foo bar', '@scope/', 'café']); + expect(cyclone.components[0]!.purl).toBe('pkg:npm/chalk@5.3.0'); + + for (const name of ['foo bar', '@scope/', 'café']) { + const row = cyclone.components.find((c) => c.name === name)!; + expect(row.purl).toBeUndefined(); + expect(row['bom-ref'].startsWith('pkg:')).toBe(false); + expect(row['bom-ref']).toBe(`vibgrate:npm:${name}@${row.version}`); + const status = row.properties.find((p) => p.name === 'vibgrate:purlStatus')?.value; + const warning = row.properties.find((p) => p.name === 'vibgrate:purlWarning')?.value; + expect(status).toBe('unavailable'); + expect(warning).toBe(describeUnavailablePurl('npm', name, row.version)); + expect(warning).toContain(`npm package "${name}"`); + expect(warning).not.toContain('/var/private'); + } + + const warnings = collectPurlWarnings(artifact); + expect(warnings).toEqual([ + describeUnavailablePurl('npm', 'foo bar', '1.0.0'), + describeUnavailablePurl('npm', '@scope/', '2.0.0'), + describeUnavailablePurl('npm', 'café', '3.0.0'), + ]); + expect(warnings[0]).toContain('whitespace or a non-ASCII character'); + expect(warnings[1]).toContain('empty path segment'); + + const spdx = toSpdx(artifact) as { + packages: Array<{ + name: string; + externalRefs?: Array<{ referenceType: string; referenceLocator: string }>; + annotations: Array<{ comment: string }>; + }>; + }; + expect(JSON.stringify(toSpdx(artifact))).toBe(JSON.stringify(spdx)); + const bad = spdx.packages.find((p) => p.name === 'foo bar')!; + expect(bad.externalRefs).toBeUndefined(); + expect(bad.annotations[0]!.comment).toContain('purlStatus=unavailable'); + expect(bad.annotations[1]!.comment).toBe(warnings[0]); + expect(spdx.packages.find((p) => p.name === 'chalk')!.externalRefs?.[0]?.referenceLocator).toBe('pkg:npm/chalk@5.3.0'); + }); + + it('does not put a rejected purl on a dependency-graph edge', () => { + const artifact = makeArtifact('5.3.0', 90); + const chalk = artifact.projects[0]!.dependencies[0]!; + artifact.projects[0]!.dependencies.push({ ...chalk, package: 'foo bar', currentSpec: '1.0.0', resolvedVersion: '1.0.0' }); + const graph: LockfileGraph = { + components: [ + { package: 'chalk', version: '5.3.0' }, + { package: 'foo bar', version: '1.0.0' }, + ], + edges: new Map([['chalk@5.3.0', ['foo bar@1.0.0']]]), + rootDependsOn: ['chalk@5.3.0', 'foo bar@1.0.0'], + }; + const sbom = toCycloneDx(artifact, graph) as { + components: Array<{ name: string; 'bom-ref': string; purl?: string }>; + dependencies: Array<{ ref: string; dependsOn: string[] }>; + }; + const badRef = sbom.components.find((c) => c.name === 'foo bar')!['bom-ref']; + expect(badRef).toBe('vibgrate:npm:foo bar@1.0.0'); + expect(sbom.dependencies).toEqual([ + { ref: 'vibgrate-root', dependsOn: ['pkg:npm/chalk@5.3.0', badRef] }, + { ref: 'pkg:npm/chalk@5.3.0', dependsOn: [badRef] }, + { ref: badRef, dependsOn: [] }, + ]); + expect(JSON.stringify(sbom.dependencies)).not.toContain('pkg:npm/foo'); + }); + + it('purlFor returns null for a bad name, an empty segment, and an unknown ecosystem', () => { + expect(purlFor('npm', 'foo bar', '1.0.0')).toBeNull(); + expect(purlFor('npm', '@scope/', '2.0.0')).toBeNull(); + expect(purlFor('go', 'github.com//sse', 'v1.0.0')).toBeNull(); + expect(purlFor('npm', 'chalk', '^1.2.3')).toBeNull(); + expect(purlFor('not-a-registry' as never, 'chalk', '1.0.0')).toBeNull(); + expect(npmPurl('chalk', '5.3.0')).toBe('pkg:npm/chalk@5.3.0'); + }); + describe('collectLockfileGraph', () => { let root: string; beforeEach(() => { diff --git a/src/reporting/commands/sbom.ts b/src/reporting/commands/sbom.ts index c560d2f..5fd8065 100644 --- a/src/reporting/commands/sbom.ts +++ b/src/reporting/commands/sbom.ts @@ -4,7 +4,7 @@ import chalk from 'chalk'; import { pathExists, readJsonFile, writeTextFile } from '../utils/fs.js'; import type { DependencyRow, ProjectScan, ScanArtifact } from '../types.js'; import { fullDependencyGraph, type LockfileComponent, type LockfileGraph } from '../../engine/lockfile.js'; -import type { Ecosystem } from '../../engine/drift.js'; +import { ECOSYSTEMS, type Ecosystem } from '../../engine/drift.js'; import { vexCommand } from './vex.js'; type SbomFormat = 'cyclonedx' | 'spdx'; @@ -107,8 +107,31 @@ function isConcreteVersion(spec: string): boolean { return true; } +/** + * purl types this exporter actually emits. `encodeURIComponent` will turn a + * space or a non-ASCII name into a string that still starts with `pkg:`, so + * "looks like a purl" is not the check — the type has to be one of these. + */ +const KNOWN_PURL_TYPES = new Set(['npm', 'pypi', 'cargo', 'golang', 'maven', 'gem', 'composer', 'nuget', 'swift', 'pub']); + +/** + * A path segment we are willing to call a purl name. `encodeURIComponent` + * leaves these characters alone, plus `%40`, which is the encoded `@` of an + * npm scope (`pkg:npm/%40scope/name`). Anything else — `%20` for a space, + * `%C3%A9` for non-ASCII, an empty segment — is a purl-shaped string, not a + * Package URL. `.` and `..` are forbidden segments in the purl spec. + */ +const PURL_SEGMENT = /^(?:[A-Za-z0-9._~!*'()-]|%40)+$/; + +const KNOWN_ECOSYSTEMS = new Set(ECOSYSTEMS); + +/** CycloneDX property that says why `purl` was left off. Stable across runs. */ +const PURL_STATUS_PROPERTY = 'vibgrate:purlStatus'; +const PURL_WARNING_PROPERTY = 'vibgrate:purlWarning'; +const PURL_STATUS_UNAVAILABLE = 'unavailable'; + /** The purl type/namespace/name portion, without a version — shared by every ecosystem branch of `purlFor`. */ -function purlPath(ecosystem: Ecosystem, name: string): string { +function purlPath(ecosystem: Ecosystem, name: string): string | null { switch (ecosystem) { case 'npm': { const scopeSlash = name.startsWith('@') ? name.indexOf('/') : -1; @@ -138,7 +161,9 @@ function purlPath(ecosystem: Ecosystem, name: string): string { case 'dart': return `pkg:pub/${encodeURIComponent(name)}`; default: - return purlPath('npm', name); + // An ecosystem this function does not know is not npm. Falling through + // to `pkg:npm/...` would report a registry the scan did not detect. + return null; } } @@ -148,8 +173,8 @@ function purlPath(ecosystem: Ecosystem, name: string): string { * not a single percent-encoded `%40scope%2Fname`). Used to key components and * dependency-graph refs so a vulnerability scanner can match on purl directly. */ -export function npmPurl(name: string, version: string): string { - return `${purlPath('npm', name)}@${encodeURIComponent(version)}`; +export function npmPurl(name: string, version: string): string | null { + return purlFor('npm', name, version); } /** PyPI purl names are normalized per PEP 503: lowercased, runs of `-_.` collapsed to one `-`. */ @@ -163,10 +188,91 @@ function pypiPurlName(name: string): string { * mapping. A purl's `@version` is a claim about what's actually installed, * so `UNKNOWN_VERSION` omits it (a bare `pkg:npm/axios` is valid purl syntax) * rather than encode a range or protocol spec as if it were one. + * + * Returns null when the built string is not a Package URL: unknown type, + * empty name or path segment, a space or other character that only survives + * as percent-encoding, or a version that is not one concrete token. Callers + * keep the component and mark the purl unavailable — they do not drop the + * row, and they do not emit the rejected string. */ -export function purlFor(ecosystem: Ecosystem, name: string, version: string): string { +export function purlFor(ecosystem: Ecosystem, name: string, version: string): string | null { const path = purlPath(ecosystem, name); - return version === UNKNOWN_VERSION ? path : `${path}@${encodeURIComponent(version)}`; + if (!path) return null; + const purl = version === UNKNOWN_VERSION ? path : `${path}@${encodeURIComponent(version)}`; + return isValidBuiltPurl(purl) ? purl : null; +} + +function hasNonAscii(value: string): boolean { + for (let i = 0; i < value.length; i++) { + if (value.charCodeAt(i) > 0x7f) return true; + } + return false; +} + +function isPurlSegment(segment: string): boolean { + if (segment === '.' || segment === '..') return false; + return PURL_SEGMENT.test(segment); +} + +/** + * True when `purl` is a Package URL we would hand to a scanner: known type, + * every path segment a non-empty name, version either absent or one concrete + * token (`isConcreteVersion` already rejects ranges, wildcards, and protocol + * specs). Percent-encoding other than an npm scope's `%40` fails — that is + * how `foo bar` was leaving as `pkg:npm/foo%20bar@1.0.0`. + */ +function isValidBuiltPurl(purl: string): boolean { + if (!purl.startsWith('pkg:')) return false; + const rest = purl.slice(4); + const at = rest.lastIndexOf('@'); + const coords = at === -1 ? rest : rest.slice(0, at); + const version = at === -1 ? null : rest.slice(at + 1); + const slash = coords.indexOf('/'); + if (slash <= 0) return false; + const type = coords.slice(0, slash); + if (!KNOWN_PURL_TYPES.has(type)) return false; + const pathPart = coords.slice(slash + 1); + if (!pathPart || pathPart.split('/').some((segment) => !isPurlSegment(segment))) return false; + if (version === null) return true; + let decoded: string; + try { + decoded = decodeURIComponent(version); + } catch { + return false; + } + if (!decoded || /\s/u.test(decoded)) return false; + return isConcreteVersion(decoded); +} + +/** + * Why `purlFor` returned null. Names the package and ecosystem and says what + * to do. No filesystem path — a scan root is not part of the package identity. + */ +export function describeUnavailablePurl(ecosystem: string, name: string, version: string): string { + let because: string; + if (!KNOWN_ECOSYSTEMS.has(ecosystem)) { + because = 'this ecosystem has no Package URL type, so none is guessed'; + } else if (name.length === 0 || name.split('/').some((part) => part.length === 0)) { + because = 'the name has an empty path segment'; + } else if (/\s/u.test(name) || hasNonAscii(name)) { + because = 'the name contains whitespace or a non-ASCII character'; + } else if (version !== UNKNOWN_VERSION && !isConcreteVersion(version)) { + because = 'the version is not one concrete installed version'; + } else { + because = 'the coordinates cannot be encoded as a Package URL'; + } + return `Package URL unavailable for ${ecosystem} package "${name}": ${because}. The component is included without a purl. Use the package's registry name, with no spaces or empty path segments.`; +} + +export function resolvePurl(ecosystem: Ecosystem, name: string, version: string): { purl: string | null; warning: string | null } { + const purl = purlFor(ecosystem, name, version); + if (purl) return { purl, warning: null }; + return { purl: null, warning: describeUnavailablePurl(ecosystem, name, version) }; +} + +/** Stable CycloneDX bom-ref. A valid purl when we have one; never a rejected purl string. */ +function componentBomRef(ecosystem: Ecosystem, name: string, version: string): string { + return purlFor(ecosystem, name, version) ?? `vibgrate:${ecosystem}:${name}@${version}`; } function splitDependencyKey(key: string): { name: string; version: string } { @@ -211,12 +317,15 @@ function cycloneDxDependencyGraph( if (!graph?.edges) return undefined; const purlOfKey = (key: string): string => { const { name, version } = splitDependencyKey(key); - return npmPurl(name, version); + // Lockfile edges are keyed `name@version` and carry no ecosystem. For npm + // this ref matches the component bom-ref, including the non-purl ref used + // when the name cannot be a Package URL. + return componentBomRef('npm', name, version); }; const nodes = [{ ref: ROOT_BOM_REF, dependsOn: uniqSorted(graph.rootDependsOn).map(purlOfKey) }]; for (const dep of dependencies) { const key = `${dep.package}@${dep.version}`; - nodes.push({ ref: npmPurl(dep.package, dep.version), dependsOn: uniqSorted(graph.edges.get(key) ?? []).map(purlOfKey) }); + nodes.push({ ref: componentBomRef('npm', dep.package, dep.version), dependsOn: uniqSorted(graph.edges.get(key) ?? []).map(purlOfKey) }); } return nodes; } @@ -369,20 +478,30 @@ export function toCycloneDx(artifact: ScanArtifact, graph?: LockfileGraph): Reco name: artifact.rootPath, }, }, - components: dependencies.map((dep) => ({ - type: 'library', - 'bom-ref': purlFor(dep.ecosystem, dep.package, dep.version), - name: dep.package, - version: dep.version, - purl: purlFor(dep.ecosystem, dep.package, dep.version), - properties: [ + components: dependencies.map((dep) => { + const { purl, warning } = resolvePurl(dep.ecosystem, dep.package, dep.version); + const properties: Array<{ name: string; value: string }> = [ { name: 'vibgrate:project', value: dep.project }, { name: 'vibgrate:currentSpec', value: dep.currentSpec }, { name: 'vibgrate:drift', value: dep.drift }, { name: 'vibgrate:majorsBehind', value: String(dep.majorsBehind ?? 'unknown') }, { name: 'vibgrate:scope', value: dep.scope }, - ], - })), + ]; + if (warning) { + properties.push( + { name: PURL_STATUS_PROPERTY, value: PURL_STATUS_UNAVAILABLE }, + { name: PURL_WARNING_PROPERTY, value: warning }, + ); + } + return { + type: 'library', + 'bom-ref': purl ?? componentBomRef(dep.ecosystem, dep.package, dep.version), + name: dep.package, + version: dep.version, + ...(purl ? { purl } : {}), + properties, + }; + }), ...(dependencyGraph ? { dependencies: dependencyGraph } : {}), }; } @@ -400,32 +519,57 @@ export function toSpdx(artifact: ScanArtifact, graph?: LockfileGraph): Record ({ - name: dep.package, - SPDXID: `SPDXRef-Package-${i + 1}`, - versionInfo: dep.version, - downloadLocation: 'NOASSERTION', - filesAnalyzed: false, - externalRefs: [ - { - referenceCategory: 'PACKAGE-MANAGER', - referenceType: 'purl', - referenceLocator: purlFor(dep.ecosystem, dep.package, dep.version), - }, - ], - annotations: [ + packages: dependencies.map((dep, i) => { + const { purl, warning } = resolvePurl(dep.ecosystem, dep.package, dep.version); + const status = warning ? `; purlStatus=${PURL_STATUS_UNAVAILABLE}` : ''; + const annotations = [ { annotationType: 'OTHER', annotator: 'Tool: @vibgrate/cli', annotationDate: artifact.timestamp, - comment: `project=${dep.project}; drift=${dep.drift}; majorsBehind=${dep.majorsBehind ?? 'unknown'}; scope=${dep.scope}`, + comment: `project=${dep.project}; drift=${dep.drift}; majorsBehind=${dep.majorsBehind ?? 'unknown'}; scope=${dep.scope}${status}`, }, - ], - })), + ]; + if (warning) { + annotations.push({ + annotationType: 'OTHER', + annotator: 'Tool: @vibgrate/cli', + annotationDate: artifact.timestamp, + comment: warning, + }); + } + return { + name: dep.package, + SPDXID: `SPDXRef-Package-${i + 1}`, + versionInfo: dep.version, + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + ...(purl + ? { + externalRefs: [ + { + referenceCategory: 'PACKAGE-MANAGER', + referenceType: 'purl', + referenceLocator: purl, + }, + ], + } + : {}), + annotations, + }; + }), ...(relationships ? { relationships } : {}), }; } +/** Warnings for components whose purl was omitted. Same order as the SBOM rows; stable for a given artifact. */ +export function collectPurlWarnings(artifact: ScanArtifact, graph?: LockfileGraph): string[] { + return flattenDependencies(artifact, graph?.components ?? [], graph?.ecosystem).flatMap((dep) => { + const warning = resolvePurl(dep.ecosystem, dep.package, dep.version).warning; + return warning ? [warning] : []; + }); +} + function projectDependencyMap(artifact: ScanArtifact): Map { const map = new Map(); for (const project of artifact.projects) { @@ -468,7 +612,11 @@ export function formatDeltaText(base: ScanArtifact, current: ScanArtifact): stri '===================', `Baseline: ${base.timestamp}`, `Current: ${current.timestamp}`, - `DriftScore delta: ${(current.drift.score - base.drift.score).toFixed(2)} points`, + `DriftScore delta: ${ + typeof current.drift.score === 'number' && typeof base.drift.score === 'number' + ? `${(current.drift.score - base.drift.score).toFixed(2)} points` + : 'n/a' + }`, '', `Added dependencies (${added.length})`, ...added.map((d) => ` + ${d}`), @@ -517,6 +665,9 @@ const exportCommand = new Command('export') const lockfileGraph = opts.transitive ? collectLockfileGraph(artifact, path.resolve(opts.root)) : undefined; const sbom = format === 'cyclonedx' ? toCycloneDx(artifact, lockfileGraph) : toSpdx(artifact, lockfileGraph); + for (const warning of collectPurlWarnings(artifact, lockfileGraph)) { + console.error(chalk.yellow(`warning: ${warning}`)); + } const body = JSON.stringify(sbom, null, 2); if (opts.out) { diff --git a/src/reporting/commands/scan.ts b/src/reporting/commands/scan.ts index 76a4e3b..3d9b1db 100644 --- a/src/reporting/commands/scan.ts +++ b/src/reporting/commands/scan.ts @@ -18,7 +18,7 @@ import { loadConfig, findConfigFile, } from '../../core-open/index.js'; -import { evaluateConfigDriftBudget } from '../drift-budget-gate.js'; +import { compareDriftBudget, evaluateConfigDriftBudget } from '../drift-budget-gate.js'; import type { ScanOptions, ScanArtifact } from '../../core-open/index.js'; import { analyzeReachability, collectPreflightDependencies } from '../reachability.js'; import type { VgGraph } from '../../schema.js'; @@ -889,19 +889,23 @@ export const scanCommand = new Command('scan') if (!opts.quiet) console.error(chalk.dim(`\niac gate: no findings at or above ${threshold} (${securityPacksLabel(section)}).`)); } - if (scanOpts.driftBudget !== undefined && artifact.drift.score > scanOpts.driftBudget) { - console.error(chalk.red(`\nFailing fitness function: DriftScore ${artifact.drift.score}/100 exceeds budget ${scanOpts.driftBudget}.`)); - process.exit(2); + const measuredDrift = artifact.drift.score; + if (scanOpts.driftBudget !== undefined) { + const budget = compareDriftBudget(measuredDrift, scanOpts.driftBudget); + if (budget.message) { + console.error(budget.exitCode === 2 ? chalk.red(`\n${budget.message}`) : chalk.yellow(`\n${budget.message}`)); + } + if (budget.exitCode === 2) process.exit(2); } if (scanOpts.driftWorseningPercent !== undefined) { - if (artifact.delta === undefined) { + if (measuredDrift === null) { + console.error(chalk.yellow('\nDriftScore is absent; --drift-worsening was not compared.')); + } else if (artifact.delta === undefined) { console.error(chalk.red('\nFailing fitness function: --drift-worsening requires --baseline to compare against previous drift.')); process.exit(2); - } - - if (artifact.delta > 0) { - const baselineScore = artifact.drift.score - artifact.delta; + } else if (artifact.delta > 0) { + const baselineScore = measuredDrift - artifact.delta; const denominator = Math.max(Math.abs(baselineScore), 0.0001); const worseningPercent = (artifact.delta / denominator) * 100; @@ -916,18 +920,24 @@ export const scanCommand = new Command('scan') // so an explicit flag keeps its exact historic meaning. if (scanOpts.driftBudget === undefined && scanOpts.driftWorseningPercent === undefined) { const projectConfig = await loadConfig(rootDir); - const gate = evaluateConfigDriftBudget({ - raw: projectConfig.driftBudget, - configFile: findConfigFile(rootDir), - headScore: artifact.drift.score, - baseScore: artifact.delta === undefined ? null : artifact.drift.score - artifact.delta, - }); - for (const line of gate.lines) { - if (line.level === 'error') console.error(chalk.red(line.text)); - else if (line.level === 'warn') console.error(chalk.yellow(line.text)); - else if (!opts.quiet) console.error(chalk.dim(line.text)); + if (measuredDrift === null) { + if (projectConfig.driftBudget !== undefined && projectConfig.driftBudget !== null) { + console.error(chalk.yellow('\nDriftScore is absent; the project drift budget was not compared.')); + } + } else { + const gate = evaluateConfigDriftBudget({ + raw: projectConfig.driftBudget, + configFile: findConfigFile(rootDir), + headScore: measuredDrift, + baseScore: artifact.delta === undefined ? null : measuredDrift - artifact.delta, + }); + for (const line of gate.lines) { + if (line.level === 'error') console.error(chalk.red(line.text)); + else if (line.level === 'warn') console.error(chalk.yellow(line.text)); + else if (!opts.quiet) console.error(chalk.dim(line.text)); + } + if (gate.exitCode === 2) process.exit(2); } - if (gate.exitCode === 2) process.exit(2); } // Reachability hand-off (before push): post the dependency coordinates the diff --git a/src/reporting/drift-budget-gate.test.ts b/src/reporting/drift-budget-gate.test.ts index 541f6f0..3b7f523 100644 --- a/src/reporting/drift-budget-gate.test.ts +++ b/src/reporting/drift-budget-gate.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { evaluateConfigDriftBudget } from './drift-budget-gate.js'; +import { compareDriftBudget, evaluateConfigDriftBudget } from './drift-budget-gate.js'; const file = '.vibgrate/config.yml'; @@ -53,3 +53,24 @@ describe('evaluateConfigDriftBudget', () => { ]); }); }); + +describe('compareDriftBudget', () => { + it('does not fail when the score is absent', () => { + expect(compareDriftBudget(null, 0)).toEqual({ + exitCode: 0, + message: 'DriftScore is absent; --drift-budget 0 was not compared.', + }); + }); + + it('passes a measured zero without treating it as absent', () => { + expect(compareDriftBudget(0, 0)).toEqual({ exitCode: 0, message: null }); + expect(compareDriftBudget(0, 30)).toEqual({ exitCode: 0, message: null }); + }); + + it('fails when a measured score is above the budget', () => { + expect(compareDriftBudget(44, 40)).toEqual({ + exitCode: 2, + message: 'Failing fitness function: DriftScore 44/100 exceeds budget 40.', + }); + }); +}); diff --git a/src/reporting/drift-budget-gate.ts b/src/reporting/drift-budget-gate.ts index 5b1497b..f326d78 100644 --- a/src/reporting/drift-budget-gate.ts +++ b/src/reporting/drift-budget-gate.ts @@ -30,6 +30,31 @@ export interface DriftBudgetGateResult { verdict: DriftBudgetVerdict | null; } +/** + * Compare a DriftScore to `--drift-budget`. + * + * A null score is unmeasured. It does not fail the budget, and it is not + * treated as 0. A measured 0 is a real score and is compared as usual. + */ +export function compareDriftBudget( + score: number | null, + budget: number, +): { exitCode: 0 | 2; message: string | null } { + if (score === null) { + return { + exitCode: 0, + message: `DriftScore is absent; --drift-budget ${budget} was not compared.`, + }; + } + if (score > budget) { + return { + exitCode: 2, + message: `Failing fitness function: DriftScore ${score}/100 exceeds budget ${budget}.`, + }; + } + return { exitCode: 0, message: null }; +} + export function evaluateConfigDriftBudget(input: DriftBudgetGateInput): DriftBudgetGateResult { const source = input.configFile ?? 'the project config'; const parsed = parseDriftBudget(input.raw); diff --git a/src/reporting/formatters/formatters.test.ts b/src/reporting/formatters/formatters.test.ts index 9019427..573ef26 100644 --- a/src/reporting/formatters/formatters.test.ts +++ b/src/reporting/formatters/formatters.test.ts @@ -169,6 +169,43 @@ describe('formatMarkdown', () => { expect(md).toContain('| Frameworks | 80 |'); }); + it('renders an absent DriftScore as n/a, not 0', () => { + const md = formatMarkdown(makeArtifact({ + drift: { + score: null, + riskLevel: null, + components: { + runtimeScore: null, + frameworkScore: null, + dependencyScore: null, + eolScore: null, + }, + }, + })); + expect(md).toContain('n/a'); + expect(md).not.toContain('0/100'); + expect(md).not.toContain('| Runtime | 0 |'); + expect(md).toContain('| Runtime | n/a |'); + }); + + it('renders a measured zero DriftScore as 0', () => { + const md = formatMarkdown(makeArtifact({ + drift: { + score: 0, + riskLevel: 'low', + components: { + runtimeScore: 0, + frameworkScore: 0, + dependencyScore: 0, + eolScore: 0, + }, + }, + })); + expect(md).toContain('0/100'); + expect(md).toContain('| Runtime | 0 |'); + expect(md).toContain('LOW'); + }); + it('includes per-project details', () => { const md = formatMarkdown(makeArtifact()); expect(md).toContain('### my-app (node)'); @@ -331,6 +368,43 @@ describe('formatText', () => { expect(text).toContain('65/100'); }); + it('renders an absent DriftScore as n/a, not 0/100', () => { + const text = formatText(makeArtifact({ + projects: [], + findings: [], + drift: { + score: null, + riskLevel: null, + components: { + runtimeScore: null, + frameworkScore: null, + dependencyScore: null, + eolScore: null, + }, + }, + })); + expect(text).toContain('n/a'); + expect(text).not.toContain('0/100'); + expect(text).not.toContain('LOW'); + }); + + it('renders a measured zero DriftScore as 0/100', () => { + const text = formatText(makeArtifact({ + drift: { + score: 0, + riskLevel: 'low', + components: { + runtimeScore: 0, + frameworkScore: 0, + dependencyScore: 0, + eolScore: 0, + }, + }, + })); + expect(text).toContain('0/100'); + expect(text).toContain('LOW'); + }); + it('includes project count', () => { const text = formatText(makeArtifact()); expect(text).toContain('1'); diff --git a/src/reporting/formatters/markdown.ts b/src/reporting/formatters/markdown.ts index 5778589..0b6cb49 100644 --- a/src/reporting/formatters/markdown.ts +++ b/src/reporting/formatters/markdown.ts @@ -1,5 +1,10 @@ import type { ScanArtifact } from '../types.js'; +/** A measured zero stays `0`. An unmeasured component is `n/a`, never `0`. */ +function markdownDriftCell(score: number | null): string { + return score === null ? 'n/a' : String(score); +} + /** Generate a Markdown report from scan artifact */ export function formatMarkdown(artifact: ScanArtifact): string { const lines: string[] = []; @@ -8,8 +13,8 @@ export function formatMarkdown(artifact: ScanArtifact): string { lines.push(''); lines.push(`| Metric | Value |`); lines.push(`|--------|-------|`); - lines.push(`| **DriftScore** | ${artifact.drift.score}/100 _(lower is better; 0 = no drift)_ |`); - lines.push(`| **Risk Level** | ${artifact.drift.riskLevel.toUpperCase()} |`); + lines.push(`| **DriftScore** | ${artifact.drift.score === null ? 'n/a' : `${artifact.drift.score}/100 _(lower is better; 0 = no drift)_`} |`); + lines.push(`| **Risk Level** | ${artifact.drift.riskLevel ? artifact.drift.riskLevel.toUpperCase() : 'n/a'} |`); lines.push(`| **Projects** | ${artifact.projects.length} |`); const scannedMeta: string[] = [artifact.timestamp]; if (artifact.durationMs !== undefined) scannedMeta.push(`${(artifact.durationMs / 1000).toFixed(1)}s`); @@ -28,10 +33,10 @@ export function formatMarkdown(artifact: ScanArtifact): string { lines.push(''); lines.push(`| Component | Score |`); lines.push(`|-----------|-------|`); - lines.push(`| Runtime | ${artifact.drift.components.runtimeScore} |`); - lines.push(`| Frameworks | ${artifact.drift.components.frameworkScore} |`); - lines.push(`| Dependencies | ${artifact.drift.components.dependencyScore} |`); - lines.push(`| EOL Risk | ${artifact.drift.components.eolScore} |`); + lines.push(`| Runtime | ${markdownDriftCell(artifact.drift.components.runtimeScore)} |`); + lines.push(`| Frameworks | ${markdownDriftCell(artifact.drift.components.frameworkScore)} |`); + lines.push(`| Dependencies | ${markdownDriftCell(artifact.drift.components.dependencyScore)} |`); + lines.push(`| EOL Risk | ${markdownDriftCell(artifact.drift.components.eolScore)} |`); lines.push(''); // Per project diff --git a/src/reporting/formatters/text.ts b/src/reporting/formatters/text.ts index 5275e34..41c7282 100644 --- a/src/reporting/formatters/text.ts +++ b/src/reporting/formatters/text.ts @@ -128,13 +128,11 @@ export function formatText(artifact: ScanArtifact): string { } // Score summary — drift score is lower-is-better (0 = no drift). - const scoreColor = artifact.drift.score <= 30 ? chalk.green : - artifact.drift.score <= 60 ? chalk.yellow : chalk.red; - + // A null score is unmeasured, not a perfect 0. lines.push(...titleBox('DriftScore Summary')); lines.push(''); - lines.push(chalk.bold(' DriftScore: ') + scoreColor.bold(`${artifact.drift.score}/100`)); - lines.push(chalk.bold(' Risk Level: ') + riskBadge(artifact.drift.riskLevel)); + lines.push(chalk.bold(' DriftScore: ') + formatHeadlineScore(artifact.drift.score)); + lines.push(chalk.bold(' Risk Level: ') + formatHeadlineRisk(artifact.drift.riskLevel)); lines.push(chalk.bold(' Projects: ') + `${artifact.projects.length}`); if (artifact.vcs) { @@ -146,13 +144,12 @@ export function formatText(artifact: ScanArtifact): string { lines.push(''); - // Score breakdown - const m = new Set(artifact.drift.measured ?? ['runtime', 'framework', 'dependency', 'eol']); + // Score breakdown. Null components render as n/a; a measured 0 still renders as 0. lines.push(' ' + chalk.bold.underline('Score Breakdown')); - lines.push(` Runtime: ${m.has('runtime') ? scoreBar(artifact.drift.components.runtimeScore) : chalk.dim('n/a')}`); - lines.push(` Frameworks: ${m.has('framework') ? scoreBar(artifact.drift.components.frameworkScore) : chalk.dim('n/a')}`); - lines.push(` Dependencies: ${m.has('dependency') ? scoreBar(artifact.drift.components.dependencyScore) : chalk.dim('n/a')}`); - lines.push(` EOL Risk: ${m.has('eol') ? scoreBar(artifact.drift.components.eolScore) : chalk.dim('n/a')}`); + lines.push(` Runtime: ${formatComponentScore(artifact.drift.components.runtimeScore)}`); + lines.push(` Frameworks: ${formatComponentScore(artifact.drift.components.frameworkScore)}`); + lines.push(` Dependencies: ${formatComponentScore(artifact.drift.components.dependencyScore)}`); + lines.push(` EOL Risk: ${formatComponentScore(artifact.drift.components.eolScore)}`); lines.push(''); const scannedParts: string[] = [`Scanned at ${artifact.timestamp}`]; @@ -182,6 +179,21 @@ function riskBadge(level: string): string { } } +function formatHeadlineScore(score: number | null): string { + if (score === null) return chalk.dim('n/a'); + const scoreColor = score <= 30 ? chalk.green : score <= 60 ? chalk.yellow : chalk.red; + return scoreColor.bold(`${score}/100`); +} + +function formatHeadlineRisk(level: string | null): string { + if (!level) return chalk.dim('n/a'); + return riskBadge(level); +} + +function formatComponentScore(score: number | null): string { + return score === null ? chalk.dim('n/a') : scoreBar(score); +} + function scoreBar(score: number): string { // Sub-cell gradient fill (green → the score's own risk colour) for a smoother read. return driftBar(score, 20); diff --git a/src/reporting/planning/expected-drift.test.ts b/src/reporting/planning/expected-drift.test.ts index ce7e1c8..2bc2d60 100644 --- a/src/reporting/planning/expected-drift.test.ts +++ b/src/reporting/planning/expected-drift.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; import { estimateDriftScore } from './expected-drift.js'; + +function measured(score: number | null): number { + if (score === null) throw new Error('expected a measured DriftScore'); + return score; +} import type { ScanArtifact } from '../../core-open/index.js'; /** Build a minimal artifact with one node project's dependency age buckets. */ @@ -44,8 +49,8 @@ describe('estimateDriftScore', () => { const before = estimateDriftScore(a, new Set()); const afterOne = estimateDriftScore(a, new Set(['b'])); // moves 1 out of oneBehind const afterAll = estimateDriftScore(a, new Set(['a', 'b'])); - expect(afterOne).toBeLessThanOrEqual(before); - expect(afterAll).toBeLessThanOrEqual(afterOne); + expect(afterOne).toBeLessThanOrEqual(measured(before)); + expect(afterAll).toBeLessThanOrEqual(measured(afterOne)); }); it('does not mutate the input artifact', () => { diff --git a/src/reporting/planning/expected-drift.ts b/src/reporting/planning/expected-drift.ts index 63fa322..d3eb2e9 100644 --- a/src/reporting/planning/expected-drift.ts +++ b/src/reporting/planning/expected-drift.ts @@ -21,7 +21,7 @@ import type { ScanArtifact, ProjectScan } from '../../core-open/index.js'; */ /** Recompute the DriftScore assuming every package named in `upgraded` lands at latest. */ -export function estimateDriftScore(artifact: ScanArtifact, upgraded: Set): number { +export function estimateDriftScore(artifact: ScanArtifact, upgraded: Set): number | null { const projects: ProjectScan[] = JSON.parse(JSON.stringify(artifact.projects ?? [])); for (const p of projects) { const buckets = p.dependencyAgeBuckets; diff --git a/src/reporting/scoring/drift-score.test.ts b/src/reporting/scoring/drift-score.test.ts index 043d2a4..dcb20bb 100644 --- a/src/reporting/scoring/drift-score.test.ts +++ b/src/reporting/scoring/drift-score.test.ts @@ -1,7 +1,17 @@ import { describe, it, expect } from 'vitest'; import { computeDriftScore, generateFindings, computeProjectId } from '../scoring/drift-score.js'; +import { computeDriftScore as computeLiveDriftScore } from '../../core-open/scoring/drift-score.js'; +import { formatMarkdown as formatLiveMarkdown } from '../../core-open/formatters/markdown.js'; +import { formatText as formatLiveText } from '../../core-open/formatters/text.js'; +import type { ProjectScan as LiveProjectScan, ScanArtifact as LiveScanArtifact } from '../../core-open/types.js'; import type { ProjectScan, VibgrateConfig } from '../types.js'; +/** Narrow a score the fixture measured. Null here is a test bug, not absence. */ +function measured(score: number | null): number { + if (score === null) throw new Error('expected a measured DriftScore'); + return score; +} + // ── Helpers ── function makeNodeProject(overrides: Partial = {}): ProjectScan { @@ -48,14 +58,54 @@ describe('computeDriftScore', () => { expect(result.riskLevel).toBe('low'); }); - it('returns 0 drift for empty projects array', () => { + it('returns null, not 0, when no project was scanned', () => { const result = computeDriftScore([]); + expect(result.score).toBeNull(); + expect(result.riskLevel).toBeNull(); + expect(result.measured).toEqual([]); + expect(result.components.runtimeScore).toBeNull(); + expect(result.components.frameworkScore).toBeNull(); + expect(result.components.dependencyScore).toBeNull(); + expect(result.components.eolScore).toBeNull(); + const serialized = JSON.parse(JSON.stringify(result)) as { score: unknown; components: { runtimeScore: unknown } }; + expect(serialized.score).toBeNull(); + expect(serialized.components.runtimeScore).toBeNull(); + }); + + it('returns null, not 0, for a project with no runtime and empty dependency buckets', () => { + const project = makeNodeProject({ + runtime: undefined, + runtimeMajorsBehind: undefined, + frameworks: [], + dependencies: [], + dependencyAgeBuckets: { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, + }); + const result = computeDriftScore([project]); + expect(result.score).toBeNull(); + expect(result.riskLevel).toBeNull(); + expect(result.components.runtimeScore).toBeNull(); + expect(result.components.frameworkScore).toBeNull(); + expect(result.components.dependencyScore).toBeNull(); + expect(result.components.eolScore).toBeNull(); + expect(JSON.stringify(result)).not.toContain('"score":0'); + }); + + it('keeps a measured zero when runtime, framework, and dependencies are current', () => { + const project = makeNodeProject({ + runtimeMajorsBehind: 0, + frameworks: [ + { name: 'React', currentVersion: '19.0.0', latestVersion: '19.0.0', majorsBehind: 0 }, + ], + dependencyAgeBuckets: { current: 4, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, + }); + const result = computeDriftScore([project]); expect(result.score).toBe(0); expect(result.riskLevel).toBe('low'); expect(result.components.runtimeScore).toBe(0); expect(result.components.frameworkScore).toBe(0); expect(result.components.dependencyScore).toBe(0); expect(result.components.eolScore).toBe(0); + expect(JSON.parse(JSON.stringify(result)).components.runtimeScore).toBe(0); }); it('penalises runtime 1 major behind', () => { @@ -82,19 +132,21 @@ describe('computeDriftScore', () => { expect(result.components.runtimeScore).toBe(100); }); - it('returns runtimeScore 0 (no drift) when no runtime info', () => { + it('leaves runtimeScore null when no runtime info was measured', () => { const project = makeNodeProject({ runtimeMajorsBehind: undefined, runtime: undefined, }); const result = computeDriftScore([project]); - expect(result.components.runtimeScore).toBe(0); + expect(result.components.runtimeScore).toBeNull(); + expect(result.measured ?? []).not.toContain('runtime'); }); - it('computes frameworkScore 0 (no drift) when no frameworks', () => { + it('leaves frameworkScore null when no frameworks were measured', () => { const project = makeNodeProject({ frameworks: [] }); const result = computeDriftScore([project]); - expect(result.components.frameworkScore).toBe(0); + expect(result.components.frameworkScore).toBeNull(); + expect(result.measured ?? []).not.toContain('framework'); }); it('penalises frameworks with major lag', () => { @@ -114,7 +166,7 @@ describe('computeDriftScore', () => { ], }); const result = computeDriftScore([project]); - expect(result.components.frameworkScore).toBe(0); + expect(result.components.frameworkScore).toBeNull(); }); it('computes dependencyScore 0 (no drift) when all current', () => { @@ -133,12 +185,14 @@ describe('computeDriftScore', () => { expect(result.components.dependencyScore).toBeGreaterThan(50); }); - it('dependencyScore 0 (no drift) when no deps at all', () => { + it('leaves dependencyScore null when there are no dependencies', () => { const project = makeNodeProject({ + dependencies: [], dependencyAgeBuckets: { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, }); const result = computeDriftScore([project]); - expect(result.components.dependencyScore).toBe(0); + expect(result.components.dependencyScore).toBeNull(); + expect(result.measured ?? []).not.toContain('dependency'); }); it('eolScore penalises node 2 majors behind', () => { @@ -454,8 +508,8 @@ describe('per-project drift scores', () => { expect(score2.score).toBeGreaterThan(70); // Aggregate should be between the two (pulled up by p2's drift) - expect(aggregate.score).toBeLessThan(score2.score); - expect(aggregate.score).toBeGreaterThan(score1.score); + expect(aggregate.score).toBeLessThan(measured(score2.score)); + expect(aggregate.score).toBeGreaterThan(measured(score1.score)); }); it('individual project score matches single-project aggregate', () => { @@ -474,3 +528,95 @@ describe('per-project drift scores', () => { expect(singleProjectScore.components).toBeDefined(); }); }); + +// The scan command scores through the vendored engine, not the reporting copy above. +describe('computeDriftScore (scan engine)', () => { + function unscoredProject(): LiveProjectScan { + return { + type: 'node', + path: '/test/empty', + name: 'empty', + frameworks: [], + dependencies: [], + dependencyAgeBuckets: { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, + }; + } + + function liveArtifact(drift: LiveScanArtifact['drift'], projects: LiveProjectScan[] = []): LiveScanArtifact { + return { + schemaVersion: '1.0', + timestamp: '2026-02-16T00:00:00.000Z', + vibgrateVersion: '0.0.0', + rootPath: '/test', + projects, + drift, + findings: [], + }; + } + + it('serializes an empty scan as null, not 0', () => { + const result = computeLiveDriftScore([]); + expect(result.score).toBeNull(); + expect(result.riskLevel).toBeNull(); + expect(result.components).toEqual({ + runtimeScore: null, + frameworkScore: null, + dependencyScore: null, + eolScore: null, + }); + const json = JSON.stringify(result); + expect(json).toContain('"score":null'); + expect(json).not.toContain('"score":0'); + }); + + it('serializes a project with no runtime and empty dependency buckets as null', () => { + const result = computeLiveDriftScore([unscoredProject()]); + expect(result.score).toBeNull(); + expect(result.components.runtimeScore).toBeNull(); + expect(result.components.dependencyScore).toBeNull(); + expect(result.components.eolScore).toBeNull(); + expect(result.components.frameworkScore).toBeNull(); + }); + + it('keeps a measured runtime zero distinct from an absent runtime', () => { + const measured = computeLiveDriftScore([{ ...unscoredProject(), runtimeMajorsBehind: 0 }]); + const absent = computeLiveDriftScore([unscoredProject()]); + expect(measured.components.runtimeScore).toBe(0); + expect(absent.components.runtimeScore).toBeNull(); + // Lag of 4 or more is a measured health of 0, inverted to drift 100 — not null. + const lagged = computeLiveDriftScore([{ ...unscoredProject(), runtimeMajorsBehind: 5 }]); + expect(lagged.components.runtimeScore).toBe(100); + }); + + it('renders the scan summary as n/a when the score is absent and as 0/100 when it is zero', () => { + const absent = liveArtifact(computeLiveDriftScore([unscoredProject()]), [unscoredProject()]); + const absentText = formatLiveText(absent); + const absentMd = formatLiveMarkdown(absent); + expect(absentText).toContain('n/a'); + expect(absentText).not.toContain('0/100'); + expect(absentMd).toContain('| **DriftScore** | n/a |'); + expect(absentMd).toContain('| Runtime | n/a |'); + expect(absentMd).not.toContain('0/100'); + + const current = computeLiveDriftScore([{ + ...unscoredProject(), + runtimeMajorsBehind: 0, + frameworks: [{ name: 'React', currentVersion: '19.0.0', latestVersion: '19.0.0', majorsBehind: 0 }], + dependencies: [{ + package: 'left-pad', + section: 'dependencies', + currentSpec: '1.0.0', + resolvedVersion: '1.0.0', + latestStable: '1.0.0', + majorsBehind: 0, + drift: 'current', + }], + dependencyAgeBuckets: { current: 1, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, + }]); + expect(current.score).toBe(0); + const zeroMd = formatLiveMarkdown(liveArtifact(current)); + expect(zeroMd).toContain('| **DriftScore** | 0/100 |'); + expect(zeroMd).toContain('| Runtime | 0 |'); + expect(formatLiveText(liveArtifact(current))).toContain('0/100'); + }); +}); diff --git a/src/reporting/scoring/drift-score.ts b/src/reporting/scoring/drift-score.ts index e39d70b..830db1c 100644 --- a/src/reporting/scoring/drift-score.ts +++ b/src/reporting/scoring/drift-score.ts @@ -138,21 +138,27 @@ export function computeDriftScore(projects: ProjectScan[]): DriftScore { // DriftScore v2 convention: 0 = no drift (best), 100 = maximum drift (worst). // Components are computed on a "health" scale and inverted to drift here. + // A null health value stays null: `?? 100` would invert to drift 0 and look + // like "no drift". A measured health of 0 (runtime lag of 4 or more) still + // inverts to drift 100. const toDrift = (health: number) => 100 - health; + const healthToDrift = (health: number | null): number | null => + health === null ? null : toDrift(Math.round(health)); const buildComponents = (): DriftScore['components'] => ({ - runtimeScore: toDrift(Math.round(rs ?? 100)), - frameworkScore: toDrift(Math.round(fs ?? 100)), - dependencyScore: toDrift(Math.round(ds ?? 100)), - eolScore: toDrift(Math.round(es ?? 100)), + runtimeScore: healthToDrift(rs), + frameworkScore: healthToDrift(fs), + dependencyScore: healthToDrift(ds), + eolScore: healthToDrift(es), }); const active = components.filter((c) => c.score !== null); if (active.length === 0) { - // No data at all — neutral score (no measurable drift) + // Nothing was measured. Absent is not a perfect score. return { - score: 0, - riskLevel: 'low', + score: null, + riskLevel: null, components: buildComponents(), + measured: [], methodologyVersion: DRIFT_SCORE_METHODOLOGY_VERSION, }; } diff --git a/src/reporting/types.ts b/src/reporting/types.ts index 0ac11bf..424f090 100644 --- a/src/reporting/types.ts +++ b/src/reporting/types.ts @@ -172,15 +172,17 @@ export interface DriftScore { /** * Aggregate drift score, 0–100. Lower is better: 0 = no drift, 100 = maximum drift. * Risk bands: 0–30 = low, 31–60 = moderate, 61–100 = high. + * `null` means the score was not measured. A missing score is never stored as 0. */ - score: number; - riskLevel: RiskLevel; - /** Per-component drift scores (0 = no drift, 100 = maximum drift). */ + score: number | null; + /** `null` when `score` was not measured. */ + riskLevel: RiskLevel | null; + /** Per-component drift scores (0 = no drift, 100 = maximum drift). `null` means that component had no input. */ components: { - runtimeScore: number; - frameworkScore: number; - dependencyScore: number; - eolScore: number; + runtimeScore: number | null; + frameworkScore: number | null; + dependencyScore: number | null; + eolScore: number | null; }; /** Which components had sufficient data to score. Missing = no data available. */ measured?: ('runtime' | 'framework' | 'dependency' | 'eol')[]; diff --git a/src/reporting/utils/ingest-id-output.test.ts b/src/reporting/utils/ingest-id-output.test.ts index e134b32..5f69fb7 100644 --- a/src/reporting/utils/ingest-id-output.test.ts +++ b/src/reporting/utils/ingest-id-output.test.ts @@ -37,4 +37,13 @@ describe('emitDriftScoreLine', () => { emitDriftScoreLine(42); expect(log).toHaveBeenCalledWith('VIBGRATE_DRIFT_SCORE=42'); }); + + it('emits null for an absent score and 0 for a measured zero', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + process.env.VIBGRATE_EMIT_MARKERS = '1'; + emitDriftScoreLine(null); + emitDriftScoreLine(0); + expect(log).toHaveBeenNthCalledWith(1, 'VIBGRATE_DRIFT_SCORE=null'); + expect(log).toHaveBeenNthCalledWith(2, 'VIBGRATE_DRIFT_SCORE=0'); + }); }); diff --git a/src/reporting/utils/ingest-id-output.ts b/src/reporting/utils/ingest-id-output.ts index 0c2ee3f..d8b9a32 100644 --- a/src/reporting/utils/ingest-id-output.ts +++ b/src/reporting/utils/ingest-id-output.ts @@ -13,7 +13,9 @@ export function emitIngestIdLine(ingestId: string, options?: { unchanged?: boole * (VIBGRATE_EMIT_MARKERS=1, set by the migration agent) so normal CLI output is * unchanged. */ -export function emitDriftScoreLine(score: number): void { +export function emitDriftScoreLine(score: number | null): void { if (process.env.VIBGRATE_EMIT_MARKERS !== '1') return; - console.log(`VIBGRATE_DRIFT_SCORE=${score}`); + // `null` is the machine-readable form of an unmeasured score. A measured 0 + // stays `0`; never collapse the two. + console.log(`VIBGRATE_DRIFT_SCORE=${score === null ? 'null' : score}`); } diff --git a/src/review/explain-doc.test.ts b/src/review/explain-doc.test.ts index 66c9a28..fd7ea66 100644 --- a/src/review/explain-doc.test.ts +++ b/src/review/explain-doc.test.ts @@ -5,7 +5,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { GraphEdge, GraphNode, VgGraph } from '../schema.js'; import type { HaileProvider } from '../engine/haile/haile-provider.js'; import { renderReviewDocMarkdown, validateReviewDoc } from './doc.js'; -import { buildExplainDoc, explainChange } from './explain-doc.js'; +import { buildExplainDoc, buildPathDoc, ExplainEmpty, explainChange } from './explain-doc.js'; +import { callPath } from '../engine/paths.js'; import type { GitRunner } from './git.js'; /** @@ -142,3 +143,68 @@ describe('document kind', () => { expect(validateReviewDoc({ ...doc, kind: 'scratch' }).map((i) => i.code)).toEqual(['enum']); }); }); + +describe('the flow-only view (vg show flow)', () => { + const flow = { + type: 'flow', + title: 'What save does', + nodes: [{ key: 's', label: 'persist Order', pins: [{ side: 'head', path: 'src/store.ts', start: 45, end: 45 }], origin: 'graph' }], + edges: [], + }; + const withFlow = { ...provider({}), reviewDiagrams: () => ({ blocks: [stackBlock, flow], contract: [], notes: [] }) } as unknown as HaileProvider; + + it('keeps only the flows', () => { + const { doc } = buildExplainDoc({ root, graph, node: save, provider: withFlow, run: noGit, only: ['flow'] }); + const design = doc.sections.find((x) => x.kind === 'design'); + expect(design?.blocks.map((b) => b.type)).toEqual(['flow']); + }); + + it('says so when the code map has no flow for the symbol', () => { + expect(() => buildExplainDoc({ root, graph, node: save, provider: provider({}), run: noGit, only: ['flow'] })).toThrow(ExplainEmpty); + }); +}); + +describe('the path view (vg path --diagram)', () => { + const sited = { + ...graph, + edges: [ + { ...edge('main', 'save'), sites: [7], awaited: true }, + { ...edge('save', 'audit'), sites: [52] }, + ], + } as unknown as VgGraph; + + it('draws the call path caller first, each frame and hop pinned', () => { + const found = callPath(sited, 'main', 'audit'); + expect(found).not.toBeNull(); + const { doc, resolve } = buildPathDoc({ root, graph: sited, path: found!, callsOnly: true, run: noGit }); + expect(doc.kind).toBe('explain'); + expect(doc.title).toBe('Path: main → audit'); + expect(validateReviewDoc(doc, resolve)).toEqual([]); + const stack = doc.sections.find((x) => x.kind === 'design')?.blocks[0] as { head: { key: string; parent_key?: string; via?: { kind: string }; call_site?: unknown }[] }; + expect(stack.head.map((f) => [f.key, f.parent_key ?? null, f.via?.kind ?? null])).toEqual([ + ['main', null, null], + ['save', 'main', 'async'], + ['audit', 'save', 'call'], + ]); + expect(stack.head[1]!.call_site).toEqual({ side: 'head', path: 'src/app.ts', start: 7, end: 7 }); + const md = renderReviewDocMarkdown(doc); + expect(md).toContain('2 hops, following calls only'); + expect(md).toContain('awaited call at line 7 (`src/app.ts:7`)'); + expect(md).not.toContain('| Before | After |'); + }); + + it('draws a reverse path in the order it runs', () => { + const { doc } = buildPathDoc({ root, graph: sited, path: { ids: ['audit', 'save', 'main'], direction: 'reverse' }, callsOnly: true, run: noGit }); + expect(doc.title).toBe('Path: main → audit'); + }); + + it('leaves out a step with no code to pin, and notes it', () => { + const ext = { + ...sited, + nodes: [...sited.nodes, node('lib', { file: 'node_modules/lib/index.js', span: { start: 1, end: 3 } })], + edges: [...sited.edges, edge('audit', 'lib')], + } as unknown as VgGraph; + const { doc } = buildPathDoc({ root, graph: ext, path: callPath(ext, 'main', 'lib')!, callsOnly: true, run: noGit }); + expect(doc.generator.notes.join(' ')).toContain('with no code in the working tree to pin: lib'); + }); +}); diff --git a/src/review/explain-doc.ts b/src/review/explain-doc.ts index 69605c6..0b1348e 100644 --- a/src/review/explain-doc.ts +++ b/src/review/explain-doc.ts @@ -17,6 +17,7 @@ import { overviewOf } from '../engine/chart/server.js'; import { readHaileSidecar } from '../engine/haile/sidecar.js'; import type { HaileProvider } from '../engine/haile/haile-provider.js'; import { indexFor } from '../engine/relations.js'; +import { describeHops, type PathResult } from '../engine/paths.js'; import type { GraphNode, VgGraph } from '../schema.js'; import { readDataModels } from './data-models.js'; import { deriveDiagrams, mapPrefix, rolesOf } from './derive.js'; @@ -31,11 +32,16 @@ import { withIds, type DocBlock, type DocSection, + type Pin, type PinResolver, type ReviewDoc, + type StackFrame, } from './doc.js'; import { defaultRun, gitTopLevel, isGitRepo, normalizeRemote, repoKey, type ChangeSet, type GitRunner } from './git.js'; +/** Thrown when a narrowed explain view has nothing to show; the message says why. */ +export class ExplainEmpty extends Error {} + /** Callers and callees listed under implementation, each. */ export const MAX_LISTED = 12; @@ -47,6 +53,8 @@ export interface ExplainOptions { graphPath?: string; provider: HaileProvider | null; run?: GitRunner; + /** Keep only these diagram types in "How it works" (`vg show flow` keeps flows). */ + only?: DocBlock['type'][]; } export interface BuiltExplainDoc { @@ -142,7 +150,13 @@ export function buildExplainDoc(o: ExplainOptions): BuiltExplainDoc { const impl = [list('Called by', callers), list('Calls', callees)].filter((b): b is DocBlock => b !== null); const sections: DocSection[] = [{ kind: 'what_why', title: 'What it is', blocks: withIds([{ type: 'markdown', text: what.join('\n') }]) }]; - if (design.blocks.length > 0) sections.push({ kind: 'design', title: 'How it works', blocks: withIds(design.blocks) }); + // Narrowed to some types, the first kept diagram leads when the primary was dropped. + const kept = o.only ? design.blocks.filter((b) => o.only!.includes(b.type)) : design.blocks; + const diagrams = kept.some((b) => (b as { primary?: boolean }).primary === true) ? kept : kept.map((b, i) => (i === 0 ? ({ ...b, primary: true } as DocBlock) : b)); + if (o.only && diagrams.length === 0) { + throw new ExplainEmpty(`no ${o.only.join(' or ')} diagram for ${node.qualifiedName}: the code map records no steps for it — \`vg show ${node.qualifiedName} --diagram\` shows what there is`); + } + if (diagrams.length > 0) sections.push({ kind: 'design', title: 'How it works', blocks: withIds(diagrams) }); if (impl.length > 0) sections.push({ kind: 'implementation', title: 'Callers and callees', blocks: withIds(impl) }); const notes = ['explains the code as it is in the working tree; nothing here is a change', ...design.notes]; @@ -167,3 +181,104 @@ export function buildExplainDoc(o: ExplainOptions): BuiltExplainDoc { } return { doc, resolve }; } + +export interface PathDocOptions { + root: string; + graph: VgGraph; + path: PathResult; + /** Whether the path follows call edges only (`vg path --calls`). */ + callsOnly: boolean; + run?: GitRunner; +} + +/** + * The explain view of a path: how one piece of code reaches another, drawn as + * one call path, caller first, each frame pinned to its declaration and each + * hop to the line that makes it. Graph facts only: the path is the one + * `vg path` finds, and nothing is judged. + */ +export function buildPathDoc(o: PathDocOptions): BuiltExplainDoc { + const { root, graph } = o; + const run = o.run ?? defaultRun; + const byId = new Map(graph.nodes.map((n) => [n.id, n] as const)); + // A reverse path was found from B back to A; draw it in the order it runs. + const ids = o.path.direction === 'forward' ? o.path.ids : [...o.path.ids].reverse(); + const nodes = ids.map((id) => byId.get(id)).filter((n): n is GraphNode => n !== undefined); + if (nodes.length !== ids.length || nodes.length < 2) throw new Error('internal: the path names a node that is not in the code map'); + const first = nodes[0]!; + const last = nodes[nodes.length - 1]!; + const change = explainChange(root, first, run); + const resolve = makePinResolver(change, { inPlace: true }, run); + const prefix = mapPrefix(change, root); + const repoPath = (file: string) => (prefix ? `${prefix}/${file.replace(/\\/g, '/')}` : file.replace(/\\/g, '/')); + /** A pin for lines of a file, or null when it does not land in the working tree. */ + const pinOf = (file: string | undefined, start: number, end: number): Pin | null => { + if (!file) return null; + const p = repoPath(file); + const lines = resolve('head', p); + const e = Math.max(start, end); + return lines !== null && start >= 1 && e <= lines ? { side: 'head', path: p, start, end: e } : null; + }; + const hops = describeHops(graph, ids, 'forward'); + + const frames: StackFrame[] = []; + const unpinned: string[] = []; + nodes.forEach((n, i) => { + const pin = pinOf(n.file, n.span.start, n.span.end); + if (!pin) { + unpinned.push(n.qualifiedName); + return; + } + const hop = i > 0 ? hops[i - 1] : undefined; + const frame: StackFrame = { key: n.id, label: n.qualifiedName, pin }; + if (frames.length > 0) frame.parent_key = frames[frames.length - 1]!.key; + if (hop?.kind === 'call') frame.via = { kind: hop.awaited ? 'async' : 'call' }; + const site = hop?.line ? pinOf(hop.file, hop.line, hop.line) : null; + if (site) frame.call_site = site; + frames.push(frame); + }); + if (frames.length === 0) throw new ExplainEmpty('no step of this path has code in the working tree to pin, so there is nothing to draw'); + + const name = (n: GraphNode) => { + const pin = pinOf(n.file, n.span.start, n.span.end); + const label = n.qualifiedName.replace(/[[\]`]/g, ''); + return pin ? `[${label}](${pinLink(pin)})` : `\`${n.qualifiedName.replace(/`/g, "'")}\``; + }; + const steps = hops.map((h, i) => { + const site = h.line ? pinOf(h.file, h.line, h.line) : null; + const how = [h.kind === 'call' ? (h.awaited ? 'awaited call' : 'call') : h.kind, site ? `at [line ${h.line}](${pinLink(site)})` : null] + .filter(Boolean) + .join(' '); + return `${i + 1}. ${name(nodes[i]!)} → ${name(nodes[i + 1]!)} · ${how}`; + }); + const what = [ + `How ${name(first)} reaches ${name(last)}: ${plural(hops.length, 'hop')}, ${o.callsOnly ? 'following calls only' : 'over any relation in the code map (add --calls to follow calls only)'}.`, + '', + ...steps, + ]; + + const sections: DocSection[] = [ + { kind: 'what_why', title: 'What it is', blocks: withIds([{ type: 'markdown', text: what.join('\n') }]) }, + { + kind: 'design', + title: 'How it works', + blocks: withIds([ + { type: 'call_stack_diff', title: `How ${first.qualifiedName} reaches ${last.qualifiedName}`.slice(0, 300), primary: true, base_status: 'not_computed', base: [], head: frames }, + ]), + }, + ]; + const notes = ['explains the code as it is in the working tree; nothing here is a change', `the path ${o.callsOnly ? 'follows call edges only' : 'is the shortest over any edge'}, as \`vg path\` finds it`]; + if (unpinned.length > 0) notes.push(`left out of the call path, with no code in the working tree to pin: ${unpinned.join(', ')}`); + const doc = sealReviewDoc({ + schema_version: DOC_SCHEMA, + kind: 'explain', + title: `Path: ${first.qualifiedName} → ${last.qualifiedName}`.slice(0, 300), + target: { repo_key: repoKey(change.remote, change.topLevel), base_sha: change.baseSha, head_sha: change.headSha, merge_base: null, dirty_tree_hash: null }, + sections, + groups_digest: null, + generator: { by: 'vg', notes }, + }); + const issues = validateReviewDoc(doc, resolve); + if (issues.length > 0) throw new Error(`internal: generated path document failed validation — ${issues[0].path}: ${issues[0].message}`); + return { doc, resolve }; +} diff --git a/src/review/scratchpad.test.ts b/src/review/scratchpad.test.ts new file mode 100644 index 0000000..3f0ea96 --- /dev/null +++ b/src/review/scratchpad.test.ts @@ -0,0 +1,129 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { GraphEdge, GraphNode, VgGraph } from '../schema.js'; +import { validateReviewDoc, makePinResolver } from './doc.js'; +import { buildPathDoc } from './explain-doc.js'; +import type { GitRunner } from './git.js'; +import { clearScratchpad, entriesOf, getScratchpad, keepInScratchpad, MAX_ENTRIES, patchScratchpad, scratchpadFile } from './scratchpad.js'; + +/** + * The explain scratchpad: newest on top, the same explanation kept twice + * moves rather than copies, an agent patches by id, and code that moved + * under it is reported rather than blocking. + */ + +const noGit: GitRunner = () => ({ stdout: '', status: 1 }); +const o = { run: noGit, clock: () => new Date('2026-10-03T10:00:00Z') }; + +function node(id: string, file: string, start: number, end: number): GraphNode { + return { + id, kind: 'function', name: id, qualifiedName: id, file, span: { start, end }, lang: 'ts', importance: 0.1, + centrality: { degree: 0, pagerank: 0, betweenness: 0, eigenvector: 0 }, area: 0, isHub: false, + } as GraphNode; +} +const edge = (src: string, dst: string, line: number): GraphEdge => + ({ id: `call:${src}>${dst}`, kind: 'call', src, dst, resolution: 'tsc', confidence: 1, sites: [line] }) as GraphEdge; +const graph = { + schemaVersion: 'vg-graph/1.1', + nodes: [node('main', 'src/app.ts', 1, 5), node('save', 'src/store.ts', 2, 6), node('audit', 'src/audit.ts', 1, 3)], + edges: [edge('main', 'save', 3), edge('save', 'audit', 4)], + areas: [{ id: 0, label: 'app' }], +} as unknown as VgGraph; + +let root: string; +const pathDoc = (a: string, b: string) => { + const ids = a === 'main' && b === 'audit' ? ['main', 'save', 'audit'] : a === 'main' ? ['main', 'save'] : ['save', 'audit']; + return buildPathDoc({ root, graph, path: { ids, direction: 'forward' }, callsOnly: true, run: noGit }).doc; +}; +const lines = (n: number) => Array.from({ length: n }, (_, i) => `// ${i + 1}`).join('\n') + '\n'; + +beforeEach(() => { + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'vg-scratch-'))); + fs.mkdirSync(path.join(root, 'src')); + fs.writeFileSync(path.join(root, 'src/app.ts'), lines(10)); + fs.writeFileSync(path.join(root, 'src/store.ts'), lines(10)); + fs.writeFileSync(path.join(root, 'src/audit.ts'), lines(5)); +}); +afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + +const headings = (root_: string) => + entriesOf(getScratchpad(root_, o).doc!.sections.flatMap((s) => s.blocks)).map((e) => (e[0] as { text: string }).text); + +describe('keeping explanations', () => { + it('starts empty, then keeps each explanation on top, newest first, as one valid document', () => { + expect(getScratchpad(root, o)).toMatchObject({ version: 0, doc: null, stale: [] }); + keepInScratchpad(root, pathDoc('main', 'save'), o); + const pad = keepInScratchpad(root, pathDoc('save', 'audit'), o); + expect(pad.version).toBe(2); + expect(headings(root)).toEqual(['#### Path: save → audit', '#### Path: main → save']); + expect(pad.doc!.kind).toBe('explain'); + const resolve = makePinResolver({ topLevel: root, baseSha: '', headSha: '', mergeBase: null, ref: null, dirty: false, dirtyTreeHash: null, files: [], remote: null }, { inPlace: true }, noGit); + expect(validateReviewDoc(pad.doc!, resolve)).toEqual([]); + const primaries = pad.doc!.sections.flatMap((s) => s.blocks).filter((b) => (b as { primary?: boolean }).primary); + expect(primaries).toHaveLength(1); + expect(fs.existsSync(scratchpadFile(root, noGit))).toBe(true); + }); + + it('moves an explanation kept again to the top instead of copying it', () => { + keepInScratchpad(root, pathDoc('main', 'save'), o); + keepInScratchpad(root, pathDoc('save', 'audit'), o); + keepInScratchpad(root, pathDoc('main', 'save'), o); + expect(headings(root)).toEqual(['#### Path: main → save', '#### Path: save → audit']); + const ids = getScratchpad(root, o).doc!.sections.flatMap((s) => s.blocks).map((b) => b.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it(`keeps at most ${MAX_ENTRIES} entries`, () => { + for (let i = 0; i < MAX_ENTRIES + 2; i++) { + const d = pathDoc('main', 'save'); + keepInScratchpad(root, { ...d, title: `Path ${i}` }, o); + } + expect(headings(root)).toHaveLength(MAX_ENTRIES); + expect(headings(root)[0]).toBe(`#### Path ${MAX_ENTRIES + 1}`); + }); + + it('is deleted once it has not been touched for the retention period', () => { + keepInScratchpad(root, pathDoc('main', 'save'), o); + expect(getScratchpad(root, { run: noGit, clock: () => new Date('2027-10-04T10:00:00Z') }).doc).toBeNull(); + expect(fs.existsSync(scratchpadFile(root, noGit))).toBe(false); + }); +}); + +describe('patching by id', () => { + it('applies an agent patch against the version it read, and refuses a stale one', () => { + const pad = keepInScratchpad(root, pathDoc('main', 'save'), o); + const note = pad.doc!.sections[0]!.blocks.find((b) => b.type === 'markdown' && !(b as { text: string }).text.startsWith('####'))!; + const res = patchScratchpad(root, pad.version, [{ op: 'set_text', block: note.id, text: 'main saves through [save](head:src/store.ts#L2-L6).' }], o); + expect(res.ok).toBe(true); + if (!res.ok) return; + const patched = res.scratchpad.doc!.sections[0]!.blocks.find((b) => b.id === note.id) as { text: string; origin?: string }; + expect(patched).toMatchObject({ text: 'main saves through [save](head:src/store.ts#L2-L6).', origin: 'agent' }); + expect(patchScratchpad(root, pad.version, [{ op: 'remove', block: note.id }], o)).toMatchObject({ ok: false, conflict: true }); + }); + + it('refuses a patch whose pin does not land, saving nothing', () => { + const pad = keepInScratchpad(root, pathDoc('main', 'save'), o); + const res = patchScratchpad(root, pad.version, [{ op: 'insert', section: 'design', at: 'start', block: { type: 'code_peek', pin: { side: 'head', path: 'src/app.ts', start: 99, end: 120 } } }], o); + expect(res.ok).toBe(false); + expect(getScratchpad(root, o).version).toBe(pad.version); + }); + + it('reports blocks whose code moved, and still lets other blocks be patched', () => { + const pad = keepInScratchpad(root, pathDoc('main', 'save'), o); + fs.writeFileSync(path.join(root, 'src/store.ts'), lines(2)); + const now = getScratchpad(root, o); + expect(now.stale.length).toBeGreaterThan(0); + const heading = now.doc!.sections[0]!.blocks[0]!; + const res = patchScratchpad(root, pad.version, [{ op: 'set_text', block: heading.id, text: '#### Path: main → save (old)' }], o); + expect(res.ok).toBe(true); + }); + + it('clears', () => { + keepInScratchpad(root, pathDoc('main', 'save'), o); + expect(clearScratchpad(root, { run: noGit })).toBe(1); + expect(getScratchpad(root, o).doc).toBeNull(); + expect(patchScratchpad(root, 0, [{ op: 'remove', block: 'x' }], o)).toMatchObject({ ok: false }); + }); +}); diff --git a/src/review/scratchpad.ts b/src/review/scratchpad.ts new file mode 100644 index 0000000..7117ad3 --- /dev/null +++ b/src/review/scratchpad.ts @@ -0,0 +1,277 @@ +/** + * The explain scratchpad: one always-present document per repository for + * understanding code as it is, not for reviewing a change. + * + * Every explanation kept here (`vg show --diagram --keep`, + * `vg path --diagram --keep`, `review_doc` op "explain" with `keep`) + * lands on top, newest first. An entry starts with a `####` heading block and + * runs to the next one; keeping the same explanation again moves it to the + * top rather than adding a copy. An agent patches blocks by id with the same + * operations as a review document, and its words are marked `origin: agent`. + * + * It is a `vg.review.doc.v1` document with `kind: "explain"`, so the + * validator, renderers and the VS Code tab are shared. Every pin points at + * the working tree. Code moves under a scratchpad, so a block whose pins no + * longer land is reported, never silently dropped, and only new breakage + * refuses a patch. + * + * Stored in `.vibgrate/review-docs/scratchpad.json`, never committed. Same + * retention as a review document (GUARDRAILS §1.7, Repository, 365 days): a + * scratchpad not updated for 365 days is deleted the next time it is read. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { ensureVibgrateGitignore } from '../engine/artifacts.js'; +import { applyPatch, RETENTION_DAYS } from './doc-store.js'; +import { + blockId, + DOC_SCHEMA, + makePinResolver, + sealReviewDoc, + validateReviewDoc, + type DocBlock, + type DocIssue, + type PinResolver, + type ReviewDoc, +} from './doc.js'; +import { defaultRun, gitTopLevel, isGitRepo, normalizeRemote, repoKey, type ChangeSet, type GitRunner } from './git.js'; + +export const SCRATCHPAD_SCHEMA = 'vg.review.scratchpad.v1' as const; +/** The id `review_doc` uses for the scratchpad, beside `rd_…` review documents. */ +export const SCRATCHPAD_ID = 'scratchpad'; +/** Entries kept; the oldest go first. */ +export const MAX_ENTRIES = 30; + +const DIAGRAMS = new Set(['flow', 'sequence', 'call_stack_diff', 'data_store', 'system_map']); + +interface StoredScratchpad { + schema: typeof SCRATCHPAD_SCHEMA; + version: number; + updated_at: string; + /** Null when empty: a document needs at least one block. */ + doc: ReviewDoc | null; +} + +export interface Scratchpad { + doc_id: typeof SCRATCHPAD_ID; + version: number; + updated_at: string | null; + doc: ReviewDoc | null; + /** Pins that no longer land in the working tree, by block. */ + stale: { block: string; message: string }[]; +} + +export type Clock = () => Date; +const systemClock: Clock = () => new Date(); + +/** Where a repository's scratchpad lives: its top level, so every subdirectory shares one. */ +function homeOf(root: string, run: GitRunner): string { + return isGitRepo(root, run) ? gitTopLevel(root, run) : root; +} + +function fileOf(top: string): string { + return path.join(top, '.vibgrate', 'review-docs', 'scratchpad.json'); +} + +/** The working tree as a change of nothing: pins resolve against files as they are. */ +function workingTree(top: string, run: GitRunner): ChangeSet { + const git = isGitRepo(top, run); + const head = git ? run(['rev-parse', 'HEAD'], top).stdout.trim() : ''; + const remote = git ? run(['config', '--get', 'remote.origin.url'], top) : null; + const sha = head || 'working-tree'; + return { + topLevel: top, + baseSha: sha, + headSha: sha, + mergeBase: null, + ref: null, + dirty: false, + dirtyTreeHash: null, + files: [], + remote: remote && remote.status === 0 ? normalizeRemote(remote.stdout) : null, + }; +} + +function read(top: string, clock: Clock): StoredScratchpad | null { + const file = fileOf(top); + let stored: StoredScratchpad; + try { + stored = JSON.parse(fs.readFileSync(file, 'utf8')) as StoredScratchpad; + } catch { + return null; + } + if (stored?.schema !== SCRATCHPAD_SCHEMA || typeof stored.version !== 'number') return null; + if (Date.parse(stored.updated_at) < clock().getTime() - RETENTION_DAYS * 86_400_000) { + try { + fs.rmSync(file, { force: true }); + } catch { + /* the next read tries again */ + } + return null; + } + return stored; +} + +function write(top: string, stored: StoredScratchpad): void { + const file = fileOf(top); + fs.mkdirSync(path.dirname(file), { recursive: true }); + ensureVibgrateGitignore(top); + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmp, `${JSON.stringify(stored, null, 2)}\n`); + fs.renameSync(tmp, file); +} + +const isHeading = (b: DocBlock) => b.type === 'markdown' && /^#### /.test((b as { text: string }).text); +const blocksOf = (doc: ReviewDoc | null): DocBlock[] => (doc ? doc.sections.flatMap((s) => s.blocks) : []); + +/** Entries, newest first: a heading block and everything up to the next one. */ +export function entriesOf(blocks: DocBlock[]): DocBlock[][] { + const out: DocBlock[][] = []; + for (const b of blocks) { + if (isHeading(b) || out.length === 0) out.push([b]); + else out[out.length - 1]!.push(b); + } + return out; +} + +/** + * Lay blocks out as a valid explain document: one design section with + * exactly one primary diagram (the agent's choice if there is exactly one, + * else the topmost diagram), or a what-and-why section when there are no + * diagrams at all. + */ +function compose(blocks: DocBlock[], change: ChangeSet, title = 'Scratchpad'): ReviewDoc | null { + if (blocks.length === 0) return null; + const diagrams = blocks.filter((b) => DIAGRAMS.has(b.type)); + const primaries = blocks.filter((b) => (b as { primary?: boolean }).primary === true); + const keep = primaries.length === 1 && DIAGRAMS.has(primaries[0]!.type) ? primaries[0] : diagrams[0]; + const laid = blocks.map((b) => { + const { primary: _p, ...rest } = b as DocBlock & { primary?: boolean }; + return (b === keep ? { ...rest, primary: true } : rest) as DocBlock; + }); + return sealReviewDoc({ + schema_version: DOC_SCHEMA, + kind: 'explain', + title, + target: { repo_key: repoKey(change.remote, change.topLevel), base_sha: change.baseSha, head_sha: change.headSha, merge_base: null, dirty_tree_hash: null }, + sections: [{ kind: diagrams.length > 0 ? 'design' : 'what_why', title: 'Scratchpad', blocks: laid }], + groups_digest: null, + generator: { by: 'vg', notes: ['explains code as it is in the working tree, newest on top; nothing here is a change'] }, + }); +} + +/** The id of the block an issue is in, or the issue's path when it is in none. */ +function blockOfIssue(doc: ReviewDoc | null, issue: DocIssue): string { + const m = /^\$\.sections\[(\d+)\]\.blocks\[(\d+)\]/.exec(issue.path); + const block = m && doc ? doc.sections[Number(m[1])]?.blocks[Number(m[2])] : undefined; + return block?.id ?? issue.path; +} + +function staleOf(doc: ReviewDoc | null, resolve: PinResolver): { stale: { block: string; message: string }[]; issues: DocIssue[] } { + if (!doc) return { stale: [], issues: [] }; + const issues = validateReviewDoc(doc, resolve); + const byBlock = new Map(); + for (const i of issues) { + const block = blockOfIssue(doc, i); + if (!byBlock.has(block)) byBlock.set(block, i.message); + } + return { stale: [...byBlock].map(([block, message]) => ({ block, message })), issues }; +} + +/** The scratchpad as it is, with any blocks whose pins no longer land. */ +export function getScratchpad(root: string, o: { run?: GitRunner; clock?: Clock } = {}): Scratchpad { + const run = o.run ?? defaultRun; + const top = homeOf(root, run); + const stored = read(top, o.clock ?? systemClock); + if (!stored) return { doc_id: SCRATCHPAD_ID, version: 0, updated_at: null, doc: null, stale: [] }; + const resolve = makePinResolver(workingTree(top, run), { inPlace: true }, run); + return { doc_id: SCRATCHPAD_ID, version: stored.version, updated_at: stored.updated_at, doc: stored.doc, stale: staleOf(stored.doc, resolve).stale }; +} + +/** Block ids unique against those already in the scratchpad. */ +function freshIds(blocks: DocBlock[], taken: Set): DocBlock[] { + return blocks.map((b) => { + const base = b.id ?? blockId(b); + let id = base; + for (let n = 1; taken.has(id); n++) id = `${base}_${n}`; + taken.add(id); + return { ...b, id }; + }); +} + +/** + * Keep an explain document on top of the scratchpad. Its title becomes the + * entry heading, its first section's text follows, then its diagrams. The + * same title kept again replaces the older entry. + */ +export function keepInScratchpad(root: string, explained: ReviewDoc, o: { run?: GitRunner; clock?: Clock } = {}): Scratchpad { + const run = o.run ?? defaultRun; + const clock = o.clock ?? systemClock; + const top = homeOf(root, run); + const stored = read(top, clock); + const heading = `#### ${explained.title.replace(/\s+/g, ' ')}`; + const older = entriesOf(blocksOf(stored?.doc ?? null)).filter((e) => (e[0] as { text?: string }).text !== heading); + const kept = older.slice(0, MAX_ENTRIES - 1).flat(); + const taken = new Set(kept.map((b) => b.id ?? '')); + const body = explained.sections.flatMap((s) => s.blocks).map((b) => { + const { primary: _p, id: _id, ...rest } = b as DocBlock & { primary?: boolean }; + return rest as DocBlock; + }); + const entry = freshIds([{ type: 'markdown', text: heading } as DocBlock, ...body], taken); + const change = workingTree(top, run); + const doc = compose([...entry, ...kept], change, stored?.doc?.title); + const next: StoredScratchpad = { schema: SCRATCHPAD_SCHEMA, version: (stored?.version ?? 0) + 1, updated_at: clock().toISOString(), doc }; + write(top, next); + return getScratchpad(root, o); +} + +export type ScratchpadPatch = + | { ok: true; scratchpad: Scratchpad; notes: string[] } + | { ok: false; version: number; conflict?: boolean; errors: string[]; issues: DocIssue[] }; + +/** + * Patch the scratchpad by block id, with the review document's operations. + * All or nothing, against the version the writer read. Refused when the + * result has a new problem; a block that was already stale may stay stale. + */ +export function patchScratchpad(root: string, expect: number, ops: unknown, o: { run?: GitRunner; clock?: Clock } = {}): ScratchpadPatch { + const run = o.run ?? defaultRun; + const clock = o.clock ?? systemClock; + const top = homeOf(root, run); + const stored = read(top, clock); + const version = stored?.version ?? 0; + if (expect !== version) { + return { ok: false, version, conflict: true, errors: [`written against version ${expect}; the scratchpad is at version ${version} — read it again and reapply`], issues: [] }; + } + if (!stored?.doc) return { ok: false, version, errors: ['the scratchpad is empty — keep an explanation first (op "explain" with keep: true)'], issues: [] }; + const change = workingTree(top, run); + const resolve = makePinResolver(change, { inPlace: true }, run); + const result = applyPatch(stored.doc, ops); + if (result.errors.length > 0) return { ok: false, version, errors: result.errors, issues: [] }; + const doc = compose(blocksOf(result.doc), change, result.doc.title); + const before = new Set(staleOf(stored.doc, resolve).stale.map((s) => s.block)); + const after = staleOf(doc, resolve); + const fresh = after.issues.filter((i) => !before.has(blockOfIssue(doc, i))); + if (fresh.length > 0) return { ok: false, version, errors: [], issues: fresh }; + write(top, { schema: SCRATCHPAD_SCHEMA, version: version + 1, updated_at: clock().toISOString(), doc }); + return { ok: true, scratchpad: getScratchpad(root, o), notes: result.notes }; +} + +/** Empty the scratchpad. Returns the version it was at. */ +export function clearScratchpad(root: string, o: { run?: GitRunner } = {}): number { + const run = o.run ?? defaultRun; + const top = homeOf(root, run); + const stored = read(top, systemClock); + try { + fs.rmSync(fileOf(top), { force: true }); + } catch { + /* nothing to clear */ + } + return stored?.version ?? 0; +} + +/** Where the scratchpad file is, for a watcher (VS Code reloads its tab when it changes). */ +export function scratchpadFile(root: string, run: GitRunner = defaultRun): string { + return fileOf(homeOf(root, run)); +} diff --git a/src/version.ts b/src/version.ts index 924f035..89d98f6 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.1003.1'; +export const VERSION = '2026.1003.2';