From 523ec4b215ac7c5eda702cd8253826016c72fa2b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 22:40:20 +0000 Subject: [PATCH] chore: sync from vibgrate-cli monorepo (v2026.1003.3) Outbound mirror of packages/vibgrate-cli-public. Passed the public-surface leak gate. --- CHANGELOG.md | 18 ++ 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.3.md | 52 ++++ src/cli.ts | 7 +- src/commands/config-invalid.test.ts | 121 ++++++++ src/commands/daemon.ts | 3 + src/commands/path.ts | 27 +- src/commands/show-flow.ts | 46 +++ src/commands/show-scratchpad.ts | 42 +++ src/commands/show.ts | 9 +- src/commands/why.ts | 1 + src/core-open/config.ts | 214 +++++++++++++- src/core-open/formatters/markdown.ts | 17 +- src/core-open/formatters/sarif.ts | 5 + src/core-open/formatters/text.ts | 35 ++- src/core-open/index.ts | 13 +- src/core-open/licenses/diagnostic.ts | 106 +++++++ src/core-open/licenses/index.ts | 1 + src/core-open/licenses/normalize.ts | 15 +- src/core-open/run-core-scan.ts | 13 +- src/core-open/scanners/cargo-lockfile.ts | 10 +- src/core-open/scanners/dart-scanner.ts | 23 +- src/core-open/scanners/gemfile-lock.ts | 10 +- src/core-open/scanners/gradle-lockfile.ts | 10 +- src/core-open/scanners/java-scanner.ts | 7 +- src/core-open/scanners/node-scanner.ts | 7 +- src/core-open/scanners/npm-lockfile.ts | 52 ++-- src/core-open/scanners/php-scanner.ts | 24 +- src/core-open/scanners/python-lockfile.ts | 21 +- src/core-open/scanners/python-scanner.ts | 7 +- src/core-open/scanners/ruby-scanner.ts | 7 +- src/core-open/scanners/rust-scanner.ts | 10 +- src/core-open/scanners/swift-scanner.ts | 44 ++- .../scanners/vulnerability-scanner.ts | 52 +++- src/core-open/scoring/cvss.ts | 148 ++++++++-- src/core-open/scoring/drift-score.ts | 53 +++- src/core-open/types.ts | 55 +++- src/core-open/utils/fs.ts | 6 +- src/core-open/utils/glob.ts | 40 ++- src/core-open/utils/lockfile-parse.ts | 243 +++++++++++++++ src/core-open/utils/mermaid.ts | 4 +- src/engine/cache.ts | 4 +- src/engine/discover.ts | 44 ++- src/engine/docs-ingest.ts | 19 +- src/engine/duties.test.ts | 39 +++ src/engine/duties.ts | 13 + src/engine/export.test.ts | 35 +++ src/engine/export.ts | 28 +- src/engine/index-db.ts | 21 +- src/engine/load.ts | 6 +- src/engine/lockfile.test.ts | 60 +++- src/engine/lockfile.ts | 186 ++++++------ src/engine/manifests.ts | 20 +- src/engine/serialize.ts | 101 ++++++- src/engine/snapshot.ts | 33 ++- src/engine/truncated-lockfile.cli.test.ts | 78 +++++ src/index.ts | 2 +- src/lsp/server.ts | 82 ++++-- src/mcp/cross-impact.test.ts | 2 +- src/mcp/review-tools.test.ts | 54 ++++ src/mcp/review-tools.ts | 101 ++++++- src/mcp/server.ts | 5 +- src/mcp/staleness.test.ts | 8 +- src/mcp/tools.ts | 12 +- 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 | 158 +++++++++- src/reporting/commands/sbom.ts | 259 +++++++++++++--- .../commands/scan-package-manifest.test.ts | 157 ++++++++++ src/reporting/commands/scan.ts | 67 +++-- 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 ++- .../package-version-manifest.test.ts | 165 ++++++++++- src/reporting/package-version-manifest.ts | 181 ++++++++++-- src/reporting/planning/expected-drift.test.ts | 9 +- src/reporting/planning/expected-drift.ts | 2 +- .../scanners/dependency-graph.test.ts | 8 +- src/reporting/scanners/dependency-graph.ts | 16 +- src/reporting/scoring/drift-score.test.ts | 166 ++++++++++- src/reporting/scoring/drift-score.ts | 20 +- src/reporting/types.ts | 16 +- src/reporting/utils/glob.ts | 19 +- src/reporting/utils/ingest-id-output.test.ts | 9 + src/reporting/utils/ingest-id-output.ts | 6 +- src/review/config.ts | 14 +- src/review/explain-doc.test.ts | 68 ++++- src/review/explain-doc.ts | 117 +++++++- src/review/prepare.ts | 5 +- src/review/review.test.ts | 5 +- src/review/scratchpad.test.ts | 129 ++++++++ src/review/scratchpad.ts | 277 ++++++++++++++++++ src/runtime/vgd/server.ts | 52 +++- src/version.ts | 2 +- test/blank-exclude.test.ts | 90 ++++++ test/graph-load.test.ts | 203 +++++++++++++ test/snapshot.test.ts | 11 +- test/vulnerabilities.test.ts | 84 ++++++ 108 files changed, 4509 insertions(+), 592 deletions(-) create mode 100644 releases/v2026.1003.3.md create mode 100644 src/commands/config-invalid.test.ts create mode 100644 src/commands/show-flow.ts create mode 100644 src/commands/show-scratchpad.ts create mode 100644 src/core-open/licenses/diagnostic.ts create mode 100644 src/core-open/utils/lockfile-parse.ts create mode 100644 src/engine/truncated-lockfile.cli.test.ts create mode 100644 src/reporting/commands/scan-package-manifest.test.ts create mode 100644 src/review/scratchpad.test.ts create mode 100644 src/review/scratchpad.ts create mode 100644 test/blank-exclude.test.ts create mode 100644 test/graph-load.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2ec89..da8e324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,24 @@ backward compatible. ### Fixed +- **`vg scan --package-manifest` stops when the manifest cannot be read.** A + missing path, a file this process cannot read, or content that is not a + package-version manifest (JSON, or a ZIP containing `package-versions.json`, + `manifest.json`, or `index.json`) now ends the command with a non-zero exit + and an error that names the path and what to pass instead. The scan does not + continue without the manifest, and the message does not include the file's + contents. + +- **`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..48e84b8 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.3' # 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..94fa08d 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.3" # 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..31a4877 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vibgrate/cli", - "version": "2026.1003.1", + "version": "2026.1003.3", "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.3.md b/releases/v2026.1003.3.md new file mode 100644 index 0000000..e8fb902 --- /dev/null +++ b/releases/v2026.1003.3.md @@ -0,0 +1,52 @@ +# Vibgrate CLI 2026.1003.3 + +_Released 2026-10-03_ + +This release of the Vibgrate CLI includes several important fixes and new features aimed at improving the user experience. Notably, it addresses issues with DriftScore reporting, configuration file handling, and introduces new diagramming capabilities for code analysis. + +## What changed + +### New + +- `vg path --diagram` and `vg show flow ` offer new ways to visualize code interactions. +- An explain scratchpad has been introduced for understanding code, with the newest entries displayed on top. + +### Fixed + +- `vg scan` now correctly distinguishes between an unmeasured DriftScore and a real score of 0. +- A typo in `.vibgrate/config.yml` now prevents commands from crashing and provides clear error messages. +- Commands now stop with clear errors for truncated or invalid code maps, and provide guidance to rebuild. +- `vg scan --package-manifest` exits with an error when the manifest file is missing or unreadable. +- `vg sbom export` now correctly handles components with names that cannot be represented as Package URLs. +- `vg scan` and `vg build` will stop for truncated lockfiles and provide instructions to regenerate them. +- `vg scan --vulns` now reports issues with unparseable CVSS vectors separately from missing scores. +- Warnings are issued for declared licenses that cannot be read as SPDX, including the license text. +- `vg build` and `vg scan` now skip empty or whitespace-only exclude patterns, improving project visibility. + +## 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) | 26401 count | 26401 count | +| Call edges extracted (corpus total) | 18653 count | 18653 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) | 608.20 ms | 615.20 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.3 diff --git a/src/cli.ts b/src/cli.ts index 9e2d1e3..e680a4f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -42,6 +42,8 @@ import { registerPolicy } from './commands/policy.js'; import { registerLlmHost } from './commands/llm-host.js'; import { registerHcs } from './commands/hcs.js'; import { registerReview } from './commands/review.js'; +import { ConfigFileError } from './core-open/config.js'; +import { LockfileParseError } from './core-open/utils/lockfile-parse.js'; import { CliError, ExitCode } from './util/exit.js'; import { c, info, disableColor, exitAfterFlush } from './util/output.js'; @@ -431,10 +433,11 @@ function handleError(err: unknown): never { /* stdout closed */ } }; - if (err instanceof CliError) { + if (err instanceof CliError || err instanceof LockfileParseError || err instanceof ConfigFileError) { + const code = err instanceof CliError ? err.code : ExitCode.ERROR; emitHostError(err.message); info(c.red(`error: ${err.message}`)); - return exitAfterFlush(err.code); + return exitAfterFlush(code); } const message = err instanceof Error ? err.message : String(err); const correlation = Math.random().toString(36).slice(2, 10); diff --git a/src/commands/config-invalid.test.ts b/src/commands/config-invalid.test.ts new file mode 100644 index 0000000..e31347e --- /dev/null +++ b/src/commands/config-invalid.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { ConfigFileError, parseDataConfig } from '../core-open/config.js'; +import { readConfigExcludes } from '../engine/discover.js'; +import { loadReviewConfig } from '../review/config.js'; +import { main } from '../cli.js'; + +const TOKEN = 'example-placeholder'; +const BROKEN_YAML = `token: ${TOKEN}\nexclude: [unclosed\n`; + +const roots: string[] = []; +function project(files: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-config-invalid-')); + roots.push(root); + for (const [rel, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), text); + } + return root; +} + +function expectedMessage(text: string, file: string): string { + try { + parseDataConfig(text, file); + } catch (err) { + if (err instanceof ConfigFileError) return err.message; + throw err; + } + throw new Error('expected a config error'); +} + +afterEach(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe('malformed project config fails closed', () => { + it('names the file and line, and does not echo a token or a stack', () => { + const message = expectedMessage(BROKEN_YAML, '.vibgrate/config.yml'); + expect(message).toBe( + '.vibgrate/config.yml is not valid YAML at line 3, column 1: Flow sequence in block collection must be sufficiently indented and end with a ]. Fix the file and run the command again.', + ); + expect(message).not.toContain(TOKEN); + expect(message).not.toMatch(/\n\s+at /); + }); + + it('names the key when exclude is not a list', () => { + const text = `# note\nexclude: legacy/**\ntoken: ${TOKEN}\n`; + const message = expectedMessage(text, '.vibgrate/config.yml'); + expect(message).toBe( + '.vibgrate/config.yml: `exclude` must be a list of strings, for example exclude: ["legacy/**"] (line 2). Fix that key and run the command again.', + ); + expect(message).not.toContain(TOKEN); + }); + + it('does not treat a broken file as an empty exclude list', () => { + const root = project({ '.vibgrate/config.yml': BROKEN_YAML }); + expect(() => readConfigExcludes(root)).toThrow(ConfigFileError); + expect(() => readConfigExcludes(root)).toThrow(/\.vibgrate\/config\.yml is not valid YAML at line 3/); + expect(() => readConfigExcludes(root)).not.toThrow(new RegExp(TOKEN)); + }); + + it('does not fall back to review defaults when the committed config is invalid', () => { + const run = (args: string[]) => + args[1] === 'HEAD:.vibgrate/config.yml' + ? { stdout: BROKEN_YAML, status: 0 } + : { stdout: '', status: 1 }; + expect(() => loadReviewConfig('/repo', undefined, run)).toThrow(ConfigFileError); + expect(() => loadReviewConfig('/repo', undefined, run)).toThrow(/not valid YAML at line 3/); + expect(() => loadReviewConfig('/repo', undefined, run)).not.toThrow(new RegExp(TOKEN)); + }); +}); + +describe('vg exits non-zero on a malformed config file', () => { + async function runCli(args: string[]): Promise<{ code: number; stderr: string; stdout: string }> { + vi.stubEnv('NO_COLOR', '1'); + vi.stubEnv('VIBGRATE_NO_KERNEL', '1'); + const stderr: string[] = []; + const stdout: string[] = []; + const write = (bucket: string[]) => (chunk: unknown, encodingOrCb?: unknown, cb?: unknown) => { + bucket.push(String(chunk)); + const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb; + if (typeof callback === 'function') callback(); + return true; + }; + const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(write(stderr) as never); + const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation(write(stdout) as never); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`EXIT:${code ?? 0}`); + }) as never); + + try { + await main(['node', 'vg', ...args]); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const match = /^EXIT:(\d+)$/.exec(message); + if (!match) throw err; + return { code: Number(match[1]), stderr: stderr.join(''), stdout: stdout.join('') }; + } finally { + errSpy.mockRestore(); + outSpy.mockRestore(); + exitSpy.mockRestore(); + } + throw new Error('expected the CLI to exit'); + } + + it('vg scan, vg build, and vg review print the same actionable error', async () => { + const root = project({ '.vibgrate/config.yml': BROKEN_YAML }); + const expected = `error: ${expectedMessage(BROKEN_YAML, '.vibgrate/config.yml')}\n`; + const scan = await runCli(['scan', root, '--offline', '--no-graph', '--quiet', '--no-local-artifacts']); + const build = await runCli(['build', '-C', root, '--offline', '--quiet']); + const review = await runCli(['review', '-C', root, '--offline', '--quiet', '--no-setup']); + + expect(scan).toEqual({ code: 1, stderr: expected, stdout: '' }); + expect(build).toEqual({ code: 1, stderr: expected, stdout: '' }); + expect(review).toEqual({ code: 1, stderr: expected, stdout: '' }); + }); +}); diff --git a/src/commands/daemon.ts b/src/commands/daemon.ts index f9435e4..1b08eba 100644 --- a/src/commands/daemon.ts +++ b/src/commands/daemon.ts @@ -411,6 +411,9 @@ export function registerDaemon(program: Command): void { { socketPath }, ); let put = loaded; + if (!loaded.ok && loaded.code === 'bad_graph') { + throw new CliError(loaded.error, ExitCode.ERROR); + } if (!loaded.ok && loaded.code === 'no_map') { throw new CliError(loaded.error, ExitCode.NOT_FOUND); } 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/commands/why.ts b/src/commands/why.ts index a66a91b..5ee37dc 100644 --- a/src/commands/why.ts +++ b/src/commands/why.ts @@ -78,6 +78,7 @@ export function registerWhy(program: Command): void { const cvss = adv.cvss != null ? ` cvss ${adv.cvss}` : ''; const fixed = adv.fixedVersions.length ? ` — fixed in ${adv.fixedVersions.join(', ')}` : ' — no fix available'; info(` ${severityTag(adv.severity)} ${idLabel}${c.dim(cvss)}${c.dim(fixed)}`); + if (adv.cvssDiagnostic) info(c.dim(` ${adv.cvssDiagnostic.message}`)); if (adv.introduced) { const exposure = adv.exposureDays != null ? `, ${adv.exposureDays}d exposed` : ''; info( diff --git a/src/core-open/config.ts b/src/core-open/config.ts index daf08f5..b9e7eaf 100644 --- a/src/core-open/config.ts +++ b/src/core-open/config.ts @@ -5,9 +5,10 @@ import * as path from 'node:path'; import * as fs from 'node:fs/promises'; import { existsSync, readFileSync } from 'node:fs'; import type * as TsModule from 'typescript'; -import { parse as parseYaml } from 'yaml'; +import { parseDocument } from 'yaml'; import type { VibgrateConfig } from './types.js'; import { pathExists, readTextFile } from './utils/fs.js'; +import { redactSecrets } from './utils/redact.js'; /** * The project config, in lookup order. The FIRST file found is the config; @@ -33,20 +34,45 @@ export function isDataConfigFile(file: string): boolean { } /** - * Parse a data config (YAML or JSON) into a plain object. Throws an Error - * naming the file when the text is not a valid mapping. + * A project config file could not be read. The message names the file and, + * when the parser can say, the line and key — never a source excerpt, so a + * token sitting on the broken line is not echoed. */ -export function parseDataConfig(text: string, file: string): Record { - let parsed: unknown; - try { - parsed = /\.ya?ml$/.test(file) ? parseYaml(text) : JSON.parse(text); - } catch (err) { - const reason = err instanceof Error ? err.message.split('\n')[0] : 'unreadable'; - throw new Error(`${file} is not valid ${/\.ya?ml$/.test(file) ? 'YAML' : 'JSON'}: ${reason}`); +export class ConfigFileError extends Error { + readonly file: string; + readonly line?: number; + readonly key?: string; + + constructor(message: string, details: { file: string; line?: number; key?: string }) { + super(message); + this.name = 'ConfigFileError'; + this.file = details.file; + this.line = details.line; + this.key = details.key; } +} + +export function isConfigFileError(err: unknown): err is ConfigFileError { + return err instanceof ConfigFileError || (err instanceof Error && err.name === 'ConfigFileError'); +} + +/** + * Parse a data config (YAML or JSON) into a plain object. Throws + * {@link ConfigFileError} naming the file (and the line or key when the + * parser can say) when the text is not a valid mapping. + */ +export function parseDataConfig(text: string, file: string): Record { + const parsed = /\.ya?ml$/.test(file) ? parseYamlConfig(text, file) : parseJsonConfig(text, file); // An empty YAML file is an empty config, not an error. if (parsed === null || parsed === undefined) return {}; - if (!isRecord(parsed)) throw new Error(`${file} must contain a mapping of settings.`); + if (!isRecord(parsed)) { + const found = Array.isArray(parsed) ? 'a list' : `a ${typeof parsed}`; + throw configFileError( + `${file} must contain a mapping of settings (found ${found}). Use key: value entries, for example exclude: ["legacy/**"].`, + { file }, + ); + } + assertKnownShapes(parsed, text, file); return parsed; } @@ -84,8 +110,22 @@ export function readDataConfigSync(rootDir: string): DataConfigRead { try { return { file, config: parseDataConfig(readFileSync(path.join(rootDir, file), 'utf8'), file) }; } catch (err) { - return { file, config: null, error: err instanceof Error ? err.message : String(err) }; + const message = err instanceof Error ? err.message.split('\n')[0] : 'unreadable'; + return { file, config: null, error: redactSecrets(message || 'unreadable') }; + } +} + +/** + * Like {@link readDataConfigSync}, but a data file that does not parse is an + * error. A missing file and a `.ts`/`.js` config are unchanged: callers that + * cannot execute code keep their existing fallback. + */ +export function requireDataConfig(rootDir: string): DataConfigRead { + const read = readDataConfigSync(rootDir); + if (read.error && read.file && isDataConfigFile(read.file)) { + throw new ConfigFileError(read.error, { file: read.file }); } + return read; } const TRUSTED_CONFIG_ENV = 'VIBGRATE_TRUST_CONFIG'; @@ -110,6 +150,156 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function configFileError( + message: string, + details: { file: string; line?: number; key?: string }, +): ConfigFileError { + return new ConfigFileError(redactSecrets(message), details); +} + +interface SourceLocation { + line?: number; + column?: number; +} + +function locationOf(err: unknown): SourceLocation { + const tagged = err as { linePos?: Array<{ line?: number; col?: number }>; message?: string }; + const pos = tagged?.linePos?.[0]; + if (pos && typeof pos.line === 'number' && pos.line > 0) { + return { line: pos.line, column: typeof pos.col === 'number' && pos.col > 0 ? pos.col : undefined }; + } + const first = typeof tagged?.message === 'string' ? (tagged.message.split('\n')[0] ?? '') : ''; + const match = /at line (\d+), column (\d+)/.exec(first); + if (!match) return {}; + return { line: Number(match[1]), column: Number(match[2]) }; +} + +/** Parser reason with the source preview and the repeated location stripped. */ +function parserReason(err: unknown): string { + const raw = err instanceof Error ? err.message : ''; + let line = raw.split('\n')[0] ?? ''; + line = line.replace(/\s+at line \d+, column \d+:?\s*$/i, '').trim(); + line = redactSecrets(line).trim(); + if (!line) return 'the syntax could not be read'; + return line.length > 180 ? `${line.slice(0, 177)}...` : line; +} + +function whereAt(loc: SourceLocation): string { + if (loc.line === undefined) return ''; + return ` at line ${loc.line}${loc.column !== undefined ? `, column ${loc.column}` : ''}`; +} + +function yamlFailure(file: string, err: unknown): ConfigFileError { + const loc = locationOf(err); + return configFileError( + `${file} is not valid YAML${whereAt(loc)}: ${parserReason(err)}. Fix the file and run the command again.`, + { file, line: loc.line }, + ); +} + +function parseYamlConfig(text: string, file: string): unknown { + let doc: ReturnType; + try { + doc = parseDocument(text); + } catch (err) { + throw yamlFailure(file, err); + } + if (doc.errors.length > 0) throw yamlFailure(file, doc.errors[0]); + try { + return doc.toJS(); + } catch (err) { + throw yamlFailure(file, err); + } +} + +function jsonLocation(text: string, err: unknown): SourceLocation { + const message = err instanceof Error ? err.message : ''; + // Use only the coordinates Node puts in the message. The rest of the + // message quotes a slice of the file, which is where a token would leak. + const explicit = /\(line (\d+) column (\d+)\)/.exec(message); + if (explicit) return { line: Number(explicit[1]), column: Number(explicit[2]) }; + const pos = /position (\d+)/.exec(message); + if (pos) { + const index = Number(pos[1]); + if (Number.isFinite(index) && index >= 0) { + const slice = text.slice(0, index); + const parts = slice.split('\n'); + return { line: parts.length, column: (parts[parts.length - 1]?.length ?? 0) + 1 }; + } + } + if (/Unexpected end of JSON input/.test(message) && text.length > 0) { + const parts = text.split('\n'); + const last = parts[parts.length - 1] ?? ''; + return { line: parts.length, column: last.length + 1 }; + } + return {}; +} + +function parseJsonConfig(text: string, file: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch (err) { + const loc = jsonLocation(text, err); + // The JSON parser quotes a slice of the file. Do not repeat it — that + // slice is where a token would leak. + throw configFileError( + `${file} is not valid JSON${whereAt(loc)}. Fix the file and run the command again.`, + { file, line: loc.line }, + ); + } +} + +interface ShapeCheck { + key: string; + ok: (value: unknown) => boolean; + expect: string; +} + +function isStringList(value: unknown): boolean { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +function isNonNegativeNumber(value: unknown): boolean { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +const SHAPE_CHECKS: readonly ShapeCheck[] = [ + { key: 'include', ok: isStringList, expect: 'a list of strings, for example include: ["src/**"]' }, + { key: 'exclude', ok: isStringList, expect: 'a list of strings, for example exclude: ["legacy/**"]' }, + { key: 'maxFileSizeToScan', ok: isNonNegativeNumber, expect: 'a number of bytes, for example maxFileSizeToScan: 5242880' }, + { key: 'projectScanTimeout', ok: isNonNegativeNumber, expect: 'a number of seconds, for example projectScanTimeout: 180' }, + { key: 'areaSkills', ok: (value) => typeof value === 'boolean', expect: 'true or false' }, + { key: 'scanners', ok: (value) => value === false || isRecord(value), expect: 'a mapping of scanner settings, or false' }, + { key: 'thresholds', ok: isRecord, expect: 'a mapping of threshold settings' }, + { key: 'driftBudget', ok: isRecord, expect: 'a mapping of budget settings' }, + { key: 'review', ok: isRecord, expect: 'a mapping of review settings' }, +]; + +function lineOfKey(text: string, file: string, key: string): number | undefined { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = /\.json$/.test(file) + ? new RegExp(`"${escaped}"\\s*:`) + : new RegExp(`^(?:"${escaped}"|'${escaped}'|${escaped})\\s*:`); + const lines = text.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + if (re.test(lines[i] ?? '')) return i + 1; + } + return undefined; +} + +function assertKnownShapes(parsed: Record, text: string, file: string): void { + for (const check of SHAPE_CHECKS) { + if (!Object.prototype.hasOwnProperty.call(parsed, check.key)) continue; + if (check.ok(parsed[check.key])) continue; + const line = lineOfKey(text, file, check.key); + const at = line !== undefined ? ` (line ${line})` : ''; + throw configFileError( + `${file}: \`${check.key}\` must be ${check.expect}${at}. Fix that key and run the command again.`, + { file, line, key: check.key }, + ); + } +} + function toStaticValue( expr: TsModule.Expression, constBindings: Map, 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/sarif.ts b/src/core-open/formatters/sarif.ts index 9825a8d..bea9d3e 100644 --- a/src/core-open/formatters/sarif.ts +++ b/src/core-open/formatters/sarif.ts @@ -170,6 +170,11 @@ function buildRules(findings: Finding[]) { shortDescription: { text: 'Known vulnerability in an installed dependency' }, helpUri: 'https://vibgrate.com/rules/vulnerability', }, + 'vibgrate/license-parse-failed': { + id: 'vibgrate/license-parse-failed', + shortDescription: { text: 'Declared license could not be resolved as SPDX' }, + helpUri: 'https://vibgrate.com/rules/license-parse-failed', + }, }; return descriptions[id] ?? { id, 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/index.ts b/src/core-open/index.ts index 2142847..9250041 100644 --- a/src/core-open/index.ts +++ b/src/core-open/index.ts @@ -20,6 +20,9 @@ export { isDataConfigFile, parseDataConfig, readDataConfigSync, + requireDataConfig, + ConfigFileError, + isConfigFileError, type ConfigFile, type DataConfigRead, } from './config.js'; @@ -144,7 +147,15 @@ export { VULN_RULE_ID, type VulnTarget, } from './scanners/vulnerability-scanner.js'; -export { cvssV3BaseScore, severityFromCvss, severityRank, normalizeSeverityLabel } from './scoring/cvss.js'; +export { + cvssV3BaseScore, + parseCvssVector, + severityFromCvss, + severityRank, + normalizeSeverityLabel, + CVSS_VECTOR_PARSE_FAILED, + type CvssParseResult, +} from './scoring/cvss.js'; export { computeUpgradeImpact, analyzeUsage, computeVersionJump } from './scanners/upgrade-impact.js'; export { getChangelogSignals, diff --git a/src/core-open/licenses/diagnostic.ts b/src/core-open/licenses/diagnostic.ts new file mode 100644 index 0000000..18203e7 --- /dev/null +++ b/src/core-open/licenses/diagnostic.ts @@ -0,0 +1,106 @@ +// VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by +// scripts/vendor-core-open.mjs. Do not edit here — change the source package +// and re-run the vendor script. Apache-2.0. +/** + * Diagnostic for a declared license that failed SPDX resolution. + * + * `normalizeLicense` stays tolerant: a bad string still finishes as + * `matchStatus: 'unknown'` / `NOASSERTION`, and a partially resolved + * expression stays `expression`. Callers that print only `spdxId` then skip + * `unknown`, which drops the failure with no trace. This module is the + * trace: one stable code, the truncated declared string, and the + * manifest-relative path. + * + * An explicit unknown (`NOASSERTION`, `unknown`, `none`, `n/a`, or empty) + * is not a failure. A fuzzy family match is not a failure either. + */ + +import { isExplicitUnknownLicense, normalizeLicense } from './normalize.js'; +import { parseLicenseExpression } from './spdx-expression.js'; + +/** Stable code for scan, report, SARIF, and SBOM. Identical for every failure. */ +export const LICENSE_PARSE_FAILED = 'vibgrate/license-parse-failed'; + +/** How much of the declared license string a diagnostic may repeat. */ +export const LICENSE_RAW_LIMIT = 120; + +export interface LicenseParseDiagnostic { + code: typeof LICENSE_PARSE_FAILED; + /** Actionable, deterministic text. No file body and no credential. */ + message: string; + /** Manifest-relative path the declaration was read from. */ + location: string; + /** Truncated, credential-redacted declared license string. */ + raw: string; +} + +const REDACTED = '[REDACTED]'; + +/** + * Credential shapes only. The shared ingest redactor also blanks long + * base64-like runs, which would hide a long license string; this pass does + * not. Deterministic. + */ +const TOKEN_PATTERNS: RegExp[] = [ + /\b(?:gh[opusr]_|github_pat_)[A-Za-z0-9_]{8,}\b/g, + /\bglpat-[A-Za-z0-9_-]{8,}\b/g, + /\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g, + /\bsk-[A-Za-z0-9_-]{8,}\b/gi, + /\b[sr]k_(?:live|test)_[A-Za-z0-9]{8,}\b/g, + /\bnpm_[A-Za-z0-9]{16,}\b/g, + /\b(?:AKIA|ASIA)[0-9A-Z]{12,}\b/g, + /\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]+\b/g, + /\b(?:Bearer|Basic)\s+[A-Za-z0-9+/._:=-]{8,}/g, + /\/\/[^/\s@]+@/g, +]; + +function redactLicenseText(value: string): string { + let out = value; + for (const re of TOKEN_PATTERNS) { + re.lastIndex = 0; + out = out.replace(re, REDACTED); + } + return out; +} + +function truncateLicense(value: string): string { + if (value.length <= LICENSE_RAW_LIMIT) return value; + return `${value.slice(0, LICENSE_RAW_LIMIT - 1)}…`; +} + +function expressionHasUnresolvedId(input: string): boolean { + const parsed = parseLicenseExpression(input); + return parsed.licenseIds.some((id) => normalizeLicense(id).matchStatus === 'unknown'); +} + +/** + * One diagnostic when `raw` is a non-empty license that does not resolve, or + * an expression that contains a constituent id that does not resolve. + * Returns null for an explicit unknown, a fuzzy match, and a fully resolved + * id or expression. Same inputs always return the same object. + */ +export function licenseParseDiagnostic( + raw: string | null | undefined, + manifestPath: string, + packageName?: string, +): LicenseParseDiagnostic | null { + if (isExplicitUnknownLicense(raw)) return null; + const input = (raw ?? '').trim(); + const verdict = normalizeLicense(input); + const unresolvedExpression = + verdict.matchStatus === 'expression' && expressionHasUnresolvedId(input); + if (verdict.matchStatus !== 'unknown' && !unresolvedExpression) return null; + + const shown = truncateLicense(redactLicenseText(input)); + const pkg = packageName?.trim(); + const where = pkg ? `for ${pkg} at ${manifestPath}` : `at ${manifestPath}`; + const message = + `Could not resolve SPDX license "${shown}" ${where}. ` + + 'Replace it with a canonical SPDX id or expression, or NOASSERTION if the license is intentionally unknown.'; + return { + code: LICENSE_PARSE_FAILED, + message, + location: manifestPath, + raw: shown, + }; +} diff --git a/src/core-open/licenses/index.ts b/src/core-open/licenses/index.ts index 2704e7b..a47e342 100644 --- a/src/core-open/licenses/index.ts +++ b/src/core-open/licenses/index.ts @@ -12,3 +12,4 @@ export * from './spdx-catalog.js'; export * from './spdx-aliases.js'; export * from './spdx-expression.js'; export * from './normalize.js'; +export * from './diagnostic.js'; diff --git a/src/core-open/licenses/normalize.ts b/src/core-open/licenses/normalize.ts index 82ab86c..8083f95 100644 --- a/src/core-open/licenses/normalize.ts +++ b/src/core-open/licenses/normalize.ts @@ -53,6 +53,19 @@ export interface LicenseVerdict { components: string[]; } +/** Declared values that mean "no license asserted", not a failed parse. */ +const EXPLICIT_UNKNOWN_LICENSE = /^(unknown|noassertion|none|n\/a)$/i; + +/** + * True for an empty declaration or an explicit unknown sentinel + * (`NOASSERTION`, `unknown`, `none`, `n/a`). These stay `matchStatus: 'unknown'` + * and are not parse failures. + */ +export function isExplicitUnknownLicense(raw: string | null | undefined): boolean { + const input = (raw ?? '').trim(); + return input.length === 0 || EXPLICIT_UNKNOWN_LICENSE.test(input); +} + const CATEGORY_RESTRICTIVENESS: Record = { 'public-domain': 0, permissive: 1, @@ -112,7 +125,7 @@ function fuzzyMatch(raw: string): LicenseRecord | undefined { */ export function normalizeLicense(raw: string | null | undefined): LicenseVerdict { const input = (raw ?? '').trim(); - if (!input || /^(unknown|noassertion|none|n\/a)$/i.test(input)) { + if (isExplicitUnknownLicense(raw)) { return verdictFromRecord(unknownLicenseRecord(), 'unknown', 0); } 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/scanners/cargo-lockfile.ts b/src/core-open/scanners/cargo-lockfile.ts index 0b5ba14..1d04ed9 100644 --- a/src/core-open/scanners/cargo-lockfile.ts +++ b/src/core-open/scanners/cargo-lockfile.ts @@ -14,6 +14,7 @@ */ import * as path from 'node:path'; import * as semver from 'semver'; +import { assertLockfileText, LockfileParseError, rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { parseToml } from '../utils/toml.js'; import type { LockfileIo } from './npm-lockfile.js'; export type { LockfileIo } from './npm-lockfile.js'; @@ -67,10 +68,13 @@ export async function loadCargoLockIndex(dir: string, io: LockfileIo): Promise false))) return null; try { - const map = parseCargoLock(await io.readText(lockPath)); + const text = await io.readText(lockPath); + assertLockfileText(lockPath, text, 'TOML'); + const map = parseCargoLock(text); if (!map.size) return null; return { size: map.size, resolve: (name, spec) => pickLockedVersion(map.get(name), spec) }; - } catch { - return null; + } catch (err) { + rethrowLockfileParseError(err); + throw new LockfileParseError(lockPath, 'TOML'); } } diff --git a/src/core-open/scanners/dart-scanner.ts b/src/core-open/scanners/dart-scanner.ts index 35630c2..f8a4d67 100644 --- a/src/core-open/scanners/dart-scanner.ts +++ b/src/core-open/scanners/dart-scanner.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import * as semver from 'semver'; import { readTextFile, FileCache } from '../utils/fs.js'; +import { assertLockfileText, LockfileParseError, rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { withTimeout } from '../utils/timeout.js'; import { PubCache } from './pub-cache.js'; import type { ProjectScan, DependencyRow, DetectedFramework } from '../types.js'; @@ -174,11 +175,19 @@ function parsePubspecYaml(content: string): { async function parsePubspecLock(filePath: string, cache?: FileCache): Promise> { const resolved = new Map(); + let content: string; try { - const content = cache + content = cache ? await cache.readTextFile(filePath) : await readTextFile(filePath); - + } catch (err) { + rethrowLockfileParseError(err); + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return resolved; + throw new LockfileParseError(filePath, 'YAML'); + } + assertLockfileText(filePath, content, 'YAML'); + + try { let currentPackage: string | null = null; for (const line of content.split(/\r?\n/)) { @@ -203,8 +212,9 @@ async function parsePubspecLock(filePath: string, cache?: FileCache): Promise(); try { resolvedVersions = await parsePubspecLock(lockPath, cache); - } catch { - // No pubspec.lock or can't read it + } catch (err) { + rethrowLockfileParseError(err); } // Filter out dev dependencies for main analysis diff --git a/src/core-open/scanners/gemfile-lock.ts b/src/core-open/scanners/gemfile-lock.ts index 9da2d3a..280f2e5 100644 --- a/src/core-open/scanners/gemfile-lock.ts +++ b/src/core-open/scanners/gemfile-lock.ts @@ -14,6 +14,7 @@ * AGPL library is used. */ import * as path from 'node:path'; +import { assertLockfileText, LockfileParseError, rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import type { LockfileIo } from './npm-lockfile.js'; export type { LockfileIo } from './npm-lockfile.js'; @@ -50,10 +51,13 @@ export async function loadGemfileLockIndex(dir: string, io: LockfileIo): Promise const lockPath = path.join(dir, 'Gemfile.lock'); if (!(await io.exists(lockPath).catch(() => false))) return null; try { - const map = parseGemfileLock(await io.readText(lockPath)); + const text = await io.readText(lockPath); + assertLockfileText(lockPath, text, 'Gemfile.lock'); + const map = parseGemfileLock(text); if (!map.size) return null; return { size: map.size, resolve: (name) => map.get(name) ?? null }; - } catch { - return null; + } catch (err) { + rethrowLockfileParseError(err); + throw new LockfileParseError(lockPath, 'Gemfile.lock'); } } diff --git a/src/core-open/scanners/gradle-lockfile.ts b/src/core-open/scanners/gradle-lockfile.ts index 190bb36..13da967 100644 --- a/src/core-open/scanners/gradle-lockfile.ts +++ b/src/core-open/scanners/gradle-lockfile.ts @@ -16,6 +16,7 @@ * AGPL library is used. */ import * as path from 'node:path'; +import { assertLockfileText, LockfileParseError, rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import type { LockfileIo } from './npm-lockfile.js'; export type { LockfileIo } from './npm-lockfile.js'; @@ -49,10 +50,13 @@ export async function loadGradleLockIndex(dir: string, io: LockfileIo): Promise< const lockPath = path.join(dir, 'gradle.lockfile'); if (!(await io.exists(lockPath).catch(() => false))) return null; try { - const map = parseGradleLockfile(await io.readText(lockPath)); + const text = await io.readText(lockPath); + assertLockfileText(lockPath, text, 'gradle.lockfile'); + const map = parseGradleLockfile(text); if (!map.size) return null; return { size: map.size, resolve: (coordinate) => map.get(coordinate) ?? null }; - } catch { - return null; + } catch (err) { + rethrowLockfileParseError(err); + throw new LockfileParseError(lockPath, 'gradle.lockfile'); } } diff --git a/src/core-open/scanners/java-scanner.ts b/src/core-open/scanners/java-scanner.ts index 7fccb0a..4000c68 100644 --- a/src/core-open/scanners/java-scanner.ts +++ b/src/core-open/scanners/java-scanner.ts @@ -7,6 +7,7 @@ import { XMLParser } from 'fast-xml-parser'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; import { withTimeout } from '../utils/timeout.js'; import { MavenCache } from './maven-cache.js'; +import { rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { loadGradleLockIndex, type GradleLockIndex } from './gradle-lockfile.js'; import type { LockfileIo } from './npm-lockfile.js'; import { latestLts, runtimeEolStatus, extractCycle, eolDate } from '../runtimes/catalog.js'; @@ -387,6 +388,7 @@ export async function scanJavaProjects( } } } catch (e: unknown) { + rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); console.error(`Error scanning Java project ${dir}: ${msg}`); } @@ -486,7 +488,10 @@ async function scanOneJavaProject( readText: (p) => (cache ? cache.readTextFile(p) : readTextFile(p)), readJson: (p: string) => (cache ? cache.readJsonFile(p) : readJsonFile(p)), }; - const lockIndex: GradleLockIndex | null = await loadGradleLockIndex(dir, lockIo).catch(() => null); + const lockIndex: GradleLockIndex | null = await loadGradleLockIndex(dir, lockIo).catch((err: unknown) => { + rethrowLockfileParseError(err); + return null; + }); // Resolve dependencies against Maven Central const dependencies: DependencyRow[] = []; diff --git a/src/core-open/scanners/node-scanner.ts b/src/core-open/scanners/node-scanner.ts index 1f19fc4..53102ec 100644 --- a/src/core-open/scanners/node-scanner.ts +++ b/src/core-open/scanners/node-scanner.ts @@ -5,6 +5,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import * as semver from 'semver'; import { findPackageJsonFiles, readJsonFile, readTextFile, pathExists, FileCache } from '../utils/fs.js'; +import { rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { loadNpmLockIndex, type NpmLockIndex, type LockfileIo } from './npm-lockfile.js'; import { Semaphore } from '../utils/semaphore.js'; import { withTimeout } from '../utils/timeout.js'; @@ -187,7 +188,10 @@ export async function scanNodeProjects( readText: (p) => (cache ? cache.readTextFile(p) : readTextFile(p)), readJson: (p: string) => (cache ? cache.readJsonFile(p) : readJsonFile(p)), }; - const lockIndex = await loadNpmLockIndex(rootDir, lockIo).catch(() => null); + const lockIndex = await loadNpmLockIndex(rootDir, lockIo).catch((err: unknown) => { + rethrowLockfileParseError(err); + return null; + }); const STUCK_TIMEOUT_MS = projectScanTimeout ?? cache?.projectScanTimeout ?? 180_000; const cores = typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length || 4; @@ -245,6 +249,7 @@ export async function scanNodeProjects( } return null; } catch (e: unknown) { + rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); console.error(`Error scanning ${pjPath}: ${msg}`); return null; diff --git a/src/core-open/scanners/npm-lockfile.ts b/src/core-open/scanners/npm-lockfile.ts index 270be98..03e9876 100644 --- a/src/core-open/scanners/npm-lockfile.ts +++ b/src/core-open/scanners/npm-lockfile.ts @@ -16,6 +16,7 @@ */ import * as path from 'node:path'; import { parse as parseYaml } from 'yaml'; +import { assertLockfileText, LockfileParseError, rethrowLockfileParseError } from '../utils/lockfile-parse.js'; export type NpmLockSource = 'package-lock' | 'yarn' | 'pnpm'; @@ -167,35 +168,50 @@ export async function loadNpmLockIndex(dir: string, io: LockfileIo): Promise false)) { + const text = await io.readText(pnpmPath); + let doc: unknown; try { - const map = parsePnpmLock(parseYaml(await io.readText(pnpmPath))); - if (map.size) return { source: 'pnpm', size: map.size, resolve: (name) => map.get(name) ?? null }; - } catch { - /* fall through */ + assertLockfileText(pnpmPath, text, 'YAML'); + doc = parseYaml(text); + } catch (err) { + rethrowLockfileParseError(err); + throw new LockfileParseError(pnpmPath, 'YAML'); } + const map = parsePnpmLock(doc); + if (map.size) return { source: 'pnpm', size: map.size, resolve: (name) => map.get(name) ?? null }; } // package-lock next: structured JSON and unambiguous. if (await io.exists(lockPath).catch(() => false)) { + let json: unknown; try { - const map = parsePackageLock(await io.readJson(lockPath)); - if (map.size) return { source: 'package-lock', size: map.size, resolve: (name) => map.get(name) ?? null }; - } catch { - /* fall through to yarn */ + json = await io.readJson(lockPath); + } catch (err) { + rethrowLockfileParseError(err); + throw new LockfileParseError(lockPath, 'JSON'); } + const map = parsePackageLock(json); + if (map.size) return { source: 'package-lock', size: map.size, resolve: (name) => map.get(name) ?? null }; } if (await io.exists(yarnPath).catch(() => false)) { + const text = await io.readText(yarnPath); + let parsed: ReturnType; try { - const { bySpec, byName } = parseYarnLock(await io.readText(yarnPath)); - if (byName.size) { - return { - source: 'yarn', - size: byName.size, - resolve: (name, spec) => (spec ? bySpec.get(`${name}@${spec}`) : undefined) ?? byName.get(name) ?? null, - }; - } - } catch { - /* none usable */ + assertLockfileText(yarnPath, text, 'yarn.lock'); + parsed = parseYarnLock(text); + } catch (err) { + rethrowLockfileParseError(err); + throw new LockfileParseError(yarnPath, 'yarn.lock'); + } + const { bySpec, byName } = parsed; + if (byName.size) { + return { + source: 'yarn', + size: byName.size, + resolve: (name, spec) => (spec ? bySpec.get(`${name}@${spec}`) : undefined) ?? byName.get(name) ?? null, + }; } } return null; diff --git a/src/core-open/scanners/php-scanner.ts b/src/core-open/scanners/php-scanner.ts index 74b8528..e5993ca 100644 --- a/src/core-open/scanners/php-scanner.ts +++ b/src/core-open/scanners/php-scanner.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import * as semver from 'semver'; import { FileCache, readJsonFile } from '../utils/fs.js'; +import { LockfileParseError, rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { withTimeout } from '../utils/timeout.js'; import { ComposerCache } from './composer-cache.js'; import type { ProjectScan, DependencyRow, DetectedFramework } from '../types.js'; @@ -133,11 +134,18 @@ async function parseComposerJson(filePath: string, cache?: FileCache): Promise<{ async function parseComposerLock(filePath: string, cache?: FileCache): Promise> { const resolved = new Map(); + let data: { packages?: Array<{ name?: string; version?: string }>; 'packages-dev'?: Array<{ name?: string; version?: string }> }; try { - const data = cache + data = cache ? await cache.readJsonFile(filePath) - : await readJsonFile(filePath) as any; - + : await readJsonFile(filePath); + } catch (err) { + rethrowLockfileParseError(err); + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return resolved; + throw new LockfileParseError(filePath, 'JSON'); + } + + try { // Parse packages for (const pkg of (data.packages ?? []) as Array<{ name?: string; version?: string }>) { if (pkg.name && pkg.version) { @@ -151,8 +159,9 @@ async function parseComposerLock(filePath: string, cache?: FileCache): Promise(); try { resolvedVersions = await parseComposerLock(lockPath, cache); - } catch { - // No composer.lock or can't read it + } catch (err) { + rethrowLockfileParseError(err); } // Filter out dev dependencies for main analysis diff --git a/src/core-open/scanners/python-lockfile.ts b/src/core-open/scanners/python-lockfile.ts index 8382136..8a9f020 100644 --- a/src/core-open/scanners/python-lockfile.ts +++ b/src/core-open/scanners/python-lockfile.ts @@ -20,6 +20,7 @@ * is used. Only the two fields we need are read from each package entry. */ import * as path from 'node:path'; +import { assertLockfileText, LockfileParseError, rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { parseToml } from '../utils/toml.js'; import type { LockfileIo } from './npm-lockfile.js'; export type { LockfileIo } from './npm-lockfile.js'; @@ -93,20 +94,26 @@ export async function loadPythonLockIndex(dir: string, io: LockfileIo): Promise< const p = path.join(dir, file); if (!(await io.exists(p).catch(() => false))) continue; try { - const map = parsePyTomlLock(await io.readText(p)); + const text = await io.readText(p); + assertLockfileText(p, text, 'TOML'); + const map = parsePyTomlLock(text); if (map.size) return { source, size: map.size, resolve: (n) => map.get(normalizePyName(n)) ?? null }; - } catch { - /* try the next */ + } catch (err) { + rethrowLockfileParseError(err); + throw new LockfileParseError(p, 'TOML'); } } const pipfile = path.join(dir, 'Pipfile.lock'); if (await io.exists(pipfile).catch(() => false)) { + let json: unknown; try { - const map = parsePipfileLock(await io.readJson(pipfile)); - if (map.size) return { source: 'pipfile', size: map.size, resolve: (n) => map.get(normalizePyName(n)) ?? null }; - } catch { - /* none usable */ + json = await io.readJson(pipfile); + } catch (err) { + rethrowLockfileParseError(err); + throw new LockfileParseError(pipfile, 'JSON'); } + const map = parsePipfileLock(json); + if (map.size) return { source: 'pipfile', size: map.size, resolve: (n) => map.get(normalizePyName(n)) ?? null }; } return null; } diff --git a/src/core-open/scanners/python-scanner.ts b/src/core-open/scanners/python-scanner.ts index 2b467e2..e421fe4 100644 --- a/src/core-open/scanners/python-scanner.ts +++ b/src/core-open/scanners/python-scanner.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import * as semver from 'semver'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; +import { rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { loadPythonLockIndex, type PythonLockIndex } from './python-lockfile.js'; import type { LockfileIo } from './npm-lockfile.js'; import { withTimeout } from '../utils/timeout.js'; @@ -378,6 +379,7 @@ export async function scanPythonProjects( } } } catch (e: unknown) { + rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); console.error(`Error scanning Python project ${dir}: ${msg}`); } @@ -471,7 +473,10 @@ async function scanOnePythonProject( readText: (p) => (cache ? cache.readTextFile(p) : readTextFile(p)), readJson: (p: string) => (cache ? cache.readJsonFile(p) : readJsonFile(p)), }; - const lockIndex: PythonLockIndex | null = await loadPythonLockIndex(dir, lockIo).catch(() => null); + const lockIndex: PythonLockIndex | null = await loadPythonLockIndex(dir, lockIo).catch((err: unknown) => { + rethrowLockfileParseError(err); + return null; + }); // Resolve dependencies against PyPI const dependencies: DependencyRow[] = []; diff --git a/src/core-open/scanners/ruby-scanner.ts b/src/core-open/scanners/ruby-scanner.ts index 9bec462..e5927bf 100644 --- a/src/core-open/scanners/ruby-scanner.ts +++ b/src/core-open/scanners/ruby-scanner.ts @@ -6,6 +6,7 @@ import * as semver from 'semver'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; import { withTimeout } from '../utils/timeout.js'; import { RubyGemsCache } from './rubygems-cache.js'; +import { rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { loadGemfileLockIndex, type GemfileLockIndex } from './gemfile-lock.js'; import type { LockfileIo } from './npm-lockfile.js'; import { latestStable, runtimeEolStatus, extractCycle, eolDate } from '../runtimes/catalog.js'; @@ -365,6 +366,7 @@ export async function scanRubyProjects( } } } catch (e: unknown) { + rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); console.error(`Error scanning Ruby project ${dir}: ${msg}`); } @@ -448,7 +450,10 @@ async function scanOneRubyProject( readText: (p) => (cache ? cache.readTextFile(p) : readTextFile(p)), readJson: (p: string) => (cache ? cache.readJsonFile(p) : readJsonFile(p)), }; - const lockIndex: GemfileLockIndex | null = await loadGemfileLockIndex(dir, lockIo).catch(() => null); + const lockIndex: GemfileLockIndex | null = await loadGemfileLockIndex(dir, lockIo).catch((err: unknown) => { + rethrowLockfileParseError(err); + return null; + }); // Resolve dependencies against RubyGems const dependencies: DependencyRow[] = []; diff --git a/src/core-open/scanners/rust-scanner.ts b/src/core-open/scanners/rust-scanner.ts index 353a4f2..1f11642 100644 --- a/src/core-open/scanners/rust-scanner.ts +++ b/src/core-open/scanners/rust-scanner.ts @@ -6,6 +6,7 @@ import * as semver from 'semver'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; import { withTimeout } from '../utils/timeout.js'; import { CargoCache } from './cargo-cache.js'; +import { rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { loadCargoLockIndex, type CargoLockIndex } from './cargo-lockfile.js'; import type { LockfileIo } from './npm-lockfile.js'; import type { ProjectScan, DependencyRow, DetectedFramework } from '../types.js'; @@ -216,6 +217,7 @@ export async function scanRustProjects( } } } catch (e: unknown) { + rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); console.error(`Error scanning Rust project ${dir}: ${msg}`); } @@ -251,8 +253,12 @@ async function scanOneRustProject( readText: (p) => (cache ? cache.readTextFile(p) : readTextFile(p)), readJson: (p: string) => (cache ? cache.readJsonFile(p) : readJsonFile(p)), }; - let lockIndex: CargoLockIndex | null = await loadCargoLockIndex(dir, lockIo).catch(() => null); - if (!lockIndex && dir !== rootDir) lockIndex = await loadCargoLockIndex(rootDir, lockIo).catch(() => null); + const swallow = (err: unknown): null => { + rethrowLockfileParseError(err); + return null; + }; + let lockIndex: CargoLockIndex | null = await loadCargoLockIndex(dir, lockIo).catch(swallow); + if (!lockIndex && dir !== rootDir) lockIndex = await loadCargoLockIndex(rootDir, lockIo).catch(swallow); // Filter out dev dependencies for main analysis const prodDeps = allDeps.filter(d => !d.isDev); diff --git a/src/core-open/scanners/swift-scanner.ts b/src/core-open/scanners/swift-scanner.ts index b8cc0bf..9c4aec6 100644 --- a/src/core-open/scanners/swift-scanner.ts +++ b/src/core-open/scanners/swift-scanner.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import * as semver from 'semver'; import { readTextFile, FileCache } from '../utils/fs.js'; +import { parseLockfileJson, rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { withTimeout } from '../utils/timeout.js'; import { SwiftCache } from './swift-cache.js'; import type { ProjectScan, DependencyRow, DetectedFramework } from '../types.js'; @@ -123,29 +124,22 @@ function parsePackageSwift(content: string): SwiftDependency[] { * ] * } */ -function parsePackageResolved(content: string): Map { +function parsePackageResolved(filePath: string, content: string): Map { const resolved = new Map(); - - try { - const data = JSON.parse(content) as { - pins?: Array<{ - identity?: string; - state?: { - version?: string; - }; - }>; - }; - - if (!data.pins) return resolved; - - for (const pin of data.pins) { - if (!pin.identity || !pin.state?.version) continue; - resolved.set(pin.identity.toLowerCase(), pin.state.version); - } - } catch { - // Invalid JSON + const data = parseLockfileJson(filePath, content) as { + pins?: Array<{ + identity?: string; + state?: { + version?: string; + }; + }>; + } | null; + + if (!data || typeof data !== 'object' || !data.pins) return resolved; + for (const pin of data.pins) { + if (!pin.identity || !pin.state?.version) continue; + resolved.set(pin.identity.toLowerCase(), pin.state.version); } - return resolved; } @@ -200,6 +194,7 @@ export async function scanSwiftProjects( } } } catch (e: unknown) { + rethrowLockfileParseError(e); const msg = e instanceof Error ? e.message : String(e); console.error(`Error scanning Swift project ${dir}: ${msg}`); } @@ -232,9 +227,10 @@ async function scanOneSwiftProject( let resolvedVersions = new Map(); try { const resolvedContent = cache ? await cache.readTextFile(resolvedPath) : await readTextFile(resolvedPath); - resolvedVersions = parsePackageResolved(resolvedContent); - } catch { - // No Package.resolved or can't read it + resolvedVersions = parsePackageResolved(resolvedPath, resolvedContent); + } catch (err) { + rethrowLockfileParseError(err); + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; } // Determine Swift runtime version lag diff --git a/src/core-open/scanners/vulnerability-scanner.ts b/src/core-open/scanners/vulnerability-scanner.ts index 854f705..72cf3d8 100644 --- a/src/core-open/scanners/vulnerability-scanner.ts +++ b/src/core-open/scanners/vulnerability-scanner.ts @@ -9,7 +9,8 @@ import { type ManifestEcosystem, type PackageVersionManifest, } from '../package-version-manifest.js'; -import { cvssV3BaseScore, normalizeSeverityLabel, severityFromCvss, severityRank } from '../scoring/cvss.js'; +import { parseCvssVector, normalizeSeverityLabel, severityFromCvss, severityRank } from '../scoring/cvss.js'; +import { redactSecrets } from '../utils/redact.js'; import type { AffectedRange, Finding, @@ -199,10 +200,35 @@ interface RawOsvVuln { database_specific?: { severity?: string }; } +/** + * Turn a vector (and an optional numeric score from a manifest) into advisory + * fields. A parsed score, a missing score, and a failed vector stay distinct: + * the failure is `cvssDiagnostic`, never a null that looks like "no score". + */ +function cvssFields( + vector: string | null, + numeric?: number, +): Pick { + const parsed = parseCvssVector(vector); + const hasNumeric = typeof numeric === 'number' && Number.isFinite(numeric); + const cvss = hasNumeric ? numeric : parsed.status === 'parsed' ? parsed.score : null; + if (parsed.status === 'invalid') { + return { + cvss, + cvssVector: redactSecrets(vector ?? ''), + cvssDiagnostic: parsed.diagnostic, + }; + } + return { + cvss, + cvssVector: parsed.status === 'parsed' ? vector : null, + }; +} + /** Parse a raw OSV advisory into our shape, scoped to a specific package name. */ export function parseOsvAdvisory(raw: RawOsvVuln, packageName: string): VulnerabilityAdvisory { - const cvssVector = raw.severity?.find((s) => (s.type ?? '').toUpperCase().startsWith('CVSS_V3'))?.score ?? null; - const cvss = cvssV3BaseScore(cvssVector); + const rawVector = raw.severity?.find((s) => (s.type ?? '').toUpperCase().startsWith('CVSS_V3'))?.score ?? null; + const scored = cvssFields(rawVector); // Qualitative fallback severity (GHSA): top-level, then the matching affected entry. const lowerName = packageName.toLowerCase(); @@ -211,7 +237,7 @@ export function parseOsvAdvisory(raw: RawOsvVuln, packageName: string): Vulnerab raw.database_specific?.severity ?? matchingAffected.map((a) => a.ecosystem_specific?.severity ?? a.database_specific?.severity).find(Boolean) ?? null; - const severity: VulnSeverity = cvss != null ? severityFromCvss(cvss) : normalizeSeverityLabel(qualitative); + const severity: VulnSeverity = scored.cvss != null ? severityFromCvss(scored.cvss) : normalizeSeverityLabel(qualitative); const fixedVersions: string[] = []; const affectedRanges: AffectedRange[] = []; @@ -243,8 +269,7 @@ export function parseOsvAdvisory(raw: RawOsvVuln, packageName: string): Vulnerab aliases: Array.isArray(raw.aliases) ? raw.aliases : [], summary: raw.summary ?? null, severity, - cvss, - cvssVector, + ...scored, fixedVersions, published: raw.published ?? null, withdrawn: raw.withdrawn ?? null, @@ -255,19 +280,18 @@ export function parseOsvAdvisory(raw: RawOsvVuln, packageName: string): Vulnerab } export function manifestAdvisoryToAdvisory(m: ManifestAdvisory): VulnerabilityAdvisory { - const cvss = typeof m.cvss === 'number' ? m.cvss : cvssV3BaseScore(m.cvssVector ?? null); + const scored = cvssFields(m.cvssVector ?? null, m.cvss); const severity: VulnSeverity = m.severity ? normalizeSeverityLabel(m.severity) - : cvss != null - ? severityFromCvss(cvss) + : scored.cvss != null + ? severityFromCvss(scored.cvss) : 'unknown'; return { id: m.id, aliases: m.aliases ?? [], summary: m.summary ?? null, severity, - cvss, - cvssVector: m.cvssVector ?? null, + ...scored, fixedVersions: (m.ranges ?? []).map((r) => r.fixed).filter((f): f is string => Boolean(f)), published: m.published ?? null, withdrawn: m.withdrawn ?? null, @@ -499,6 +523,7 @@ export function generateVulnerabilityFindings(result: VulnerabilityScanResult): const idLabel = cve && cve !== adv.id ? `${adv.id} (${cve})` : adv.id; const fixed = adv.fixedVersions.length ? ` — fixed in ${adv.fixedVersions.join(', ')}` : ' — no fix available'; const cvssLabel = adv.cvss != null ? ` ${adv.cvss}` : ''; + const parseNote = adv.cvssDiagnostic ? ` — ${adv.cvssDiagnostic.message}` : ''; const attribution = adv.introduced != null ? ` — introduced by ${adv.introduced.authorName} in ${adv.introduced.shortSha}${adv.exposureDays != null ? ` (${adv.exposureDays}d exposed)` : ''}` @@ -506,7 +531,7 @@ export function generateVulnerabilityFindings(result: VulnerabilityScanResult): findings.push({ ruleId: VULN_RULE_ID, level: levelForSeverity(adv.severity), - message: `${pkg.package}@${pkg.version}: ${idLabel} (${adv.severity}${cvssLabel})${fixed}${attribution}`, + message: `${pkg.package}@${pkg.version}: ${idLabel} (${adv.severity}${cvssLabel})${parseNote}${fixed}${attribution}`, location: pkg.package, details: { ecosystem: pkg.ecosystem, @@ -517,6 +542,9 @@ export function generateVulnerabilityFindings(result: VulnerabilityScanResult): severity: adv.severity, cvss: adv.cvss, fixedVersions: adv.fixedVersions, + ...(adv.cvssDiagnostic + ? { cvssDiagnostic: adv.cvssDiagnostic, cvssVector: adv.cvssVector } + : {}), ...(adv.introduced ? { introducedBy: adv.introduced.authorName, diff --git a/src/core-open/scoring/cvss.ts b/src/core-open/scoring/cvss.ts index f45d5a0..51823c5 100644 --- a/src/core-open/scoring/cvss.ts +++ b/src/core-open/scoring/cvss.ts @@ -1,7 +1,8 @@ // VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. -import type { VulnSeverity } from '../types.js'; +import type { CvssDiagnostic, VulnSeverity } from '../types.js'; +import { redactSecrets } from '../utils/redact.js'; /** * Minimal, dependency-free CVSS v3.0/v3.1 base-score calculator. @@ -9,9 +10,32 @@ import type { VulnSeverity } from '../types.js'; * Advisories (OSV/GHSA) carry severity as a CVSS *vector string*, not a number. * To order findings and apply CRA severity thresholds we need the numeric base * score, so this implements the official base-score formula. It is deterministic - * and offline. Temporal/environmental metrics are ignored (base score only); - * non-v3 vectors (e.g. CVSS v2) return null rather than guessing. + * and offline. Temporal/environmental metrics are ignored (base score only). + * + * A vector that fails to parse is not a missing score and not a score of zero. + * {@link parseCvssVector} keeps the three apart: `parsed` (including 0), + * `missing` (no vector supplied), and `invalid` (a vector was supplied and + * could not be scored, with a stable diagnostic). + */ + +/** Stable code for every CVSS vector that was present but did not parse. */ +export const CVSS_VECTOR_PARSE_FAILED = 'cvss-vector-parse-failed' as const; + +/** + * Outcome of reading a CVSS vector. + * - `parsed` — a v3.0/v3.1 base score, including 0 when there is no impact. + * - `missing` — null, undefined, or blank. No score was supplied. + * - `invalid` — a non-blank vector that is not a scoreable v3 base vector. */ +export type CvssParseResult = + | { status: 'parsed'; score: number } + | { status: 'missing' } + | { status: 'invalid'; diagnostic: CvssDiagnostic }; + +const NEXT_STEP = + 'Replace it with a CVSS:3.0 or CVSS:3.1 base vector, or omit the vector and supply a numeric base score.'; + +const PREVIEW_MAX = 80; const AV: Record = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 }; const AC: Record = { L: 0.77, H: 0.44 }; @@ -28,31 +52,70 @@ function roundup(x: number): number { return (Math.floor(i / 10000) + 1) / 10; } -/** - * Compute the CVSS v3 base score (0–10) from a vector string, or null when the - * vector is not a parseable v3 base vector. - */ -export function cvssV3BaseScore(vector: string | null | undefined): number | null { - if (!vector || typeof vector !== 'string') return null; - const parts = vector.trim().split('/'); - if (!parts.length) return null; - if (!/^CVSS:3\.[01]$/i.test(parts[0])) return null; +function preview(vector: string): string { + const redacted = redactSecrets(vector.trim()).replace(/\s+/g, ' '); + if (redacted.length <= PREVIEW_MAX) return redacted; + return `${redacted.slice(0, PREVIEW_MAX - 3)}...`; +} + +function invalid(reason: string): CvssParseResult { + const message = redactSecrets( + `CVSS vector failed to parse (${CVSS_VECTOR_PARSE_FAILED}): ${reason}. ${NEXT_STEP}`, + ); + return { + status: 'invalid', + diagnostic: { code: CVSS_VECTOR_PARSE_FAILED, message }, + }; +} +function readMetrics(parts: string[]): Record { const m: Record = {}; - for (const part of parts.slice(1)) { + for (const part of parts) { const [k, v] = part.split(':'); if (k && v) m[k.toUpperCase()] = v.toUpperCase(); } + return m; +} + +function metricProblem( + metrics: Record, + key: string, + allowed: Record, + expected: string, +): string | null { + const value = metrics[key]; + if (value === undefined) return `${key} is missing (expected ${expected})`; + if (allowed[value] === undefined) return `${key} is "${value}" (expected ${expected})`; + return null; +} - const scopeChanged = m.S === 'C'; - const av = AV[m.AV]; - const ac = AC[m.AC]; - const ui = UI[m.UI]; - const pr = (scopeChanged ? PR_CHANGED : PR_UNCHANGED)[m.PR]; - const c = CIA[m.C]; - const integ = CIA[m.I]; - const a = CIA[m.A]; - if ([av, ac, ui, pr, c, integ, a].some((v) => v === undefined)) return null; +/** Problems in canonical base-metric order, so the same vector always yields the same text. */ +function baseMetricProblems(metrics: Record): string[] { + const problems: string[] = []; + const add = (problem: string | null) => { + if (problem) problems.push(problem); + }; + add(metricProblem(metrics, 'AV', AV, 'N, A, L, or P')); + add(metricProblem(metrics, 'AC', AC, 'L or H')); + add(metricProblem(metrics, 'PR', metrics.S === 'C' ? PR_CHANGED : PR_UNCHANGED, 'N, L, or H')); + add(metricProblem(metrics, 'UI', UI, 'N or R')); + if (metrics.S === undefined) problems.push('S is missing (expected U or C)'); + else if (metrics.S !== 'U' && metrics.S !== 'C') problems.push(`S is "${metrics.S}" (expected U or C)`); + add(metricProblem(metrics, 'C', CIA, 'H, L, or N')); + add(metricProblem(metrics, 'I', CIA, 'H, L, or N')); + add(metricProblem(metrics, 'A', CIA, 'H, L, or N')); + return problems; +} + +function baseScore(metrics: Record): number { + const scopeChanged = metrics.S === 'C'; + const av = AV[metrics.AV]!; + const ac = AC[metrics.AC]!; + const ui = UI[metrics.UI]!; + const pr = (scopeChanged ? PR_CHANGED : PR_UNCHANGED)[metrics.PR]!; + const c = CIA[metrics.C]!; + const integ = CIA[metrics.I]!; + const a = CIA[metrics.A]!; const iss = 1 - (1 - c) * (1 - integ) * (1 - a); const impact = scopeChanged @@ -67,6 +130,47 @@ export function cvssV3BaseScore(vector: string | null | undefined): number | nul return roundup(raw); } +/** + * Read a CVSS vector into a parsed score, a missing score, or a parse failure. + * The same input always produces the same result. Credential-shaped text in a + * failed vector is redacted before it is placed in the diagnostic. + */ +export function parseCvssVector(vector: string | null | undefined): CvssParseResult { + if (vector == null || typeof vector !== 'string') return { status: 'missing' }; + const trimmed = vector.trim(); + if (!trimmed) return { status: 'missing' }; + + const parts = trimmed.split('/'); + const version = parts[0] ?? ''; + if (!/^CVSS:3\.[01]$/i.test(version)) { + if (/^CVSS:\d/i.test(version)) { + return invalid( + `${version.toUpperCase()} is not a scored version (only CVSS:3.0 and CVSS:3.1 base vectors are scored)`, + ); + } + return invalid(`"${preview(trimmed)}" is not a CVSS:3.0 or CVSS:3.1 base vector`); + } + + const metrics = readMetrics(parts.slice(1)); + const problems = baseMetricProblems(metrics); + if (problems.length > 0) { + return invalid(`base metrics are incomplete or outside the CVSS v3 spec (${problems.join('; ')})`); + } + return { status: 'parsed', score: baseScore(metrics) }; +} + +/** + * Compute the CVSS v3 base score (0–10) from a vector string, or null when the + * vector is missing or not a parseable v3 base vector. + * + * Null here does **not** distinguish those two cases. Call {@link parseCvssVector} + * when a failed vector must stay distinct from a missing score. + */ +export function cvssV3BaseScore(vector: string | null | undefined): number | null { + const parsed = parseCvssVector(vector); + return parsed.status === 'parsed' ? parsed.score : null; +} + /** Map a CVSS v3 base score to its qualitative severity band. */ export function severityFromCvss(score: number): VulnSeverity { if (score >= 9.0) return 'critical'; diff --git a/src/core-open/scoring/drift-score.ts b/src/core-open/scoring/drift-score.ts index 9ee6bff..86e14da 100644 --- a/src/core-open/scoring/drift-score.ts +++ b/src/core-open/scoring/drift-score.ts @@ -3,6 +3,7 @@ // and re-run the vendor script. Apache-2.0. import * as crypto from 'node:crypto'; import type { ProjectScan, DriftScore, Finding, RiskLevel, VibgrateConfig } from '../types.js'; +import { licenseParseDiagnostic } from '../licenses/diagnostic.js'; import { aggregateDependencyDrift } from './dependency-drift-v3.js'; /** @@ -201,18 +202,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 +240,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, @@ -381,6 +384,30 @@ export function generateFindings( }); } } + + // A non-empty license that does not resolve, or an expression with an + // unresolved constituent, is a data-quality finding. An explicit + // NOASSERTION / empty declaration is not. Sort so the same manifest + // always emits the same order. + const licenseFindings: Finding[] = []; + const licenseDeps = [...project.dependencies].sort( + (a, b) => + a.package.localeCompare(b.package) || + (a.license?.raw ?? '').localeCompare(b.license?.raw ?? '') || + a.section.localeCompare(b.section), + ); + for (const dep of licenseDeps) { + const diag = licenseParseDiagnostic(dep.license?.raw, project.path, dep.package); + if (!diag) continue; + licenseFindings.push({ + ruleId: diag.code, + level: 'warning', + message: diag.message, + location: diag.location, + details: { raw: diag.raw }, + }); + } + findings.push(...licenseFindings); } return findings; diff --git a/src/core-open/types.ts b/src/core-open/types.ts index 07f0335..a486fc8 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 @@ -461,6 +463,21 @@ export type VulnEcosystem = /** Qualitative severity band. `unknown` distinguishes "no severity data" from a real low. */ export type VulnSeverity = 'low' | 'moderate' | 'high' | 'critical' | 'unknown'; +/** + * A CVSS vector was present and could not be parsed. Absent on an advisory that + * has a parsed score, and absent when no vector was supplied. Never used to + * mean "score is zero". + */ +export interface CvssDiagnostic { + /** Stable code, identical for every unparseable vector. */ + code: 'cvss-vector-parse-failed'; + /** + * What failed and what to do next. Deterministic for a given vector. + * Credential-shaped text from the vector is redacted. + */ + message: string; +} + /** Commit attribution: who introduced something, and when (from git history). */ export interface CommitAttribution { sha: string; @@ -488,10 +505,24 @@ export interface VulnerabilityAdvisory { summary: string | null; /** Qualitative severity. */ severity: VulnSeverity; - /** CVSS v3 base score (0–10), or null when not derivable from the advisory. */ + /** + * CVSS v3 base score (0–10) when a vector parsed or a numeric score was + * supplied. Null when no score is available. Zero is a real score (no + * impact), not a missing one. A null score with {@link cvssDiagnostic} set + * means a vector was present and failed to parse; a null score without it + * means no score was supplied. + */ cvss: number | null; - /** Raw CVSS vector string, when the advisory carried one. */ + /** + * Raw CVSS vector string, when the advisory carried one. An unparseable + * vector is stored with credential-shaped text redacted. + */ cvssVector: string | null; + /** + * Set only when a CVSS vector was present and failed to parse. Omitted when + * the score was parsed and when no vector was supplied. + */ + cvssDiagnostic?: CvssDiagnostic; /** First fixed version per affected range (empty when no fix is published). */ fixedVersions: string[]; /** ISO-8601 publish date, when known. */ diff --git a/src/core-open/utils/fs.ts b/src/core-open/utils/fs.ts index 97697fd..07cf0ea 100644 --- a/src/core-open/utils/fs.ts +++ b/src/core-open/utils/fs.ts @@ -9,7 +9,7 @@ import { promisify } from 'node:util'; import type { Dirent } from 'node:fs'; import ignore, { type Ignore } from 'ignore'; import { Semaphore } from './semaphore.js'; -import { compileGlobs } from './glob.js'; +import { compileGlobs, gitignoreWithoutBlankLines } from './glob.js'; const execFileAsync = promisify(execFile); @@ -35,7 +35,9 @@ interface GitignoreLevel { async function extendGitignoreLevels(dir: string, levels: GitignoreLevel[]): Promise { try { const txt = await fs.readFile(path.join(dir, '.gitignore'), 'utf8'); - return [...levels, { dir, ig: ignore().add(txt) }]; + const rules = gitignoreWithoutBlankLines(txt); + if (!rules) return levels; + return [...levels, { dir, ig: ignore().add(rules) }]; } catch { return levels; } diff --git a/src/core-open/utils/glob.ts b/src/core-open/utils/glob.ts index d6b15ee..bcc23b5 100644 --- a/src/core-open/utils/glob.ts +++ b/src/core-open/utils/glob.ts @@ -46,10 +46,46 @@ export function parseExcludePatterns(input: string | string[] | undefined): stri return [...new Set(out)]; } +/** + * True when a pattern is empty or only whitespace. + * + * These are not rules. The `ignore` package skips a line of spaces, but a + * newline or carriage return is not in that check: it compiles to `/(?:)/` + * and matches every path. A blank exclude, or a blank line in `.gitignore`, + * would then hide the whole tree. + */ +export function isBlankPattern(pattern: string): boolean { + return pattern.trim() === ''; +} + +/** Patterns with empty and whitespace-only entries removed. Order is kept. */ +export function dropBlankPatterns(patterns: readonly string[]): string[] { + const out: string[] = []; + for (const pattern of patterns) { + if (typeof pattern !== 'string' || isBlankPattern(pattern)) continue; + out.push(pattern); + } + return out; +} + +/** + * `.gitignore` text with blank lines removed. + * + * Splits on LF and on a bare CR. A CR that is not part of CRLF survives the + * `ignore` package's own line splitter and matches every path. + */ +export function gitignoreWithoutBlankLines(text: string): string { + return text + .split(/\r\n|\n|\r/) + .filter((line) => !isBlankPattern(line)) + .join('\n'); +} + export function compileGlobs(patterns: string[]): ((relPath: string) => boolean) | null { - if (patterns.length === 0) return null; + const usable = dropBlankPatterns(patterns); + if (usable.length === 0) return null; - const matchers = patterns.map((p) => compileOne(normalise(p))); + const matchers = usable.map((p) => compileOne(normalise(p))); return (relPath: string) => { const norm = normalise(relPath); diff --git a/src/core-open/utils/lockfile-parse.ts b/src/core-open/utils/lockfile-parse.ts new file mode 100644 index 0000000..35a9184 --- /dev/null +++ b/src/core-open/utils/lockfile-parse.ts @@ -0,0 +1,243 @@ +// VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by +// scripts/vendor-core-open.mjs. Do not edit here — change the source package +// and re-run the vendor script. Apache-2.0. +/** + * Fail closed on a lockfile that is truncated or syntactically invalid. + * + * A mid-file cut (editor crash, partial CI artifact) must not become an empty + * success or a graph built from whatever prefixes happened to parse. Callers + * surface {@link LockfileParseError} and exit non-zero. The message names the + * file and what to do; it never includes file contents, because lockfiles can + * carry registry tokens. + * + * Parsing stays on the caller thread. A bad lockfile is rejected before the + * source parse pool starts, so the failure cannot leave a worker behind. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { parse as parseToml } from 'smol-toml'; +import { parseAllDocuments } from 'yaml'; + +export type LockfileKind = + | 'JSON' + | 'YAML' + | 'TOML' + | 'yarn.lock' + | 'go.sum' + | 'gradle.lockfile' + | 'Gemfile.lock'; + +const KIND_BY_BASENAME: Readonly> = { + 'package-lock.json': 'JSON', + 'npm-shrinkwrap.json': 'JSON', + 'composer.lock': 'JSON', + 'packages.lock.json': 'JSON', + 'package.resolved': 'JSON', + 'pipfile.lock': 'JSON', + 'pnpm-lock.yaml': 'YAML', + 'pubspec.lock': 'YAML', + 'poetry.lock': 'TOML', + 'uv.lock': 'TOML', + 'pdm.lock': 'TOML', + 'cargo.lock': 'TOML', + 'yarn.lock': 'yarn.lock', + 'go.sum': 'go.sum', + 'gradle.lockfile': 'gradle.lockfile', + 'gemfile.lock': 'Gemfile.lock', +}; + +/** Kind we know how to syntax-check, or undefined for lockfiles we do not parse. */ +export function lockfileKind(basename: string): LockfileKind | undefined { + return KIND_BY_BASENAME[basename.toLowerCase()]; +} + +/** + * Actionable, content-free lockfile failure. `kind` is a format name + * (`JSON`, `YAML`, …) or `unreadable` when the file could not be opened. + */ +export class LockfileParseError extends Error { + readonly filePath: string; + readonly kind: string; + + constructor(filePath: string, kind: string) { + const detail = kind === 'unreadable' ? 'could not be read' : `is truncated or invalid ${kind}`; + const action = + kind === 'unreadable' + ? 'Check that the file is accessible, then re-run the command.' + : 'Restore or regenerate the file with your package manager, then re-run the command.'; + super(`${filePath}: lockfile ${detail}. ${action}`); + this.name = 'LockfileParseError'; + this.filePath = filePath; + this.kind = kind; + } +} + +/** Re-throw a lockfile failure so a best-effort `catch` cannot turn it into an empty graph. */ +export function rethrowLockfileParseError(err: unknown): void { + if (err instanceof LockfileParseError) throw err; +} + +function stripBom(text: string): string { + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; +} + +/** Parse JSON lockfile text. Throws {@link LockfileParseError}; never echoes the source. */ +export function parseLockfileJson(filePath: string, text: string): unknown { + const body = stripBom(text); + if (!body.trim()) throw new LockfileParseError(filePath, 'JSON'); + try { + return JSON.parse(body); + } catch { + throw new LockfileParseError(filePath, 'JSON'); + } +} + +/** + * Reject truncated or invalid lockfile text. A missing file is the caller's + * concern (absence is not an error); this only runs on bytes that were read. + */ +export function assertLockfileText(filePath: string, text: string, kind: LockfileKind): void { + switch (kind) { + case 'JSON': + parseLockfileJson(filePath, text); + return; + case 'YAML': + assertYaml(filePath, text); + return; + case 'TOML': + assertToml(filePath, text); + return; + case 'yarn.lock': + assertYarn(filePath, text); + return; + case 'go.sum': + assertGoSum(filePath, text); + return; + case 'gradle.lockfile': + assertGradle(filePath, text); + return; + case 'Gemfile.lock': + assertGemfile(filePath, text); + return; + default: { + const _exhaustive: never = kind; + return _exhaustive; + } + } +} + +/** + * Syntax-check a lockfile on disk when its basename is one we parse. + * `ENOENT` is ignored (the file disappeared between listing and reading). + * Other read failures and bad syntax throw {@link LockfileParseError}. + */ +export function assertLockfileFile(filePath: string): void { + const kind = lockfileKind(path.basename(filePath)); + if (!kind) return; + let text: string; + try { + text = fs.readFileSync(filePath, 'utf8'); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return; + throw new LockfileParseError(filePath, 'unreadable'); + } + assertLockfileText(filePath, text, kind); +} + +function assertYaml(filePath: string, text: string): void { + if (!stripBom(text).trim()) throw new LockfileParseError(filePath, 'YAML'); + let docs: Array<{ errors: unknown[] }>; + try { + docs = parseAllDocuments(stripBom(text), { logLevel: 'silent', uniqueKeys: false }); + } catch { + throw new LockfileParseError(filePath, 'YAML'); + } + if (docs.some((doc) => doc.errors.length > 0)) throw new LockfileParseError(filePath, 'YAML'); +} + +function assertToml(filePath: string, text: string): void { + const body = stripBom(text); + if (!body.trim()) throw new LockfileParseError(filePath, 'TOML'); + try { + const doc = parseToml(body); + if (!doc || typeof doc !== 'object') throw new LockfileParseError(filePath, 'TOML'); + } catch (err) { + if (err instanceof LockfileParseError) throw err; + throw new LockfileParseError(filePath, 'TOML'); + } +} + +function assertYarn(filePath: string, text: string): void { + if (!text.trim()) throw new LockfileParseError(filePath, 'yarn.lock'); + // Yarn Berry lockfiles are YAML. Classic (v1) is a custom text format. + const head = text.split('\n').slice(0, 40).join('\n'); + if (/^__metadata:\s*$/m.test(head)) { + assertYaml(filePath, text); + return; + } + let pendingHeader = false; + for (const raw of text.split('\n')) { + if (unbalancedQuotes(raw)) throw new LockfileParseError(filePath, 'yarn.lock'); + if (!raw.trim() || raw.trimStart().startsWith('#')) continue; + if (!/^\s/.test(raw)) { + if (!/:\s*$/.test(raw)) throw new LockfileParseError(filePath, 'yarn.lock'); + pendingHeader = true; + continue; + } + if (pendingHeader && /^\s+version:?\s+\S/.test(raw)) pendingHeader = false; + } + if (pendingHeader) throw new LockfileParseError(filePath, 'yarn.lock'); +} + +function assertGoSum(filePath: string, text: string): void { + if (!text.trim()) throw new LockfileParseError(filePath, 'go.sum'); + const lineRe = /^\S+\s+v\S+\s+h1:\S+$/; + for (const raw of text.split('\n')) { + const line = raw.trim(); + if (!line) continue; + if (!lineRe.test(line)) throw new LockfileParseError(filePath, 'go.sum'); + } +} + +function assertGradle(filePath: string, text: string): void { + if (!text.trim()) throw new LockfileParseError(filePath, 'gradle.lockfile'); + const lineRe = /^[^:=\s]+:[^:=\s]+:[^=\s]+=/; + for (const raw of text.split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('#') || line.startsWith('empty=')) continue; + if (!lineRe.test(line)) throw new LockfileParseError(filePath, 'gradle.lockfile'); + } +} + +function assertGemfile(filePath: string, text: string): void { + if (!text.trim()) throw new LockfileParseError(filePath, 'Gemfile.lock'); + for (const raw of text.split('\n')) { + if (unbalancedQuotes(raw) || countChar(raw, '(') !== countChar(raw, ')')) { + throw new LockfileParseError(filePath, 'Gemfile.lock'); + } + } +} + +function unbalancedQuotes(line: string): boolean { + let quote: '"' | "'" | null = null; + for (let i = 0; i < line.length; i++) { + const ch = line[i]!; + if (quote) { + if (ch === '\\') { + i++; + continue; + } + if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") quote = ch; + } + return quote !== null; +} + +function countChar(line: string, ch: string): number { + let n = 0; + for (const c of line) if (c === ch) n++; + return n; +} 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/discover.ts b/src/engine/discover.ts index aa5fce8..3179443 100644 --- a/src/engine/discover.ts +++ b/src/engine/discover.ts @@ -2,7 +2,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import ignore, { type Ignore } from 'ignore'; import { langForExtension, langById, type LanguageDef } from './languages.js'; -import { readDataConfigSync } from '../core-open/config.js'; +import { requireDataConfig } from '../core-open/config.js'; +import { dropBlankPatterns, gitignoreWithoutBlankLines } from '../core-open/utils/glob.js'; +import { assertLockfileFile, lockfileKind } from '../core-open/utils/lockfile-parse.js'; /** * Deterministic file discovery. @@ -175,19 +177,20 @@ function toPosix(p: string): string { /** * Project-local exclude globs from the project config (`.vibgrate/config.yml` * or `vibgrate.config.json`). `.ts`/`.js` configs stay scan-side (they can - * execute). A missing or malformed file is an empty list, never an error. + * execute). A missing file is an empty list. A data file that does not parse + * throws — an unreadable config must not scan as if nothing were excluded. */ export function readConfigExcludes(root: string): string[] { - const exclude = readDataConfigSync(root).config?.exclude; + const exclude = requireDataConfig(root).config?.exclude; if (!Array.isArray(exclude)) return []; - return exclude.filter((x): x is string => typeof x === 'string' && x.trim() !== ''); + return dropBlankPatterns(exclude.filter((x): x is string => typeof x === 'string')); } -/** Config excludes plus caller extras, de-duplicated, config-first. */ +/** Config excludes plus caller extras, de-duplicated, config-first. Blank entries are dropped. */ export function mergeExcludes(root: string, extra?: string[]): string[] { const seen = new Set(); const out: string[] = []; - for (const pattern of [...readConfigExcludes(root), ...(extra ?? [])]) { + for (const pattern of [...readConfigExcludes(root), ...dropBlankPatterns(extra ?? [])]) { if (seen.has(pattern)) continue; seen.add(pattern); out.push(pattern); @@ -195,14 +198,24 @@ export function mergeExcludes(root: string, extra?: string[]): string[] { return out; } -/** Build the ignore matcher from the repo's .gitignore plus extra excludes. */ -function buildRootIgnore(root: string, exclude: string[]): Ignore { +/** + * Ignore matcher for a repo root: `.gitignore` plus extra exclude globs. + * Blank lines and blank excludes are omitted. A newline- or CR-only rule + * matches every path, which would skip the whole tree. + */ +export function loadRootIgnore(root: string, exclude: string[]): Ignore { const ig = ignore(); const gitignorePath = path.join(root, '.gitignore'); - if (fs.existsSync(gitignorePath)) { - ig.add(fs.readFileSync(gitignorePath, 'utf8')); + try { + if (fs.existsSync(gitignorePath)) { + const rules = gitignoreWithoutBlankLines(fs.readFileSync(gitignorePath, 'utf8')); + if (rules) ig.add(rules); + } + } catch { + // Unreadable .gitignore: walk the tree rather than fail the build. } - if (exclude.length) ig.add(exclude); + const patterns = dropBlankPatterns(exclude); + if (patterns.length) ig.add(patterns); return ig; } @@ -219,7 +232,7 @@ export function discover(options: DiscoverOptions): DiscoveredFile[] { } } - const rootIg = buildRootIgnore(root, options.exclude ?? []); + const rootIg = loadRootIgnore(root, options.exclude ?? []); // Scope roots: explicit paths, or the whole repo. const scopeAbs = (options.paths && options.paths.length @@ -233,7 +246,12 @@ export function discover(options: DiscoverOptions): DiscoveredFile[] { const rel = toPosix(path.relative(root, abs)); if (rel.startsWith('..')) return; // outside root if (rel === '' || rootIg.ignores(rel)) return; - if (SKIP_FILES.has(path.basename(abs).toLowerCase())) return; // lockfiles etc. + if (SKIP_FILES.has(path.basename(abs).toLowerCase())) { + // Lockfiles are not source, but a truncated one must fail the build + // here — before the parse pool starts — rather than being skipped. + if (lockfileKind(path.basename(abs))) assertLockfileFile(abs); + return; + } const lang = langForExtension(path.extname(abs)); if (!lang || !allowLang(lang)) return; found.set(rel, { rel, abs, lang }); diff --git a/src/engine/docs-ingest.ts b/src/engine/docs-ingest.ts index 6308fe6..1da1c5d 100644 --- a/src/engine/docs-ingest.ts +++ b/src/engine/docs-ingest.ts @@ -20,10 +20,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import ignore, { type Ignore } from 'ignore'; import { redactSecrets } from '../core-open/utils/redact.js'; import { nodeId } from './ids.js'; -import { isSkippedDirName, SKIP_FILES } from './discover.js'; +import { isSkippedDirName, loadRootIgnore, SKIP_FILES } from './discover.js'; import type { GraphNode } from '../schema.js'; /** Soft cap on characters stored/embedded per document (keeps index snappy). */ @@ -303,20 +302,6 @@ function toPosix(p: string): string { return p.split(path.sep).join('/'); } -function buildRootIgnore(root: string, exclude: string[]): Ignore { - const ig = ignore(); - const gitignorePath = path.join(root, '.gitignore'); - if (fs.existsSync(gitignorePath)) { - try { - ig.add(fs.readFileSync(gitignorePath, 'utf8')); - } catch { - /* ignore */ - } - } - if (exclude.length) ig.add(exclude); - return ig; -} - /** * Classify a path as project-context (document) or not. Pure — no I/O. * Live secret env files return false. @@ -381,7 +366,7 @@ function isEnvExampleName(baseLower: string): boolean { export function discoverDocs(options: DiscoverDocsOptions): DiscoveredDoc[] { const root = path.resolve(options.root); const maxFiles = options.maxFiles ?? DOC_MAX_FILES; - const rootIg = buildRootIgnore(root, options.exclude ?? []); + const rootIg = loadRootIgnore(root, options.exclude ?? []); const scopeAbs = (options.paths?.length ? options.paths.map((p) => path.resolve(root, p)) : [root]).filter((p) => fs.existsSync(p), ); 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/engine/index-db.ts b/src/engine/index-db.ts index 43b9b93..952f759 100644 --- a/src/engine/index-db.ts +++ b/src/engine/index-db.ts @@ -15,6 +15,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { cacheDir } from './cache.js'; import { loadGraphFileWithSnapshot } from './snapshot.js'; +import { assertReadableGraph } from './serialize.js'; import type { Area, EpistemicTier, @@ -351,7 +352,11 @@ export function loadGraphFromIndex(root: string): VgGraph | null { /** * Load the graph preferring the SQLite index when its corpusHash matches the * committed graph.json provenance (or when only the index exists). Falls back - * to graph.json. Returns null if neither is usable. + * to graph.json. Returns null when neither exists. + * + * Throws `GraphLoadError` when the on-disk map (or, with no JSON beside it, + * the index) is truncated or not a schema this vg reads. A schema-mismatched + * index is ignored when the canonical JSON is readable. */ export function loadGraphPreferIndex( root: string, @@ -359,15 +364,21 @@ export function loadGraphPreferIndex( ): { graph: VgGraph; source: 'index' | 'json' } | null { // Snapshot-first: skips the large-string JSON.parse when a fresh binary // snapshot exists (sidecar or store-mode standalone), and self-heals the - // sidecar when not (see engine/snapshot.ts). + // sidecar when not (see engine/snapshot.ts). A broken JSON file throws + // here rather than looking like "no map". const jsonGraph: VgGraph | null = loadGraphFileWithSnapshot(graphJsonPath); const fromIndex = loadGraphFromIndex(root); if (fromIndex) { - if (!jsonGraph || fromIndex.provenance.corpusHash === jsonGraph.provenance.corpusHash) { - return { graph: fromIndex, source: 'index' }; + try { + const indexGraph = assertReadableGraph(fromIndex); + if (!jsonGraph || indexGraph.provenance?.corpusHash === jsonGraph.provenance?.corpusHash) { + return { graph: indexGraph, source: 'index' }; + } + // Index stale vs graph.json — prefer canonical JSON and let next build refresh. + } catch (err) { + if (!jsonGraph) throw err; } - // Index stale vs graph.json — prefer canonical JSON and let next build refresh. } if (jsonGraph) return { graph: jsonGraph, source: 'json' }; return null; diff --git a/src/engine/load.ts b/src/engine/load.ts index c62dc63..ad2fe61 100644 --- a/src/engine/load.ts +++ b/src/engine/load.ts @@ -9,7 +9,11 @@ import type { VgGraph } from '../schema.js'; * When `graphPath` is omitted, prefers an existing global-store snapshot, then * the legacy `.vibgrate/graph.json`, matching {@link resolveGraphPath}. * Prefers the SQLite index when its corpusHash matches the committed map - * (faster cold serve on large repos). Returns null if none exists. + * (faster cold serve on large repos). Returns null when no map exists. + * + * Throws `GraphLoadError` when a map file is present but truncated, not valid + * JSON, or not a schema this version of vg can read. The message says what + * failed and to rebuild with `vg build`; it does not include file contents. */ export function loadGraph(root: string, graphPath?: string): VgGraph | null { const file = resolveGraphPath(root, graphPath); diff --git a/src/engine/lockfile.test.ts b/src/engine/lockfile.test.ts index d52d644..0428254 100644 --- a/src/engine/lockfile.test.ts +++ b/src/engine/lockfile.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { discover } from './discover.js'; +import { LockfileParseError } from '../core-open/utils/lockfile-parse.js'; import { lockfileVersion, fullDependencyTree, fullDependencyGraph } from './lockfile.js'; /** @@ -74,11 +76,34 @@ describe('lockfileVersion — pnpm', () => { expect(lockfileVersion(root, 'npm', 'not-a-dependency')).toBeUndefined(); }); - it('never throws on a truncated or non-YAML lockfile', () => { + it('returns undefined when a valid lockfile does not finish the dependency entry', () => { write('pnpm-lock.yaml', ['importers:', ' .:', ' dependencies:', ' commander:', ''].join('\n')); expect(lockfileVersion(root, 'npm', 'commander')).toBeUndefined(); - write('pnpm-lock.yaml', ' not yaml at all'); - expect(lockfileVersion(root, 'npm', 'commander')).toBeUndefined(); + }); + + it('rejects a truncated pnpm lockfile instead of a partial graph', () => { + const secret = 'npm_AAAAAAAAAAAAAAAAAAAA'; + write( + 'pnpm-lock.yaml', + [ + "lockfileVersion: '9.0'", + 'packages:', + ' commander@15.0.0:', + ' resolution: {integrity: sha512-abc}', + ' chalk@5.0.0:', + ` resolution: {integrity: ${secret}`, + ].join('\n'), + ); + expect(() => fullDependencyGraph(root)).toThrow(LockfileParseError); + try { + fullDependencyGraph(root); + } catch (err) { + const message = (err as Error).message; + expect(message).toContain('pnpm-lock.yaml'); + expect(message).toContain('truncated or invalid YAML'); + expect(message).toContain('package manager'); + expect(message).not.toContain(secret); + } }); it('does not confuse a name that is a prefix of another', () => { @@ -218,9 +243,26 @@ describe('fullDependencyTree', () => { expect(fullDependencyTree(root)).toBeUndefined(); }); - it('never throws on a malformed package-lock.json', () => { - write('package-lock.json', '{ not valid json'); - expect(fullDependencyTree(root)).toBeUndefined(); + it('rejects a truncated package-lock.json instead of an empty graph', () => { + const secret = 'npm_BBBBBBBBBBBBBBBBBBBB'; + write('package-lock.json', `{"lockfileVersion":3,"packages":{"node_modules/left-pad":{"version":"1.0.0","integrity":"${secret}"`); + expect(() => fullDependencyTree(root)).toThrow(LockfileParseError); + try { + fullDependencyGraph(root); + } catch (err) { + const message = (err as Error).message; + expect(message).toContain(path.join(root, 'package-lock.json')); + expect(message).toContain('truncated or invalid JSON'); + expect(message).not.toContain(secret); + expect(message).not.toContain('left-pad'); + } + }); + + it('does not fall through to another lockfile when package-lock.json is truncated', () => { + write('package-lock.json', '{"lockfileVersion":3,"packages":{'); + write('yarn.lock', ['commander@^15.0.0:', ' version "15.2.1"', ''].join('\n')); + expect(() => fullDependencyGraph(root)).toThrow(/package-lock\.json/); + expect(() => fullDependencyGraph(root)).toThrow(/truncated or invalid JSON/); }); }); @@ -270,6 +312,12 @@ describe('fullDependencyGraph', () => { expect(fullDependencyGraph(root)).toBeUndefined(); }); + it('discover fails a truncated lockfile before returning source files', () => { + write('package-lock.json', '{"name":"app","packages":{'); + write('app.ts', 'export const n = 1;\n'); + expect(() => discover({ root })).toThrow(LockfileParseError); + }); + it('resolves an npm alias install (`"foo-cjs": "npm:foo@^1.0.0"`) to its real registry name, not the install-path segment', () => { // e.g. wrap-ansi-cjs/string-width-cjs, used by @isaacs/cliui for a dual // CJS/ESM install of the same package under two directory names. diff --git a/src/engine/lockfile.ts b/src/engine/lockfile.ts index ad4e128..94ec0a6 100644 --- a/src/engine/lockfile.ts +++ b/src/engine/lockfile.ts @@ -1,15 +1,54 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { + assertLockfileText, + lockfileKind, + LockfileParseError, + parseLockfileJson, +} from '../core-open/utils/lockfile-parse.js'; import type { DepRecord } from './drift.js'; +/** + * Read a lockfile that may be absent. A missing file is `undefined` (the + * caller falls through). A truncated or invalid file throws + * {@link LockfileParseError} — never a partial graph, and never the file's + * contents (lockfiles can embed registry tokens). + */ +function readOptionalText(abs: string): string | undefined { + try { + return fs.readFileSync(abs, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw new LockfileParseError(abs, 'unreadable'); + } +} + +function readCheckedText(root: string, file: string): string | undefined { + const abs = path.join(root, file); + const text = readOptionalText(abs); + if (text === undefined) return undefined; + const kind = lockfileKind(file); + if (kind) assertLockfileText(abs, text, kind); + return text; +} + +function readCheckedJson(root: string, file: string): unknown | undefined { + const abs = path.join(root, file); + const text = readOptionalText(abs); + if (text === undefined) return undefined; + return parseLockfileJson(abs, text); +} + /** * Lockfile-pinned version resolution (VG-LIB-SUPERSET-PLAN A.2 / D13). * * The version we serve docs for should be the one your **lockfile** pins, not * whatever happens to be unpacked in `node_modules` (which is empty in CI / a * fresh clone). This reads the pin deterministically and offline. Where a lockfile - * isn't present or parseable we return `undefined` and the caller falls back to the + * isn't present we return `undefined` and the caller falls back to the * installed tree, then the declared range — we never fabricate a version. + * A file that exists but is truncated or syntactically invalid throws + * {@link LockfileParseError} instead of pretending the pin is absent. * * npm covers `package-lock.json` (v1/v2/v3), `pnpm-lock.yaml` (v6/v9) and * `yarn.lock`. Other ecosystems follow the same shape and return `undefined` @@ -30,27 +69,20 @@ export function lockfileVersion(root: string, ecosystem: DepRecord['ecosystem'], /** Gradle `gradle.lockfile` — lines `group:artifact:version=configurations…`. */ function gradleLock(root: string, name: string): string | undefined { - let text: string; - try { - text = fs.readFileSync(path.join(root, 'gradle.lockfile'), 'utf8'); - } catch { - return undefined; - } + const text = readCheckedText(root, 'gradle.lockfile'); + if (text === undefined) return undefined; const m = new RegExp(`^${escapeRegExp(name)}:([^=\\s]+)=`, 'm').exec(text); return m ? m[1] : undefined; } /** Swift `Package.resolved` — JSON pins (v2/v3 `pins[]`, v1 `object.pins[]`); match `identity`. */ function packageResolved(root: string, name: string): string | undefined { - let data: { + const parsed = readCheckedJson(root, 'Package.resolved'); + if (!parsed || typeof parsed !== 'object') return undefined; + const data = parsed as { pins?: Array<{ identity?: string; package?: string; state?: { version?: string } }>; object?: { pins?: Array<{ identity?: string; package?: string; state?: { version?: string } }> }; }; - try { - data = JSON.parse(fs.readFileSync(path.join(root, 'Package.resolved'), 'utf8')); - } catch { - return undefined; - } const pins = data.pins ?? data.object?.pins; if (!Array.isArray(pins)) return undefined; const target = name.toLowerCase(); @@ -63,12 +95,8 @@ function packageResolved(root: string, name: string): string | undefined { /** Dart `pubspec.lock` — YAML; each ` :` block carries `version: "x.y.z"`. */ function pubspecLock(root: string, name: string): string | undefined { - let text: string; - try { - text = fs.readFileSync(path.join(root, 'pubspec.lock'), 'utf8'); - } catch { - return undefined; - } + const text = readCheckedText(root, 'pubspec.lock'); + if (text === undefined) return undefined; let inPkg = false; for (const line of text.split('\n')) { const key = /^ ([A-Za-z0-9_.]+):\s*$/.exec(line); @@ -90,12 +118,8 @@ function escapeRegExp(s: string): string { /** Ruby `Gemfile.lock` — the `specs:` block lists ` name (1.2.3)` (4-space indent). */ function gemfileLock(root: string, name: string): string | undefined { - let text: string; - try { - text = fs.readFileSync(path.join(root, 'Gemfile.lock'), 'utf8'); - } catch { - return undefined; - } + const text = readCheckedText(root, 'Gemfile.lock'); + if (text === undefined) return undefined; // Spec definitions are 4-space-indented with a concrete (digit-leading) version; // nested dep constraints (6-space) and the DEPENDENCIES list (2-space) don't match. const m = new RegExp(`^ ${escapeRegExp(name)} \\((\\d[^)]*)\\)`, 'm').exec(text); @@ -104,12 +128,9 @@ function gemfileLock(root: string, name: string): string | undefined { /** PHP `composer.lock` — JSON `packages` / `packages-dev` arrays of `{ name, version }`. */ function composerLock(root: string, name: string): string | undefined { - let data: Record>; - try { - data = JSON.parse(fs.readFileSync(path.join(root, 'composer.lock'), 'utf8')); - } catch { - return undefined; - } + const parsed = readCheckedJson(root, 'composer.lock'); + if (!parsed || typeof parsed !== 'object') return undefined; + const data = parsed as Record>; for (const section of ['packages', 'packages-dev']) { const arr = data[section]; if (!Array.isArray(arr)) continue; @@ -121,12 +142,9 @@ function composerLock(root: string, name: string): string | undefined { /** .NET `packages.lock.json` — `dependencies...resolved` (ids case-insensitive). */ function packagesLock(root: string, name: string): string | undefined { - let data: { dependencies?: Record> }; - try { - data = JSON.parse(fs.readFileSync(path.join(root, 'packages.lock.json'), 'utf8')); - } catch { - return undefined; - } + const parsed = readCheckedJson(root, 'packages.lock.json'); + if (!parsed || typeof parsed !== 'object') return undefined; + const data = parsed as { dependencies?: Record> }; const target = name.toLowerCase(); for (const fw of Object.values(data.dependencies ?? {})) { if (!fw || typeof fw !== 'object') continue; @@ -162,12 +180,8 @@ function pypiLockVersion(root: string, name: string): string | undefined { * header and match `name`/`version` string keys within a block. */ function tomlPackageLock(root: string, file: string, name: string, normalize: (s: string) => string): string | undefined { - let text: string; - try { - text = fs.readFileSync(path.join(root, file), 'utf8'); - } catch { - return undefined; - } + const text = readCheckedText(root, file); + if (text === undefined) return undefined; const target = normalize(name); for (const block of text.split(/\[\[package\]\]/)) { const nm = /(?:^|\n)\s*name\s*=\s*"([^"]+)"/.exec(block); @@ -181,12 +195,9 @@ function tomlPackageLock(root: string, file: string, name: string, normalize: (s /** Pipenv `Pipfile.lock` — JSON; versions look like `"==1.2.3"`. */ function pipfileLock(root: string, name: string): string | undefined { - let data: Record>; - try { - data = JSON.parse(fs.readFileSync(path.join(root, 'Pipfile.lock'), 'utf8')); - } catch { - return undefined; - } + const parsed = readCheckedJson(root, 'Pipfile.lock'); + if (!parsed || typeof parsed !== 'object') return undefined; + const data = parsed as Record>; const target = pep503(name); for (const section of ['default', 'develop']) { const deps = data[section]; @@ -235,7 +246,8 @@ export interface LockfileGraph { * design. An SBOM needs the opposite shape: the whole installed graph, since * that is what a vulnerability scanner or supply-chain review actually * walks. Tries npm, then pnpm, then yarn; returns `undefined` when none is - * present or parseable — honest degradation, same as `lockfileVersion`. + * present. A lockfile that exists but is truncated or invalid throws + * {@link LockfileParseError} — a partial prefix is not a graph. */ export function fullDependencyTree(root: string): LockfileComponent[] | undefined { return fullDependencyGraph(root)?.components; @@ -288,15 +300,12 @@ interface NpmV2Package { * `packages` encodes without needing a second (real) install. */ function npmLockGraph(root: string): LockfileGraph | undefined { - let data: { + const parsed = readCheckedJson(root, 'package-lock.json'); + if (!parsed || typeof parsed !== 'object') return undefined; + const data = parsed as { packages?: Record; dependencies?: Record }>; }; - try { - data = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8')); - } catch { - return undefined; - } if (!data.packages || typeof data.packages !== 'object') { if (!data.dependencies || typeof data.dependencies !== 'object') return undefined; @@ -375,12 +384,8 @@ function walkNpmV1Tree( * (`/name/version:` / `/@scope/name/version:`), peer suffixes stripped. */ function pnpmLockTree(root: string): LockfileComponent[] | undefined { - let text: string; - try { - text = fs.readFileSync(path.join(root, 'pnpm-lock.yaml'), 'utf8'); - } catch { - return undefined; - } + const text = readCheckedText(root, 'pnpm-lock.yaml'); + if (text === undefined) return undefined; const section = sectionOf(text, 'packages'); if (!section) return undefined; const out = new Map(); @@ -399,12 +404,8 @@ function pnpmLockTree(root: string): LockfileComponent[] | undefined { /** `yarn.lock` — every block's header name(s) paired with its resolved `version`. */ function yarnLockTree(root: string): LockfileComponent[] | undefined { - let text: string; - try { - text = fs.readFileSync(path.join(root, 'yarn.lock'), 'utf8'); - } catch { - return undefined; - } + const text = readCheckedText(root, 'yarn.lock'); + if (text === undefined) return undefined; const out = new Map(); let pendingNames: string[] = []; for (const line of text.split('\n')) { @@ -470,12 +471,8 @@ function yarnHeaderRealName(spec: string): string | undefined { * duplicating for a components-only SBOM listing. */ function cargoLockTree(root: string): LockfileComponent[] | undefined { - let text: string; - try { - text = fs.readFileSync(path.join(root, 'Cargo.lock'), 'utf8'); - } catch { - return undefined; - } + const text = readCheckedText(root, 'Cargo.lock'); + if (text === undefined) return undefined; const out = new Map(); for (const block of text.split(/\[\[package\]\]/)) { const nm = /(?:^|\n)\s*name\s*=\s*"([^"]+)"/.exec(block); @@ -492,12 +489,8 @@ function cargoLockTree(root: string): LockfileComponent[] | undefined { * go.sum records the flattened build list, not which module required which. */ function goSumTree(root: string): LockfileComponent[] | undefined { - let text: string; - try { - text = fs.readFileSync(path.join(root, 'go.sum'), 'utf8'); - } catch { - return undefined; - } + const text = readCheckedText(root, 'go.sum'); + if (text === undefined) return undefined; const out = new Map(); for (const line of text.split('\n')) { // Each module has two lines — `module version h1:...` (the module zip) @@ -517,12 +510,8 @@ function goSumTree(root: string): LockfileComponent[] | undefined { * in full for the SBOM's transitive component list. */ function poetryLikeLockTree(root: string, file: string): LockfileComponent[] | undefined { - let text: string; - try { - text = fs.readFileSync(path.join(root, file), 'utf8'); - } catch { - return undefined; - } + const text = readCheckedText(root, file); + if (text === undefined) return undefined; const out = new Map(); for (const block of text.split(/\[\[package\]\]/)) { const nm = /(?:^|\n)\s*name\s*=\s*"([^"]+)"/.exec(block); @@ -534,12 +523,9 @@ function poetryLikeLockTree(root: string, file: string): LockfileComponent[] | u /** npm `package-lock.json` — JSON, deterministic, no dependency. */ function packageLockVersion(root: string, name: string): string | undefined { - let data: { packages?: Record; dependencies?: Record }; - try { - data = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8')); - } catch { - return undefined; - } + const parsed = readCheckedJson(root, 'package-lock.json'); + if (!parsed || typeof parsed !== 'object') return undefined; + const data = parsed as { packages?: Record; dependencies?: Record }; // v2/v3: packages keyed by install path; the top-level dep is "node_modules/". const top = data.packages?.[`node_modules/${name}`]?.version; if (typeof top === 'string') return top; @@ -562,12 +548,8 @@ function packageLockVersion(root: string, name: string): string | undefined { * other reader here — a lockfile is megabytes and only one line is wanted. */ function pnpmLockVersion(root: string, name: string): string | undefined { - let text: string; - try { - text = fs.readFileSync(path.join(root, 'pnpm-lock.yaml'), 'utf8'); - } catch { - return undefined; - } + const text = readCheckedText(root, 'pnpm-lock.yaml'); + if (text === undefined) return undefined; const importers = sectionOf(text, 'importers'); if (!importers) return undefined; // Entries look like: @@ -600,12 +582,8 @@ function sectionOf(text: string, key: string): string | undefined { * return its version. Scoped names (`@scope/pkg@range`) are handled via lastIndexOf. */ function yarnLockVersion(root: string, name: string): string | undefined { - let text: string; - try { - text = fs.readFileSync(path.join(root, 'yarn.lock'), 'utf8'); - } catch { - return undefined; - } + const text = readCheckedText(root, 'yarn.lock'); + if (text === undefined) return undefined; let inBlock = false; for (const line of text.split('\n')) { if (line && !/^\s/.test(line) && !line.startsWith('#')) { diff --git a/src/engine/manifests.ts b/src/engine/manifests.ts index 9e79583..cc73b3c 100644 --- a/src/engine/manifests.ts +++ b/src/engine/manifests.ts @@ -20,10 +20,10 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import ignore, { type Ignore } from 'ignore'; +import type { Ignore } from 'ignore'; import { XMLParser } from 'fast-xml-parser'; import { nodeId, edgeId } from './ids.js'; -import { isSkippedDirName } from './discover.js'; +import { isSkippedDirName, loadRootIgnore } from './discover.js'; import { parseToml } from '../core-open/utils/toml.js'; import type { GraphEdge, GraphNode } from '../schema.js'; @@ -65,7 +65,7 @@ export function extractManifests( opts: { exclude?: string[]; paths?: string[] } = {}, ): ManifestExtract { const absRoot = path.resolve(root); - const ig = buildRootIgnore(absRoot, opts.exclude ?? []); + const ig = loadRootIgnore(absRoot, opts.exclude ?? []); const scopes = (opts.paths?.length ? opts.paths : ['.']) .map((p) => path.resolve(absRoot, p)) .filter((p) => fs.existsSync(p)); @@ -450,20 +450,6 @@ function addEdge( }); } -function buildRootIgnore(root: string, exclude: string[]): Ignore { - const ig = ignore(); - const gitignorePath = path.join(root, '.gitignore'); - if (fs.existsSync(gitignorePath)) { - try { - ig.add(fs.readFileSync(gitignorePath, 'utf8')); - } catch { - /* ignore */ - } - } - if (exclude.length) ig.add(exclude); - return ig; -} - function walkManifests( root: string, dir: string, diff --git a/src/engine/serialize.ts b/src/engine/serialize.ts index 157e384..72e68de 100644 --- a/src/engine/serialize.ts +++ b/src/engine/serialize.ts @@ -1,4 +1,88 @@ -import type { VgGraph } from '../schema.js'; +import { SUPPORTED_SCHEMA_VERSIONS, type SupportedSchemaVersion, type VgGraph } from '../schema.js'; +import { CliError, ExitCode } from '../util/exit.js'; + +const REBUILD_HINT = 'Rebuild it with `vg build`.'; + +const SUPPORTED_SCHEMA = new Set(SUPPORTED_SCHEMA_VERSIONS); + +/** + * A code map on disk cannot be loaded. + * + * The message is the operator-facing error: what failed, and how to rebuild. + * It never includes file contents, parser excerpts, or any other bytes from + * the artifact (those can carry credentials). `code` is {@link ExitCode.ERROR} + * so a command that lets this propagate exits non-zero. + */ +export class GraphLoadError extends CliError { + readonly isGraphLoadError = true; + readonly kind: 'corrupt' | 'schema'; + + constructor(message: string, kind: 'corrupt' | 'schema') { + super(message, ExitCode.ERROR); + this.name = 'GraphLoadError'; + this.kind = kind; + } +} + +function supportedSchemaList(): string { + const versions = SUPPORTED_SCHEMA_VERSIONS; + if (versions.length <= 1) return versions.join(''); + return `${versions.slice(0, -1).join(', ')} or ${versions[versions.length - 1]}`; +} + +/** A schema token we are willing to echo. Anything else stays out of the message. */ +function echoableSchema(value: unknown): string | null { + if (typeof value !== 'string' || value.length > 32) return null; + return /^vg-graph\/\d{1,4}\.\d{1,4}$/.test(value) ? value : null; +} + +function isSupportedSchema(value: unknown): value is SupportedSchemaVersion { + return typeof value === 'string' && SUPPORTED_SCHEMA.has(value); +} + +function corruptMessage(): string { + return `The code map is truncated or not valid JSON. ${REBUILD_HINT}`; +} + +function unreadableMessage(): string { + return `The code map could not be read. ${REBUILD_HINT}`; +} + +function shapeMessage(): string { + return `The code map is not a readable code map. ${REBUILD_HINT}`; +} + +function schemaMessage(version: unknown): string { + const echoed = echoableSchema(version); + const got = echoed + ? `schema \`${echoed}\`` + : 'a schema this version of vg cannot read'; + return `The code map uses ${got} (this version reads ${supportedSchemaList()}). ${REBUILD_HINT}`; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Accept a decoded map, or throw {@link GraphLoadError}. + * Checks version and shape only — it does not walk nodes or edges. + */ +export function assertReadableGraph(value: unknown): VgGraph { + if (!isPlainObject(value)) throw new GraphLoadError(shapeMessage(), 'corrupt'); + if (!isSupportedSchema(value.schemaVersion)) { + throw new GraphLoadError(schemaMessage(value.schemaVersion), 'schema'); + } + if (!Array.isArray(value.nodes) || !Array.isArray(value.edges)) { + throw new GraphLoadError(shapeMessage(), 'corrupt'); + } + return value as unknown as VgGraph; +} + +/** Read failed for a reason other than "the file is not there". */ +export function unreadableGraphError(): GraphLoadError { + return new GraphLoadError(unreadableMessage(), 'corrupt'); +} /** * Deterministic serialization of `graph.json`. @@ -51,6 +135,19 @@ function sortKeys(value: unknown): unknown { return value; } +/** + * Parse a code-map JSON document. + * + * Throws {@link GraphLoadError} when the text is truncated or not JSON, or + * when the document is not a schema this version of vg can read. The message + * names the failure and how to rebuild; it does not include the document. + */ export function parseGraph(json: string): VgGraph { - return JSON.parse(json) as VgGraph; + let value: unknown; + try { + value = JSON.parse(json); + } catch { + throw new GraphLoadError(corruptMessage(), 'corrupt'); + } + return assertReadableGraph(value); } diff --git a/src/engine/snapshot.ts b/src/engine/snapshot.ts index 887913d..30060aa 100644 --- a/src/engine/snapshot.ts +++ b/src/engine/snapshot.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import * as zlib from 'node:zlib'; import { Packr } from 'msgpackr'; -import { parseGraph } from './serialize.js'; +import { assertReadableGraph, parseGraph, unreadableGraphError } from './serialize.js'; import { VERSION } from '../version.js'; import type { VgGraph } from '../schema.js'; @@ -216,24 +216,33 @@ function readSnapshotHeader(file: string): SnapshotHeader | null { * Load a map file, preferring the binary snapshot and self-healing it. * Fast path: valid snapshot (sidecar or standalone) → decode it. Fallback: * parse `graph.json`, then (best-effort) rewrite the sidecar so the next load - * takes the fast path. Returns null only when neither representation yields a - * graph — exactly the cases the pre-snapshot code treated as "no graph". + * takes the fast path. + * + * Returns null only when no map file exists. A file that is present but + * truncated, not JSON, or not a schema this vg reads throws GraphLoadError + * — absence and a broken map are different outcomes. + * A sidecar that fails that check is ignored when canonical JSON is present, + * so a stale snapshot cannot hide a readable `graph.json`. */ export function loadGraphFileWithSnapshot(graphPath: string): VgGraph | null { const snap = readGraphSnapshot(graphPath); - if (snap) return snap; + if (snap) { + try { + return assertReadableGraph(snap); + } catch (err) { + // Standalone snapshots are the map. A sidecar can fall through to JSON. + if (!fs.existsSync(graphPath)) throw err; + } + } let json: string; try { json = fs.readFileSync(graphPath, 'utf8'); - } catch { - return null; - } - let graph: VgGraph; - try { - graph = parseGraph(json); - } catch { - return null; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return null; + throw unreadableGraphError(); } + const graph = parseGraph(json); writeGraphSnapshot(graphPath, graph); return graph; } diff --git a/src/engine/truncated-lockfile.cli.test.ts b/src/engine/truncated-lockfile.cli.test.ts new file mode 100644 index 0000000..2011f15 --- /dev/null +++ b/src/engine/truncated-lockfile.cli.test.ts @@ -0,0 +1,78 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect, afterEach } from 'vitest'; + +/** + * A truncated lockfile must fail the CLI: non-zero exit, the lockfile path, + * and a stable reason. The message must not echo file contents. + */ +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const cli = path.join(pkgRoot, 'src/cli.ts'); +const secret = 'npm_DDDDDDDDDDDDDDDDDDDD'; + +function fixture(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-trunc-lock-')); + fs.writeFileSync( + path.join(dir, 'package-lock.json'), + `{"name":"fixture","lockfileVersion":3,"packages":{"node_modules/left-pad":{"version":"1.0.0","integrity":"${secret}"`, + ); + fs.writeFileSync(path.join(dir, 'app.ts'), 'export const n = 1;\n'); + return dir; +} + +function run(dir: string, command: 'scan' | 'build'): { status: number | null; signal: NodeJS.Signals | null; stderr: string; error?: Error } { + // `-C` makes the fixture the project root. A path argument alone is a scope + // inside the process cwd, so a lockfile outside that cwd would be skipped. + const args = + command === 'scan' + ? [cli, 'scan', dir, '--offline', '--no-graph', '--no-daemon', '--quiet'] + : [cli, 'build', '-C', dir, '--no-warm', '--no-publish', '--offline', '--no-daemon']; + const res = spawnSync(process.execPath, ['--import', 'tsx', ...args], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: 45_000, + env: { + ...process.env, + NO_COLOR: '1', + VIBGRATE_NO_KERNEL: '1', + VIBGRATE_DSN: '', + }, + }); + return { status: res.status, signal: res.signal, stderr: res.stderr ?? '', error: res.error }; +} + +describe('truncated lockfile CLI', () => { + const dirs: string[] = []; + afterEach(() => { + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('vg scan exits non-zero with the lockfile path and no file contents', () => { + const dir = fixture(); + dirs.push(dir); + const res = run(dir, 'scan'); + expect(res.error).toBeUndefined(); + expect(res.signal).toBeNull(); + expect(res.status).toBe(1); + expect(res.stderr).toContain('package-lock.json'); + expect(res.stderr).toContain('truncated or invalid JSON'); + expect(res.stderr).toContain('package manager'); + expect(res.stderr).not.toContain(secret); + expect(res.stderr).not.toContain('left-pad'); + }, 60_000); + + it('vg build exits non-zero before parse workers stay up', () => { + const dir = fixture(); + dirs.push(dir); + const res = run(dir, 'build'); + expect(res.error).toBeUndefined(); + expect(res.signal).toBeNull(); + expect(res.status).toBe(1); + expect(res.stderr).toContain('package-lock.json'); + expect(res.stderr).toContain('truncated or invalid JSON'); + expect(res.stderr).not.toContain(secret); + }, 60_000); +}); diff --git a/src/index.ts b/src/index.ts index 88d4fa2..c0c4fe5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,7 @@ export { resolveLimits, ResourceLimitError } from './engine/limits.js'; export type { ResourceLimits } from './engine/limits.js'; export { loadGraph } from './engine/load.js'; -export { serializeGraph, parseGraph, stableStringify } from './engine/serialize.js'; +export { serializeGraph, parseGraph, stableStringify, GraphLoadError } from './engine/serialize.js'; export { writeArtifacts, defaultGraphPath, diff --git a/src/lsp/server.ts b/src/lsp/server.ts index f59d95f..4366060 100644 --- a/src/lsp/server.ts +++ b/src/lsp/server.ts @@ -52,6 +52,7 @@ import { fileRolesFromParseCache } from '../engine/ast-roles.js'; import type { AstRoleHit } from '../core-open/scanners/architecture/ast-roles.js'; import { boundaryProfileRules } from '../core-open/scanners/architecture/graph-refine.js'; import { loadGraph } from '../engine/load.js'; +import { GraphLoadError } from '../engine/serialize.js'; import { architectureSidecarSummary, loadArchitectureCallables, @@ -101,6 +102,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 +998,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 +1016,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 +1261,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 [ { @@ -1493,12 +1512,18 @@ export class VibgrateLanguageServer { * ever rewrite it — and never both at once. */ private reloadGraphFromDisk(): void { - const graphPath = resolveGraphPath(this.opts.root); - const reloaded = loadGraphPreferIndex(this.opts.root, graphPath)?.graph ?? loadGraph(this.opts.root); - if (!reloaded) return; - this.graph = reloaded; - this.fileRoles = fileRolesFromParseCache(this.opts.root); - this.refineAndPublishArchitecture(); + try { + const graphPath = resolveGraphPath(this.opts.root); + const reloaded = loadGraphPreferIndex(this.opts.root, graphPath)?.graph ?? loadGraph(this.opts.root); + if (!reloaded) return; + this.graph = reloaded; + this.fileRoles = fileRolesFromParseCache(this.opts.root); + this.refineAndPublishArchitecture(); + } catch (err) { + if (!(err instanceof GraphLoadError)) throw err; + this.conn.notify('window/logMessage', { type: 3, message: err.message }); + this.conn.notify('vibgrate/graph/status', { state: 'error', message: err.message } satisfies GraphStatusNotification); + } } /** @@ -2151,8 +2176,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 +2226,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/cross-impact.test.ts b/src/mcp/cross-impact.test.ts index c558ac6..f01c4e1 100644 --- a/src/mcp/cross-impact.test.ts +++ b/src/mcp/cross-impact.test.ts @@ -35,7 +35,7 @@ function node(id: string, name: string, file: string, over: Partial = function graphWith(nodes: GraphNode[], corpusHash: string): VgGraph { return { - schemaVersion: 1, + schemaVersion: 'vg-graph/1.1', generatedAt: 'x', provenance: { corpusHash }, meta: { root: '.', languages: ['typescript'], counts: { nodes: nodes.length, edges: 0, areas: 1, tests: 0, untested: 0 }, cluster: 'none' }, 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/mcp/server.ts b/src/mcp/server.ts index 42e0c3e..17dce02 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -4,7 +4,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { parseGraph } from '../engine/serialize.js'; +import { GraphLoadError, parseGraph } from '../engine/serialize.js'; import { mapFileStat } from '../engine/snapshot.js'; import { loadGraphPreferIndex } from '../engine/index-db.js'; import type { RefreshOutcome, refreshIfStale } from '../engine/refresh.js'; @@ -570,7 +570,8 @@ export function createServer(source: GraphSource, opts: ServeOptions = {}): Serv let graph: VgGraph; try { graph = await source.get(); - } catch { + } catch (err) { + if (err instanceof GraphLoadError) return errorResult(err.message); return errorResult( 'no code map found. Run `vg` in the project to build .vibgrate/graph.json, then retry.', ); diff --git a/src/mcp/staleness.test.ts b/src/mcp/staleness.test.ts index 7245248..3296a3b 100644 --- a/src/mcp/staleness.test.ts +++ b/src/mcp/staleness.test.ts @@ -16,7 +16,13 @@ function makeSource(refreshImpl: RefreshImpl): { source: GraphSource; root: stri const root = fs.mkdtempSync(path.join(os.tmpdir(), 'staleness-')); const graphPath = path.join(root, '.vibgrate', 'graph.json'); fs.mkdirSync(path.dirname(graphPath), { recursive: true }); - fs.writeFileSync(graphPath, '{}'); + // A readable map: get() loads it before the refresh probe. An empty object + // is a schema mismatch, not "no map". + fs.writeFileSync(graphPath, JSON.stringify({ + schemaVersion: 'vg-graph/1.1', + nodes: [], + edges: [], + })); const source = new GraphSource(graphPath, true, { root, refreshImpl, probeIntervalMs: 0, refreshBudgetMs: 5_000 }); return { source, root }; } diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 75333fc..01e0531 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -15,7 +15,7 @@ import { coveringTests } from '../engine/test-query.js'; import { loadOrDiscoverFederation } from '../runtime/federation.js'; import { highConfidenceBridges } from '../runtime/bridge-edges.js'; import { repositoryIdFromRoot } from '../runtime/paths.js'; -import { parseGraph } from '../engine/serialize.js'; +import { GraphLoadError, parseGraph } from '../engine/serialize.js'; import { loadVulnerabilities, filterBySeverity, resolvePackageTarget, openFixableAdvisories } from './vuln-data.js'; import { attributedInventory } from './attribution.js'; import { computeUpgradeImpact, getChangelogSignals, type VulnSeverity } from '../core-open/index.js'; @@ -622,8 +622,12 @@ export const TOOLS: VgTool[] = [ graph: { corpusHash: mg.provenance.corpusHash }, matches, }; - } catch { - return { member: m.label, root: m.root, via, graph: null, note: 'member map unreadable — rebuild with `vg`' }; + } catch (err) { + // Never forward a parser message: it can quote bytes from the file. + const note = err instanceof GraphLoadError + ? err.message + : 'member map unreadable — rebuild with `vg build`'; + return { member: m.label, root: m.root, via, graph: null, note }; } }); @@ -792,6 +796,7 @@ export const TOOLS: VgTool[] = [ cve: a.aliases.find((x) => x.startsWith('CVE-')) ?? null, severity: a.severity, cvss: a.cvss, + ...(a.cvssDiagnostic ? { cvssDiagnostic: a.cvssDiagnostic } : {}), exposureDays: a.exposureDays ?? null, introduced: a.introduced ?? null, fixedVersions: a.fixedVersions, @@ -831,6 +836,7 @@ export const TOOLS: VgTool[] = [ cve: a.aliases.find((x) => x.startsWith('CVE-')) ?? null, severity: a.severity, cvss: a.cvss, + ...(a.cvssDiagnostic ? { cvssDiagnostic: a.cvssDiagnostic } : {}), fixedVersions: a.fixedVersions, summary: a.summary, })), 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..ef2612f 100644 --- a/src/reporting/commands/sbom.test.ts +++ b/src/reporting/commands/sbom.test.ts @@ -2,7 +2,8 @@ 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 { LICENSE_PARSE_FAILED } from '../../core-open/licenses/diagnostic.js'; import type { ProjectScan, ScanArtifact } from '../types.js'; import type { LockfileGraph } from '../../engine/lockfile.js'; @@ -232,6 +233,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(() => { @@ -262,6 +370,54 @@ describe('sbom helpers', () => { }); }); + it('repeats a license-parse diagnostic in SPDX and CycloneDX, and omits it when the license is explicitly unknown', () => { + const plain = makeArtifact('5.3.0', 90); + expect((toSpdx(plain) as { annotations?: unknown }).annotations).toBeUndefined(); + expect((toCycloneDx(plain) as { metadata: { properties?: unknown } }).metadata.properties).toBeUndefined(); + + const message = + 'Could not resolve SPDX license "not-a-real-license" for bad at apps/web. Replace it with a canonical SPDX id or expression, or NOASSERTION if the license is intentionally unknown.'; + const withFailure = makeArtifact('5.3.0', 90); + withFailure.findings = [ + { + ruleId: 'vibgrate/dependency-rot', + level: 'warning', + message: 'unrelated', + location: 'apps/web', + }, + { + ruleId: LICENSE_PARSE_FAILED, + level: 'warning', + message, + location: 'apps/web', + details: { raw: 'not-a-real-license' }, + }, + ]; + + const spdx = toSpdx(withFailure) as { + annotations: Array<{ annotationType: string; annotationDate: string; comment: string }>; + }; + expect(spdx.annotations).toEqual([ + { + annotationType: 'OTHER', + annotator: 'Tool: @vibgrate/cli', + annotationDate: '2026-02-19T00:00:00.000Z', + comment: `${LICENSE_PARSE_FAILED}: ${message}`, + }, + ]); + expect(JSON.stringify(spdx)).not.toContain('unrelated'); + + const cdx = toCycloneDx(withFailure) as { + metadata: { properties: Array<{ name: string; value: string }> }; + serialNumber: string; + }; + expect(cdx.metadata.properties).toEqual([ + { name: LICENSE_PARSE_FAILED, value: `apps/web: ${message}` }, + ]); + expect(toCycloneDx(withFailure)).toEqual(cdx); + expect(cdx.serialNumber).not.toBe((toCycloneDx(plain) as { serialNumber: string }).serialNumber); + }); + it('formats dependency deltas', () => { const base = makeArtifact('5.2.0', 80); const current = makeArtifact('5.3.0', 76); diff --git a/src/reporting/commands/sbom.ts b/src/reporting/commands/sbom.ts index c560d2f..abdc373 100644 --- a/src/reporting/commands/sbom.ts +++ b/src/reporting/commands/sbom.ts @@ -2,9 +2,10 @@ import * as path from 'node:path'; import { Command } from 'commander'; import chalk from 'chalk'; import { pathExists, readJsonFile, writeTextFile } from '../utils/fs.js'; -import type { DependencyRow, ProjectScan, ScanArtifact } from '../types.js'; +import type { DependencyRow, Finding, ProjectScan, ScanArtifact } from '../types.js'; +import { LICENSE_PARSE_FAILED } from '../../core-open/licenses/diagnostic.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 +108,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 +162,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 +174,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 +189,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 } { @@ -181,6 +288,18 @@ function uniqSorted(keys: string[]): string[] { /** Stable, always-present identifier for the SBOM's root/application component. */ const ROOT_BOM_REF = 'vibgrate-root'; +/** + * License-parse findings, in a stable order. Scan stores these on the + * artifact; SBOM export repeats them so a consumer that only reads the SBOM + * still sees the failure instead of a bare NOASSERTION. + */ +function licenseParseFindings(artifact: ScanArtifact): Finding[] { + return artifact.findings + .filter((f) => f.ruleId === LICENSE_PARSE_FAILED) + .slice() + .sort((a, b) => a.location.localeCompare(b.location) || a.message.localeCompare(b.message)); +} + /** Stable seed for the document id: format + root + the ordered dependency set + any dependency graph. */ function sbomSerialSeed(format: string, artifact: ScanArtifact, deps: FlattenedDependency[], graph?: LockfileGraph): string { const edgeLines = graph?.edges @@ -196,6 +315,7 @@ function sbomSerialSeed(format: string, artifact: ScanArtifact, deps: FlattenedD ...deps.map((d) => `${d.package}|${d.version}|${d.currentSpec}|${d.project}|${d.drift}|${d.majorsBehind ?? ''}|${d.scope}`), ...(graph?.rootDependsOn.length ? [`root>${uniqSorted(graph.rootDependsOn).join(',')}`] : []), ...edgeLines, + ...licenseParseFindings(artifact).map((f) => `license-parse|${f.location}|${f.message}`), ].join('\n'); } @@ -211,12 +331,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; } @@ -349,6 +472,7 @@ export function collectLockfileGraph(artifact: ScanArtifact, root: string): Lock export function toCycloneDx(artifact: ScanArtifact, graph?: LockfileGraph): Record { const dependencies = flattenDependencies(artifact, graph?.components ?? [], graph?.ecosystem); const dependencyGraph = cycloneDxDependencyGraph(dependencies, graph); + const licenseNotes = licenseParseFindings(artifact); return { bomFormat: 'CycloneDX', specVersion: '1.5', @@ -368,21 +492,39 @@ export function toCycloneDx(artifact: ScanArtifact, graph?: LockfileGraph): Reco 'bom-ref': ROOT_BOM_REF, name: artifact.rootPath, }, + ...(licenseNotes.length + ? { + properties: licenseNotes.map((f) => ({ + name: LICENSE_PARSE_FAILED, + value: `${f.location}: ${f.message}`, + })), + } + : {}), }, - 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 } : {}), }; } @@ -390,6 +532,7 @@ export function toCycloneDx(artifact: ScanArtifact, graph?: LockfileGraph): Reco export function toSpdx(artifact: ScanArtifact, graph?: LockfileGraph): Record { const dependencies = flattenDependencies(artifact, graph?.components ?? [], graph?.ecosystem); const relationships = spdxRelationships(dependencies, graph); + const licenseNotes = licenseParseFindings(artifact); return { spdxVersion: 'SPDX-2.3', dataLicense: 'CC0-1.0', @@ -400,32 +543,67 @@ 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 } : {}), + ...(licenseNotes.length + ? { + annotations: licenseNotes.map((f) => ({ + annotationType: 'OTHER', + annotator: 'Tool: @vibgrate/cli', + annotationDate: artifact.timestamp, + comment: `${f.ruleId}: ${f.message}`, + })), + } + : {}), }; } +/** 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 +646,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 +699,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-package-manifest.test.ts b/src/reporting/commands/scan-package-manifest.test.ts new file mode 100644 index 0000000..18d4ae5 --- /dev/null +++ b/src/reporting/commands/scan-package-manifest.test.ts @@ -0,0 +1,157 @@ +// `vg scan --package-manifest` must fail closed before a scan when the file +// is missing, unreadable, or not a package-version manifest. +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CliError, ExitCode } from '../../util/exit.js'; +import { scanCommand } from './scan.js'; + +const BODY_SENTINEL = 'manifest-body-sentinel-9f3a2c'; +const NEARBY_SENTINEL = 'nearby-file-sentinel-77ab'; +const ENV_SENTINEL = 'env-sentinel-manifest-4c1e'; +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + +describe('scan --package-manifest fail closed', () => { + let dir: string; + let errorSpy: ReturnType; + let logSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(tmpdir(), 'vg-manifest-scan-')); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 't', version: '1.0.0' })); + fs.writeFileSync(path.join(dir, '.env'), `TOKEN=${NEARBY_SENTINEL}\n`); + vi.stubEnv('VIBGRATE_DSN', ''); + vi.stubEnv('VIBGRATE_NO_KERNEL', '1'); + vi.stubEnv('VIBGRATE_MANIFEST_SENTINEL', ENV_SENTINEL); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code}) called`); + }) as never); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + vi.unstubAllEnvs(); + // A mode-0 manifest is still unlinkable while its directory is writable. + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const run = (args: string[]) => + scanCommand.parseAsync(['node', 'scan', dir, '--offline', '--no-daemon', '--no-graph', '--quiet', '--vulns', ...args]); + + function assertClosed(message: string): void { + expect(message).not.toContain(BODY_SENTINEL); + expect(message).not.toContain(NEARBY_SENTINEL); + expect(message).not.toContain(ENV_SENTINEL); + expect(message).not.toContain('ENOENT'); + expect(message).not.toContain('EACCES'); + expect(fs.existsSync(path.join(dir, '.vibgrate'))).toBe(false); + expect(exitSpy).not.toHaveBeenCalled(); + } + + it('exits with an actionable error when the manifest is missing', async () => { + const missing = path.join(dir, 'missing-package-versions.json'); + const error = await run(['--package-manifest', missing]).then( + () => { + throw new Error('expected a missing manifest to fail the scan'); + }, + (err: unknown) => err, + ); + + expect(error).toBeInstanceOf(CliError); + expect(error).toMatchObject({ + code: ExitCode.ERROR, + message: `Package manifest not found: ${missing}. Pass a readable JSON or ZIP package-version manifest to --package-manifest.`, + }); + assertClosed((error as Error).message); + }); + + it('exits with an actionable error when the manifest is not readable', async () => { + const manifest = path.join(dir, 'package-versions.json'); + fs.writeFileSync(manifest, `{"npm":{},"note":"${BODY_SENTINEL}"}`); + fs.chmodSync(manifest, 0); + + const error = await run(['--package-manifest', manifest]).catch((err: unknown) => err); + expect(error).toBeInstanceOf(CliError); + expect(error).toMatchObject({ + code: ExitCode.ERROR, + message: `Package manifest is not readable: ${manifest}. Check permissions and pass a readable JSON or ZIP package-version manifest to --package-manifest.`, + }); + assertClosed((error as Error).message); + }); + + it('exits with an actionable error when the manifest is not usable', async () => { + const manifest = path.join(dir, 'package-versions.json'); + fs.writeFileSync(manifest, `not-json ${BODY_SENTINEL}`); + + const error = await run(['--package-manifest', manifest]).catch((err: unknown) => err); + expect(error).toBeInstanceOf(CliError); + expect(error).toMatchObject({ + code: ExitCode.ERROR, + message: `Package manifest is not usable: ${manifest}. Expected a JSON object of package versions, or a ZIP containing package-versions.json, manifest.json, or index.json.`, + }); + assertClosed((error as Error).message); + }); + + it('still scans when the manifest is a usable JSON file', async () => { + const manifest = path.join(dir, 'package-versions.json'); + const out = path.join(dir, 'scan.json'); + fs.writeFileSync(manifest, JSON.stringify({ npm: { react: { latest: '19.0.0', versions: ['19.0.0'] } } })); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await run(['--package-manifest', manifest, '--format', 'json', '--out', out]); + expect(fs.existsSync(out)).toBe(true); + expect(exitSpy).not.toHaveBeenCalled(); + } finally { + stderrSpy.mockRestore(); + } + }, 60_000); +}); + +describe('scan --package-manifest process exit', () => { + it('prints the stable error and exits non-zero for a missing manifest', () => { + const dir = fs.mkdtempSync(path.join(tmpdir(), 'vg-manifest-exit-')); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 't', version: '1.0.0' })); + fs.writeFileSync(path.join(dir, '.env'), `TOKEN=${NEARBY_SENTINEL}\n`); + const missing = path.join(dir, 'missing-package-versions.json'); + const tsx = createRequire(import.meta.url).resolve('tsx/cli'); + + try { + const res = spawnSync( + process.execPath, + [tsx, path.join(PACKAGE_ROOT, 'src/cli.ts'), 'scan', dir, '--offline', '--no-daemon', '--no-graph', '--quiet', '--vulns', '--package-manifest', missing], + { + cwd: dir, + encoding: 'utf8', + env: { + ...process.env, + NO_COLOR: '1', + VIBGRATE_DSN: '', + VIBGRATE_NO_KERNEL: '1', + VIBGRATE_MANIFEST_SENTINEL: ENV_SENTINEL, + }, + }, + ); + + expect(res.status).toBe(ExitCode.ERROR); + const stderr = res.stderr ?? ''; + expect(stderr).toContain(`error: Package manifest not found: ${missing}. Pass a readable JSON or ZIP package-version manifest to --package-manifest.`); + expect(stderr).not.toContain(BODY_SENTINEL); + expect(stderr).not.toContain(NEARBY_SENTINEL); + expect(stderr).not.toContain(ENV_SENTINEL); + expect(stderr).not.toContain('ENOENT'); + expect(stderr).not.toMatch(/\n\s+at /); + expect(fs.existsSync(path.join(dir, '.vibgrate'))).toBe(false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 60_000); +}); diff --git a/src/reporting/commands/scan.ts b/src/reporting/commands/scan.ts index 76a4e3b..ae734fb 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'; @@ -40,7 +40,8 @@ import { isUsableHaileSymbol } from '../../engine/haile/format.js'; import { writeSnapshot } from '../../engine/freshness.js'; import { detectAiAssistant, printAiContextPrompt } from '../ai-context-prompt.js'; import { resolveCliInvocation } from '../../util/cli-invocation.js'; -import { usageError } from '../../util/exit.js'; +import { CliError, ExitCode, usageError } from '../../util/exit.js'; +import { loadPackageVersionManifest, PackageManifestError } from '../package-version-manifest.js'; import { runSecurityPacks, type SecurityRunResult } from '../../security/run-packs.js'; import { evaluateSecurityGate, lowestThreshold, parseFailOn } from '../../security/gate.js'; import { securityFindingRow, securityPacksLabel } from '../../core-open/formatters/text.js'; @@ -444,6 +445,20 @@ export const scanCommand = new Command('scan') process.exit(1); } + // Fail closed before any scan work. A missing, unreadable, or unusable + // --package-manifest must stop the command with a stable, actionable + // error — not a stack trace, and not a scan that proceeds without it. + if (opts.packageManifest) { + try { + await loadPackageVersionManifest(opts.packageManifest); + } catch (err) { + const message = err instanceof PackageManifestError + ? err.message + : `Package manifest is not readable: ${path.resolve(opts.packageManifest)}. Check permissions and pass a readable JSON or ZIP package-version manifest to --package-manifest.`; + throw new CliError(message, ExitCode.ERROR); + } + } + // `--fail-on` is parsed up front so a typo is a usage error before a long // scan, not after it. One legacy value keeps its meaning exactly; the // security gates are evaluated after the scan against `extended.security`. @@ -889,19 +904,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 +935,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/package-version-manifest.test.ts b/src/reporting/package-version-manifest.test.ts index 6a642fe..fcba623 100644 --- a/src/reporting/package-version-manifest.test.ts +++ b/src/reporting/package-version-manifest.test.ts @@ -1,16 +1,171 @@ -import { describe, expect, it } from 'vitest'; -import { mkdtemp, writeFile } from 'node:fs/promises'; -import * as path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; import * as os from 'node:os'; -import { loadPackageVersionManifest } from './package-version-manifest.js'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { loadPackageVersionManifest, PackageManifestError } from './package-version-manifest.js'; + +const BODY_SENTINEL = 'manifest-body-sentinel-9f3a2c'; +const NEARBY_SENTINEL = 'nearby-file-sentinel-77ab'; +const ENV_SENTINEL = 'env-sentinel-manifest-4c1e'; + +function notFound(resolved: string): string { + return `Package manifest not found: ${resolved}. Pass a readable JSON or ZIP package-version manifest to --package-manifest.`; +} + +function notReadable(resolved: string): string { + return `Package manifest is not readable: ${resolved}. Check permissions and pass a readable JSON or ZIP package-version manifest to --package-manifest.`; +} + +function notUsable(resolved: string): string { + return `Package manifest is not usable: ${resolved}. Expected a JSON object of package versions, or a ZIP containing package-versions.json, manifest.json, or index.json.`; +} + +function zipNotUsable(resolved: string): string { + return `Package manifest is not usable: ${resolved}. The ZIP must contain package-versions.json, manifest.json, or index.json.`; +} describe('package version manifest loader', () => { + const dirs: string[] = []; + const locked: string[] = []; + + afterEach(async () => { + process.env.VIBGRATE_MANIFEST_SENTINEL = ''; + for (const target of locked) { + await chmod(target, 0o755).catch(() => {}); + } + locked.length = 0; + for (const dir of dirs) { + await chmod(dir, 0o755).catch(() => {}); + await rm(dir, { recursive: true, force: true }); + } + dirs.length = 0; + }); + + async function tempDir(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), 'vibgrate-manifest-test-')); + dirs.push(dir); + return dir; + } + + function assertNoSecrets(message: string): void { + expect(message).not.toContain(BODY_SENTINEL); + expect(message).not.toContain(NEARBY_SENTINEL); + expect(message).not.toContain(ENV_SENTINEL); + expect(message).not.toContain('ENOENT'); + expect(message).not.toContain('EACCES'); + } + it('loads JSON manifest files', async () => { - const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'vibgrate-manifest-test-')); + const tmpDir = await tempDir(); const manifestPath = path.join(tmpDir, 'package-versions.json'); await writeFile(manifestPath, JSON.stringify({ npm: { react: { latest: '19.0.0', versions: ['18.3.1', '19.0.0'] } } })); const manifest = await loadPackageVersionManifest(manifestPath); expect(manifest.npm?.react?.latest).toBe('19.0.0'); }); + + it('loads a ZIP that contains package-versions.json', async () => { + const tmpDir = await tempDir(); + const zipPath = path.join(tmpDir, 'package-versions.zip'); + const body = JSON.stringify({ npm: { react: { latest: '19.0.0' } } }); + execFileSync('python3', [ + '-c', + 'import sys, zipfile; zipfile.ZipFile(sys.argv[1], "w").writestr(sys.argv[2], sys.argv[3])', + zipPath, + 'package-versions.json', + body, + ]); + + const manifest = await loadPackageVersionManifest(zipPath); + expect(manifest.npm?.react?.latest).toBe('19.0.0'); + }); + + it('rejects a missing path with a stable not-found error', async () => { + const tmpDir = await tempDir(); + process.env.VIBGRATE_MANIFEST_SENTINEL = ENV_SENTINEL; + await writeFile(path.join(tmpDir, '.env'), `TOKEN=${NEARBY_SENTINEL}\n`); + const missing = path.join(tmpDir, 'missing-package-versions.json'); + + const error = await loadPackageVersionManifest(missing).then( + () => { + throw new Error('expected the missing manifest to fail'); + }, + (err: unknown) => err, + ); + + expect(error).toBeInstanceOf(PackageManifestError); + expect(error).toMatchObject({ failure: 'not_found', message: notFound(missing) }); + assertNoSecrets((error as Error).message); + }); + + it('rejects an unreadable file without echoing its contents', async () => { + const tmpDir = await tempDir(); + process.env.VIBGRATE_MANIFEST_SENTINEL = ENV_SENTINEL; + await writeFile(path.join(tmpDir, '.env'), `TOKEN=${NEARBY_SENTINEL}\n`); + const manifestPath = path.join(tmpDir, 'package-versions.json'); + await writeFile(manifestPath, `{"npm":{},"note":"${BODY_SENTINEL}"}`); + await chmod(manifestPath, 0); + locked.push(manifestPath); + + await expect(loadPackageVersionManifest(manifestPath)).rejects.toMatchObject({ + failure: 'unreadable', + message: notReadable(manifestPath), + }); + try { + await loadPackageVersionManifest(manifestPath); + } catch (err) { + assertNoSecrets(err instanceof Error ? err.message : String(err)); + } + }); + + it('rejects a directory path as not a file', async () => { + const tmpDir = await tempDir(); + await expect(loadPackageVersionManifest(tmpDir)).rejects.toMatchObject({ + failure: 'unusable', + message: `Package manifest is not a file: ${tmpDir}. Pass a JSON or ZIP package-version manifest to --package-manifest.`, + }); + }); + + it('rejects invalid content without echoing secrets', async () => { + const tmpDir = await tempDir(); + process.env.VIBGRATE_MANIFEST_SENTINEL = ENV_SENTINEL; + await writeFile(path.join(tmpDir, '.env'), `NOTE=${NEARBY_SENTINEL}\n`); + const manifestPath = path.join(tmpDir, 'package-versions.json'); + await writeFile(manifestPath, `not-json ${BODY_SENTINEL}`); + + await expect(loadPackageVersionManifest(manifestPath)).rejects.toMatchObject({ + failure: 'unusable', + message: notUsable(manifestPath), + }); + }); + + it('rejects a JSON value that is not a package-version manifest', async () => { + const tmpDir = await tempDir(); + const manifestPath = path.join(tmpDir, 'package.json'); + await writeFile( + manifestPath, + JSON.stringify({ name: 'demo', version: '1.0.0', dependencies: { react: '^19.0.0' }, note: BODY_SENTINEL }), + ); + + const error = await loadPackageVersionManifest(manifestPath).catch((err: unknown) => err); + expect(error).toMatchObject({ failure: 'unusable', message: notUsable(manifestPath) }); + assertNoSecrets((error as Error).message); + }); + + it('rejects a ZIP that does not contain a package-version manifest', async () => { + const tmpDir = await tempDir(); + const zipPath = path.join(tmpDir, 'package-versions.zip'); + execFileSync('python3', [ + '-c', + 'import sys, zipfile; zipfile.ZipFile(sys.argv[1], "w").writestr(sys.argv[2], sys.argv[3])', + zipPath, + 'readme.txt', + BODY_SENTINEL, + ]); + + const error = await loadPackageVersionManifest(zipPath).catch((err: unknown) => err); + expect(error).toMatchObject({ failure: 'unusable', message: zipNotUsable(zipPath) }); + assertNoSecrets((error as Error).message); + }); }); diff --git a/src/reporting/package-version-manifest.ts b/src/reporting/package-version-manifest.ts index 17841b9..2dab699 100644 --- a/src/reporting/package-version-manifest.ts +++ b/src/reporting/package-version-manifest.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; import { spawn } from 'node:child_process'; @@ -25,66 +25,201 @@ export interface PackageVersionManifest { terraform?: Record; } -function runCommand(cmd: string, args: string[]): Promise { +/** Why a `--package-manifest` path cannot be used. Stable for tests and exit handling. */ +export type PackageManifestFailure = 'not_found' | 'unreadable' | 'unusable'; + +/** + * Fail-closed error for a missing, unreadable, or unusable package-version + * manifest. The message names the path and what to pass instead. It never + * includes file contents, nearby files, or the environment. + */ +export class PackageManifestError extends Error { + readonly failure: PackageManifestFailure; + + constructor(message: string, failure: PackageManifestFailure) { + super(message); + this.name = 'PackageManifestError'; + this.failure = failure; + } +} + +/** Top-level keys a package-version manifest may carry. `runtimes` is optional catalog data. */ +const MANIFEST_KEYS = new Set([ + 'runtimes', + 'npm', + 'nuget', + 'pypi', + 'maven', + 'rubygems', + 'swift', + 'go', + 'cargo', + 'composer', + 'pub', + 'hex', + 'docker', + 'helm', + 'terraform', +]); + +function errnoCode(err: unknown): string | undefined { + if (!err || typeof err !== 'object' || !('code' in err)) return undefined; + const code = (err as { code?: unknown }).code; + return typeof code === 'string' ? code : undefined; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function notFound(resolved: string): PackageManifestError { + return new PackageManifestError( + `Package manifest not found: ${resolved}. Pass a readable JSON or ZIP package-version manifest to --package-manifest.`, + 'not_found', + ); +} + +function notReadable(resolved: string): PackageManifestError { + return new PackageManifestError( + `Package manifest is not readable: ${resolved}. Check permissions and pass a readable JSON or ZIP package-version manifest to --package-manifest.`, + 'unreadable', + ); +} + +function notAFile(resolved: string): PackageManifestError { + return new PackageManifestError( + `Package manifest is not a file: ${resolved}. Pass a JSON or ZIP package-version manifest to --package-manifest.`, + 'unusable', + ); +} + +function notUsable(resolved: string): PackageManifestError { + return new PackageManifestError( + `Package manifest is not usable: ${resolved}. Expected a JSON object of package versions, or a ZIP containing package-versions.json, manifest.json, or index.json.`, + 'unusable', + ); +} + +function zipNotUsable(resolved: string): PackageManifestError { + return new PackageManifestError( + `Package manifest is not usable: ${resolved}. The ZIP must contain package-versions.json, manifest.json, or index.json.`, + 'unusable', + ); +} + +function unzipMissing(resolved: string): PackageManifestError { + return new PackageManifestError( + `Package manifest is not readable: ${resolved}. Reading a ZIP manifest needs the unzip command. Pass a JSON package-version manifest to --package-manifest, or install unzip.`, + 'unreadable', + ); +} + +function ioFailure(err: unknown, resolved: string): PackageManifestError { + const code = errnoCode(err); + if (code === 'ENOENT' || code === 'ENOTDIR') return notFound(resolved); + return notReadable(resolved); +} + +function runCommand(cmd: string, args: string[]): Promise { return new Promise((resolve, reject) => { // windowsHide: also runs from console-less hosts (vgd background // rebuilds) — never flash a console window on Windows. - const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); - let out = ''; - let err = ''; - child.stdout.on('data', (d: Buffer) => (out += String(d))); - child.stderr.on('data', (d: Buffer) => (err += String(d))); + // stdout/stderr are discarded. A failing unzip must not echo archive + // bytes or tool output into the error the user sees. + const child = spawn(cmd, args, { stdio: 'ignore', windowsHide: true }); child.on('error', reject); child.on('close', (code) => { if (code !== 0) { - reject(new Error(`${cmd} ${args.join(' ')} failed (code=${code}): ${err.trim()}`)); + reject(Object.assign(new Error(`${cmd} failed`), { code: 'EUNZIP' })); return; } - resolve(out); + resolve(); }); }); } -async function parseManifestText(text: string, source: string): Promise { +function parseManifestObject(text: string, source: string): PackageVersionManifest { + let value: unknown; try { - return JSON.parse(text) as PackageVersionManifest; + value = JSON.parse(text); } catch { - throw new Error(`Invalid JSON in package version manifest: ${source}`); + throw notUsable(source); + } + if (!isPlainObject(value)) throw notUsable(source); + + const keys = Object.keys(value); + const known = keys.filter((key) => MANIFEST_KEYS.has(key)); + if (keys.length > 0 && known.length === 0) throw notUsable(source); + + for (const key of known) { + const entry = value[key]; + if (entry == null) continue; + if (!isPlainObject(entry)) throw notUsable(source); } + return value as PackageVersionManifest; } async function loadManifestFromZip(zipPath: string): Promise { - const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'vibgrate-manifest-')); + let tmpDir: string; try { - await runCommand('unzip', ['-qq', zipPath, '-d', tmpDir]); + tmpDir = await mkdtemp(path.join(os.tmpdir(), 'vibgrate-manifest-')); + } catch { + throw notReadable(zipPath); + } + try { + try { + await runCommand('unzip', ['-qq', zipPath, '-d', tmpDir]); + } catch (err) { + if (errnoCode(err) === 'ENOENT') throw unzipMissing(zipPath); + throw zipNotUsable(zipPath); + } + const candidates = [ path.join(tmpDir, 'package-versions.json'), path.join(tmpDir, 'manifest.json'), path.join(tmpDir, 'index.json'), ]; - for (const candidate of candidates) { try { const text = await readFile(candidate, 'utf8'); - return await parseManifestText(text, candidate); + return parseManifestObject(text, zipPath); } catch { - // keep searching + // Missing or unusable candidate — try the next well-known name. } } - - throw new Error('Zip must contain package-versions.json, manifest.json, or index.json'); + throw zipNotUsable(zipPath); } finally { await rm(tmpDir, { recursive: true, force: true }); } } +async function loadResolved(resolved: string): Promise { + let info: { isFile(): boolean }; + try { + info = await stat(resolved); + } catch (err) { + throw ioFailure(err, resolved); + } + if (!info.isFile()) throw notAFile(resolved); + if (resolved.toLowerCase().endsWith('.zip')) return loadManifestFromZip(resolved); + + let text: string; + try { + text = await readFile(resolved, 'utf8'); + } catch (err) { + throw ioFailure(err, resolved); + } + return parseManifestObject(text, resolved); +} + export async function loadPackageVersionManifest(filePath: string): Promise { const resolved = path.resolve(filePath); - if (resolved.toLowerCase().endsWith('.zip')) { - return loadManifestFromZip(resolved); + try { + return await loadResolved(resolved); + } catch (err) { + if (err instanceof PackageManifestError) throw err; + throw notReadable(resolved); } - const text = await readFile(resolved, 'utf8'); - return parseManifestText(text, resolved); } export function getManifestEntry( 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/scanners/dependency-graph.test.ts b/src/reporting/scanners/dependency-graph.test.ts index 60a357d..4224d88 100644 --- a/src/reporting/scanners/dependency-graph.test.ts +++ b/src/reporting/scanners/dependency-graph.test.ts @@ -239,11 +239,9 @@ packages: expect(result.duplicatedPackages[1]!.name).toBe('chalk'); }); - it('handles empty lockfile gracefully', async () => { + it('rejects an empty lockfile instead of reporting an empty graph', async () => { await fs.writeFile(path.join(tempDir, 'pnpm-lock.yaml'), ''); - const result = await scanDependencyGraph(tempDir); - expect(result.lockfileType).toBe('pnpm'); - expect(result.totalUnique).toBe(0); - expect(result.totalInstalled).toBe(0); + await expect(scanDependencyGraph(tempDir)).rejects.toThrow(/pnpm-lock\.yaml/); + await expect(scanDependencyGraph(tempDir)).rejects.toThrow(/truncated or invalid YAML/); }); }); diff --git a/src/reporting/scanners/dependency-graph.ts b/src/reporting/scanners/dependency-graph.ts index 192e36d..a446026 100644 --- a/src/reporting/scanners/dependency-graph.ts +++ b/src/reporting/scanners/dependency-graph.ts @@ -1,5 +1,6 @@ import * as path from 'node:path'; import { readTextFile, pathExists, findPackageJsonFiles, readJsonFile, FileCache } from '../../core-open/index.js'; +import { assertLockfileText, parseLockfileJson } from '../../core-open/utils/lockfile-parse.js'; import type { PackageJson, DependencyGraphResult, DuplicatedPackage, PhantomDependency } from '../../core-open/index.js'; interface LockEntry { @@ -28,10 +29,13 @@ function parsePnpmLock(content: string): LockEntry[] { /** * Parse package-lock.json (v2/v3) by reading `packages` or `dependencies` keys. */ -function parseNpmLock(content: string): LockEntry[] { +function parseNpmLock(content: string, filePath: string): LockEntry[] { const entries: LockEntry[] = []; - try { - const lock = JSON.parse(content); + const lock = parseLockfileJson(filePath, content) as { + packages?: Record; + dependencies?: Record }>; + } | null; + if (lock && typeof lock === 'object') { // v2/v3 format: `packages` keyed by path like "node_modules/lodash" if (lock.packages && typeof lock.packages === 'object') { @@ -54,7 +58,7 @@ function parseNpmLock(content: string): LockEntry[] { } walkDeps(lock.dependencies); } - } catch { /* invalid JSON */ } + } return entries; } @@ -96,14 +100,16 @@ export async function scanDependencyGraph(rootDir: string, cache?: FileCache): P if (await _pathExists(pnpmLock)) { result.lockfileType = 'pnpm'; const content = await _readTextFile(pnpmLock); + assertLockfileText(pnpmLock, content, 'YAML'); entries = parsePnpmLock(content); } else if (await _pathExists(npmLock)) { result.lockfileType = 'npm'; const content = await _readTextFile(npmLock); - entries = parseNpmLock(content); + entries = parseNpmLock(content, npmLock); } else if (await _pathExists(yarnLock)) { result.lockfileType = 'yarn'; const content = await _readTextFile(yarnLock); + assertLockfileText(yarnLock, content, 'yarn.lock'); entries = parseYarnLock(content); } 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/glob.ts b/src/reporting/utils/glob.ts index bbc54dd..099da46 100644 --- a/src/reporting/utils/glob.ts +++ b/src/reporting/utils/glob.ts @@ -15,6 +15,20 @@ import * as path from 'node:path'; * work identically on Windows and Unix. */ +/** + * Empty and whitespace-only patterns are not excludes. A newline or + * carriage-return-only rule must never be compiled: that form matches every + * path and hides the whole tree. + */ +function dropBlankPatterns(patterns: readonly string[]): string[] { + const out: string[] = []; + for (const pattern of patterns) { + if (typeof pattern !== 'string' || pattern.trim() === '') continue; + out.push(pattern); + } + return out; +} + /** * Compile an array of glob patterns into a single predicate function * that tests a **relative** path (forward-slash separated). @@ -22,9 +36,10 @@ import * as path from 'node:path'; * Returns `null` if the pattern list is empty (nothing excluded). */ export function compileGlobs(patterns: string[]): ((relPath: string) => boolean) | null { - if (patterns.length === 0) return null; + const usable = dropBlankPatterns(patterns); + if (usable.length === 0) return null; - const matchers = patterns.map((p) => compileOne(normalise(p))); + const matchers = usable.map((p) => compileOne(normalise(p))); return (relPath: string) => { const norm = normalise(relPath); 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/config.ts b/src/review/config.ts index 6602868..49ca15a 100644 --- a/src/review/config.ts +++ b/src/review/config.ts @@ -20,7 +20,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { parseToml } from '../core-open/utils/toml.js'; -import { CONFIG_FILES, isDataConfigFile, parseDataConfig, readDataConfigSync } from '../core-open/config.js'; +import { CONFIG_FILES, isDataConfigFile, parseDataConfig, requireDataConfig } from '../core-open/config.js'; import type { GitRunner } from './git.js'; import type { ReviewEnforcement } from './schemas.js'; @@ -167,12 +167,10 @@ function reviewBlockAtRef(root: string, ref: string, run: GitRunner): { file: st // shadowed one, or base and working tree could disagree about which file // is in force. if (!isDataConfigFile(file)) return undefined; - try { - const doc = parseDataConfig(res.stdout, file); - return doc.review === undefined ? undefined : { file, block: doc.review }; - } catch { - return undefined; - } + // A file that is present but unreadable must not fall through to defaults + // or to a shadowed review.toml — that would review under a weaker policy. + const doc = parseDataConfig(res.stdout, file); + return doc.review === undefined ? undefined : { file, block: doc.review }; } return undefined; } @@ -207,7 +205,7 @@ export function loadReviewConfig( // No git-visible copy (a repo with no commits, or a non-repo). The working // tree is the only state there is, and it is not "a PR weakening its own // policy" — there is no base to weaken relative to. - const project = readDataConfigSync(root); + const project = requireDataConfig(root); if (project.file && project.config?.review !== undefined) { return reviewConfigFromBlock(project.config.review, 'working-tree', project.file); } 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/prepare.ts b/src/review/prepare.ts index 0c4bab6..61a2771 100644 --- a/src/review/prepare.ts +++ b/src/review/prepare.ts @@ -31,6 +31,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { buildGraph } from '../engine/build.js'; +import { CONFIG_FILES, ConfigFileError, isDataConfigFile, parseDataConfig, readDataConfigSync } from '../core-open/config.js'; import { mergeExcludes } from '../engine/discover.js'; import { writeArtifacts } from '../engine/artifacts.js'; import { writeSnapshot } from '../engine/freshness.js'; @@ -40,7 +41,6 @@ import { acquireLock, releaseLock } from '../engine/lock.js'; import { cacheDir } from '../engine/cache.js'; import { ProgressBar } from '../util/progress.js'; import { REVIEW_CONFIG_PATH, loadReviewConfig } from './config.js'; -import { CONFIG_FILES, isDataConfigFile, parseDataConfig, readDataConfigSync } from '../core-open/config.js'; import type { GitRunner } from './git.js'; /** Matches `refresh.ts` — one lock, so a refresh and an auto-build never race. */ @@ -144,6 +144,9 @@ async function firstBuild( writeSnapshot(root, result.graph.provenance.corpusHash, result.fileStats, { exclude }); return { action: 'built', files: result.totalFiles, ms: Date.now() - start }; } catch (err) { + // A broken project config is the user's to fix. Skipping the map and + // continuing would review the change with empty excludes. + if (err instanceof ConfigFileError) throw err; return { action: 'skipped', reason: (err as Error).message }; } finally { releaseLock(lock); diff --git a/src/review/review.test.ts b/src/review/review.test.ts index e442c43..d732268 100644 --- a/src/review/review.test.ts +++ b/src/review/review.test.ts @@ -453,7 +453,10 @@ unguarded_entrypoint = false const calls: string[][] = []; const run: GitRunner = (args) => { calls.push(args); - if (args[0] === 'show' && args[1]?.startsWith('origin/main:')) { + // A missing project config is "not in this commit" (status !== 0). + // Returning TOML for `.vibgrate/config.yml` would be an invalid YAML + // file, which fails closed instead of falling through. + if (args[1] === 'origin/main:.vibgrate/review.toml') { return { stdout: '[review]\nenforcement = "enforced"\n', status: 0 }; } return { stdout: '', status: 1 }; 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/runtime/vgd/server.ts b/src/runtime/vgd/server.ts index 3691d7e..52e83d3 100644 --- a/src/runtime/vgd/server.ts +++ b/src/runtime/vgd/server.ts @@ -7,6 +7,7 @@ import { WorkspaceRegistry } from './registry.js'; import { vgdPidPath, vgdSocketPath } from './paths.js'; import { queryGraph, queryGraphSemantic } from '../../engine/query.js'; import { loadGraph } from '../../engine/load.js'; +import { GraphLoadError } from '../../engine/serialize.js'; import { resolveGraphPath } from '../../engine/artifacts.js'; import { globalGraphPathForRef } from '../paths.js'; import { clearDetectGitRefCache, detectGitRef } from '../git-ref.js'; @@ -86,10 +87,16 @@ export async function startVgdServer(options: VgdServerOptions = {}): Promise { - const graph = loadGraph(root); - if (!graph) return Promise.resolve(null); - registry.putGraph(repositoryId, gitRef, graph); - return Promise.resolve(graph.nodes?.length ?? 0); + try { + const graph = loadGraph(root); + if (!graph) return Promise.resolve(null); + registry.putGraph(repositoryId, gitRef, graph); + return Promise.resolve(graph.nodes?.length ?? 0); + } catch (err) { + const detail = err instanceof GraphLoadError ? err.message : 'the code map could not be reloaded'; + log(`freshness: ${detail}`); + return Promise.resolve(null); + } }, select: (repositoryId, gitRef) => registry.selectGitRef(repositoryId, gitRef), }); @@ -776,7 +783,13 @@ async function loadGraphIntoSlot( } ctx.log(`load-graph: ${record.id}@${gitRef} from ${root}`); - let graph = loadGraph(root, graphPath); + let graph: VgGraph | null; + try { + graph = loadGraph(root, graphPath); + } catch (err) { + if (err instanceof GraphLoadError) return { ok: false, error: err.message, code: 'bad_graph' }; + throw err; + } let rebuilt = false; if (!graph && options.rebuildIfMissing) { ctx.log(`ensure-graph: no map for ${root} — rebuilding in a child`); @@ -788,7 +801,12 @@ async function loadGraphIntoSlot( code: 'rebuild_failed', }; } - graph = loadGraph(root, graphPath); + try { + graph = loadGraph(root, graphPath); + } catch (err) { + if (err instanceof GraphLoadError) return { ok: false, error: err.message, code: 'bad_graph' }; + throw err; + } rebuilt = true; } if (!graph) { @@ -842,7 +860,16 @@ function loadRefFromDisk( ): void { const record = ctx.registry.getById(repositoryId); if (!record) return; - const fromRefPath = loadGraph(record.root, globalGraphPathForRef(record.root, gitRef)); + let fromRefPath: VgGraph | null; + try { + fromRefPath = loadGraph(record.root, globalGraphPathForRef(record.root, gitRef)); + } catch (err) { + if (err instanceof GraphLoadError) { + ctx.log(`select-git-ref: ${err.message}`); + return; + } + throw err; + } if (fromRefPath) { ctx.registry.putGraph(repositoryId, gitRef, fromRefPath); startWatching(ctx, repositoryId, gitRef); @@ -852,7 +879,16 @@ function loadRefFromDisk( // In-repo / current-HEAD snapshot only if this process is actually on that ref. clearDetectGitRefCache(record.root); if (detectGitRef(record.root).ref !== gitRef) return; - const current = loadGraph(record.root); + let current: VgGraph | null; + try { + current = loadGraph(record.root); + } catch (err) { + if (err instanceof GraphLoadError) { + ctx.log(`select-git-ref: ${err.message}`); + return; + } + throw err; + } if (!current) return; ctx.registry.putGraph(repositoryId, gitRef, current); startWatching(ctx, repositoryId, gitRef); diff --git a/src/version.ts b/src/version.ts index 924f035..51ca4ee 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.3'; diff --git a/test/blank-exclude.test.ts b/test/blank-exclude.test.ts new file mode 100644 index 0000000..ab53fc4 --- /dev/null +++ b/test/blank-exclude.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import * as path from 'node:path'; +import { discover, mergeExcludes, readConfigExcludes } from '../src/engine/discover.js'; +import { compileGlobs } from '../src/core-open/utils/glob.js'; +import { compileGlobs as reportingCompileGlobs } from '../src/reporting/utils/glob.js'; +import { FileCache } from '../src/core-open/utils/fs.js'; +import { makeProject, cleanup } from './helpers.js'; + +/** Patterns that must never be treated as "ignore everything". */ +const BLANKS = ['', ' ', ' ', '\t', '\n', '\r', ' \n', ' \r', '\r\n', '\n\n']; + +const dirs: string[] = []; +function project(files: Record): string { + const d = makeProject(files); + dirs.push(d); + return d; +} +afterEach(() => { + while (dirs.length) cleanup(dirs.pop()!); +}); + +function posix(p: string): string { + return p.split(path.sep).join('/'); +} + +describe('blank exclude and ignore patterns', () => { + it('does not let an empty or whitespace-only exclude hide the tree', () => { + const root = project({ + 'a.ts': 'export const a = 1;\n', + 'src/b.ts': 'export const b = 1;\n', + 'gen/c.ts': 'export const c = 1;\n', + }); + expect(discover({ root, exclude: [''] }).map((f) => f.rel)).toEqual(['a.ts', 'gen/c.ts', 'src/b.ts']); + expect(discover({ root, exclude: BLANKS }).map((f) => f.rel)).toEqual(['a.ts', 'gen/c.ts', 'src/b.ts']); + // A real pattern beside the blanks still applies. + expect(discover({ root, exclude: [...BLANKS, 'gen/**'] }).map((f) => f.rel)).toEqual(['a.ts', 'src/b.ts']); + }); + + it('does not let a carriage-return-only .gitignore hide the tree', () => { + const root = project({ + 'a.ts': 'export const a = 1;\n', + 'src/b.ts': 'export const b = 1;\n', + '.gitignore': '\r', + }); + expect(discover({ root }).map((f) => f.rel)).toEqual(['a.ts', 'src/b.ts']); + }); + + it('keeps real gitignore rules when a blank CR line is present', () => { + const root = project({ + 'a.ts': 'export const a = 1;\n', + 'gen/c.ts': 'export const c = 1;\n', + '.gitignore': 'gen/**\n\r', + }); + expect(discover({ root }).map((f) => f.rel)).toEqual(['a.ts']); + }); + + it('drops blank entries when merging config and flag excludes', () => { + const root = project({ + 'vibgrate.config.json': JSON.stringify({ exclude: ['', ' ', '\n', 'skip/**', 'skip/**'] }), + }); + expect(readConfigExcludes(root)).toEqual(['skip/**', 'skip/**']); + expect(mergeExcludes(root, ['', '\r', 'tmp/**', 'skip/**'])).toEqual(['skip/**', 'tmp/**']); + }); + + it('compiles blank globs as no exclude, and keeps real ones', () => { + for (const compile of [compileGlobs, reportingCompileGlobs]) { + expect(compile(BLANKS)).toBeNull(); + const match = compile([...BLANKS, 'gen/**'])!; + expect(match('a.ts')).toBe(false); + expect(match('src/b.ts')).toBe(false); + expect(match('gen/c.ts')).toBe(true); + } + }); + + it('does not let a blank exclude or a blank .gitignore skip the scan walk', async () => { + const root = project({ + 'a.ts': 'export const a = 1;\n', + 'src/b.ts': 'export const b = 1;\n', + 'gen/c.ts': 'export const c = 1;\n', + '.gitignore': '\r', + }); + const cache = new FileCache(); + cache.setExcludePatterns([...BLANKS, 'gen/**']); + const entries = await cache.walkDir(root); + const rels = entries.filter((e) => e.isFile).map((e) => posix(e.relPath)); + expect(rels).toContain('a.ts'); + expect(rels).toContain('src/b.ts'); + expect(rels).not.toContain('gen/c.ts'); + }); +}); diff --git a/test/graph-load.test.ts b/test/graph-load.test.ts new file mode 100644 index 0000000..1a4d96e --- /dev/null +++ b/test/graph-load.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { Command } from 'commander'; +import { main } from '../src/cli.js'; +import { registerStatus } from '../src/commands/status.js'; +import { loadGraph } from '../src/engine/load.js'; +import { GraphLoadError, parseGraph } from '../src/engine/serialize.js'; +import { loadGraphFileWithSnapshot, writeGraphSnapshot } from '../src/engine/snapshot.js'; +import { ExitCode } from '../src/util/exit.js'; +import type { VgGraph } from '../src/schema.js'; + +/** + * A corrupt or schema-mismatched code map must fail as an operator error: + * what went wrong, how to rebuild, non-zero exit, and none of the file's bytes. + */ + +// Distinct file bytes. A token-shaped value here trips the secret scan. +const SECRET = 'placeholder-file-contents-must-not-leak'; +const REBUILD = 'Rebuild it with `vg build`'; + +const dirs: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-graph-load-')); + dirs.push(dir); + return dir; +} + +function writeMap(dir: string, body: string): string { + const file = path.join(dir, 'graph.json'); + fs.writeFileSync(file, body); + return file; +} + +function assertSafe(err: GraphLoadError): void { + expect(err).toBeInstanceOf(GraphLoadError); + expect(err.code).toBe(ExitCode.ERROR); + expect(err.code).not.toBe(0); + expect(err.message).toContain(REBUILD); + expect(err.message).not.toContain(SECRET); + expect(err.message).not.toContain('ghp_'); + expect(err.message).not.toContain('Unexpected token'); + expect(err.message).not.toContain('graph.json'); +} + +async function runStatus(args: string[]): Promise { + const program = new Command(); + program.exitOverride(); + registerStatus(program); + await program.parseAsync(['status', ...args], { from: 'user' }); +} + +describe('parseGraph / loadGraph', () => { + it('loads a supported older schema', () => { + const graph = parseGraph(JSON.stringify({ + schemaVersion: 'vg-graph/1.0', + nodes: [], + edges: [], + })); + expect(graph.schemaVersion).toBe('vg-graph/1.0'); + }); + + it('returns null when the map file is absent', () => { + const dir = tempDir(); + expect(loadGraph(dir, path.join(dir, 'missing.json'))).toBeNull(); + }); + + it('rejects truncated JSON without echoing file contents', () => { + const dir = tempDir(); + const file = writeMap(dir, `{"note":"${SECRET}","nodes":[`); + let caught: unknown; + try { + loadGraph(dir, file); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(GraphLoadError); + const err = caught as GraphLoadError; + expect(err.kind).toBe('corrupt'); + assertSafe(err); + expect(err.message).toMatch(/truncated or not valid JSON/); + }); + + it('rejects a schema this vg cannot read without echoing the version token', () => { + const dir = tempDir(); + const file = writeMap(dir, JSON.stringify({ + schemaVersion: SECRET, + nodes: [{ name: SECRET }], + edges: [], + })); + let caught: unknown; + try { + parseGraph(fs.readFileSync(file, 'utf8')); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(GraphLoadError); + const err = caught as GraphLoadError; + expect(err.kind).toBe('schema'); + assertSafe(err); + expect(err.message).toMatch(/cannot read/); + expect(err.message).toContain('vg-graph/1.0'); + expect(err.message).toContain('vg-graph/1.1'); + }); + + it('names an unsupported vg-graph schema version', () => { + expect(() => parseGraph(JSON.stringify({ + schemaVersion: 'vg-graph/0.9', + nodes: [], + edges: [], + }))).toThrow(/schema `vg-graph\/0\.9`/); + }); + + it('rejects a standalone snapshot whose schema is not readable', () => { + const dir = tempDir(); + const file = path.join(dir, 'graph.json'); + const graph = { + schemaVersion: 'vg-graph/0.4', + nodes: [], + edges: [], + } as unknown as VgGraph; + expect(writeGraphSnapshot(file, graph, { standalone: true })).toBe(true); + expect(() => loadGraphFileWithSnapshot(file)).toThrow(GraphLoadError); + try { + loadGraphFileWithSnapshot(file); + } catch (err) { + expect(err).toBeInstanceOf(GraphLoadError); + expect((err as GraphLoadError).kind).toBe('schema'); + expect((err as GraphLoadError).message).toContain(REBUILD); + expect((err as GraphLoadError).message).toContain('vg-graph/0.4'); + } + }); +}); + +describe('vg status', () => { + it('fails the command on a corrupt map', async () => { + const dir = tempDir(); + const file = writeMap(dir, `{"token":"${SECRET}"`); + await expect(runStatus(['--cwd', dir, '--graph', file, '--offline'])).rejects.toMatchObject({ + name: 'GraphLoadError', + kind: 'corrupt', + code: ExitCode.ERROR, + }); + }); + + it('fails the command on a schema mismatch', async () => { + const dir = tempDir(); + const file = writeMap(dir, JSON.stringify({ schemaVersion: 'vg-graph/0.9', nodes: [], edges: [] })); + await expect(runStatus(['--cwd', dir, '--graph', file, '--offline'])).rejects.toMatchObject({ + name: 'GraphLoadError', + kind: 'schema', + code: ExitCode.ERROR, + }); + }); + + it('exits non-zero from the CLI entry, and the stderr line does not quote the file', async () => { + const dir = tempDir(); + const corrupt = writeMap(dir, `not-json ${SECRET}`); + const exits: number[] = []; + const errSpy = vi.spyOn(process.stderr, 'write'); + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + exits.push(code ?? -1); + return undefined as never; + }) as typeof process.exit); + + const stderrText = (): string => + errSpy.mock.calls.map((call) => String(call[0])).join('').replace(/\u001b\[[0-9;]*m/g, ''); + + await main(['node', 'vg', 'status', '--offline', '--cwd', dir, '--graph', corrupt]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(exits).toEqual([ExitCode.ERROR]); + const text = stderrText(); + expect(text).toContain('error:'); + expect(text).toContain(REBUILD); + expect(text).toContain('truncated or not valid JSON'); + expect(text).not.toContain(SECRET); + expect(text).not.toContain('Unexpected token'); + + exits.length = 0; + errSpy.mockClear(); + const mismatched = writeMap(dir, JSON.stringify({ + schemaVersion: 'vg-graph/0.2', + secret: SECRET, + nodes: [], + edges: [], + })); + await main(['node', 'vg', 'status', '--offline', '--cwd', dir, '--graph', mismatched]); + await new Promise((resolve) => setImmediate(resolve)); + expect(exits).toEqual([ExitCode.ERROR]); + const schemaText = stderrText(); + expect(schemaText).toContain('vg-graph/0.2'); + expect(schemaText).toContain(REBUILD); + expect(schemaText).not.toContain(SECRET); + }); +}); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index d934a6c..8f5860e 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -19,13 +19,14 @@ import type { VgGraph } from '../src/schema.js'; /** * The binary snapshot is a derived cache: graph.json stays canonical, and - * every failure mode below must degrade to reading the JSON — never throw, - * never serve stale data after the JSON changed. + * a bad sidecar degrades to reading the JSON and never serves stale data + * after the JSON changed. A present `graph.json` that is itself truncated + * or not JSON throws GraphLoadError instead of pretending the map is missing. */ function sampleGraph(mutate?: (g: VgGraph) => void): VgGraph { const graph = { - schemaVersion: 1, + schemaVersion: 'vg-graph/1.1', generatedAt: '2026-01-01T00:00:00.000Z', provenance: { corpusHash: 'abc123' }, nodes: [ @@ -126,10 +127,10 @@ describe('graph snapshot sidecar', () => { expect(readGraphSnapshot(graphPath)).toEqual(graph); }); - it('returns null for missing or unparseable JSON (pre-snapshot behaviour)', () => { + it('returns null when the map is missing, and throws when the JSON is not parseable', () => { expect(loadGraphFileWithSnapshot(graphPath)).toBeNull(); fs.writeFileSync(graphPath, '{ definitely not json'); - expect(loadGraphFileWithSnapshot(graphPath)).toBeNull(); + expect(() => loadGraphFileWithSnapshot(graphPath)).toThrow(/Rebuild it with `vg build`/); }); it('write is best-effort: unwritable sidecar directory does not throw', () => { diff --git a/test/vulnerabilities.test.ts b/test/vulnerabilities.test.ts index d3eff4e..7914b6b 100644 --- a/test/vulnerabilities.test.ts +++ b/test/vulnerabilities.test.ts @@ -40,8 +40,15 @@ function writeArtifact(root: string, vulnerabilities?: VulnerabilityScanResult): } const listVulns = TOOLS.find((t) => t.name === 'list_vulnerabilities')!; +const vulnAttribution = TOOLS.find((t) => t.name === 'vuln_attribution')!; const stubGraph = {} as VgGraph; +const PARSE_FAILED = { + code: 'cvss-vector-parse-failed' as const, + message: + 'CVSS vector failed to parse (cvss-vector-parse-failed): "not-a-vector" is not a CVSS:3.0 or CVSS:3.1 base vector. Replace it with a CVSS:3.0 or CVSS:3.1 base vector, or omit the vector and supply a numeric base score.', +}; + describe('vuln-data + list_vulnerabilities MCP tool', () => { let dir: string; @@ -79,4 +86,81 @@ describe('vuln-data + list_vulnerabilities MCP tool', () => { expect(onlyCritical.packages[0].package).toBe('lodash'); expect(onlyCritical.totalAdvisories).toBe(1); }); + + it('keeps a parsed score, a missing score, and a failed vector distinct', () => { + const mixed: VulnerabilityScanResult = { + source: 'osv', + totalAdvisories: 3, + severityCounts: { low: 0, moderate: 0, high: 0, critical: 1, unknown: 2 }, + packages: [ + { + ecosystem: 'npm', + package: 'left-pad', + version: '1.0.0', + advisories: [ + { + id: 'GHSA-ok', + aliases: [], + summary: null, + severity: 'critical', + cvss: 9.8, + cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', + fixedVersions: [], + published: null, + withdrawn: null, + references: [], + }, + { + id: 'GHSA-none', + aliases: [], + summary: null, + severity: 'unknown', + cvss: null, + cvssVector: null, + fixedVersions: [], + published: null, + withdrawn: null, + references: [], + }, + { + id: 'GHSA-bad', + aliases: [], + summary: null, + severity: 'unknown', + cvss: null, + cvssVector: 'not-a-vector', + cvssDiagnostic: PARSE_FAILED, + fixedVersions: [], + published: null, + withdrawn: null, + references: [], + }, + ], + }, + ], + }; + writeArtifact(dir, mixed); + const listed = listVulns.handler(stubGraph, {}, { root: dir }) as { + packages: Array<{ advisories: Array<{ id: string; cvss: number | null; cvssDiagnostic?: { code: string; message: string } }> }>; + }; + const advisories = listed.packages[0].advisories; + const ok = advisories.find((a) => a.id === 'GHSA-ok')!; + const none = advisories.find((a) => a.id === 'GHSA-none')!; + const bad = advisories.find((a) => a.id === 'GHSA-bad')!; + expect(ok.cvss).toBe(9.8); + expect(ok.cvssDiagnostic).toBeUndefined(); + expect(none.cvss).toBeNull(); + expect(none.cvssDiagnostic).toBeUndefined(); + expect(bad.cvss).toBeNull(); + expect(bad.cvssDiagnostic).toEqual(PARSE_FAILED); + + const attributed = vulnAttribution.handler(stubGraph, {}, { root: dir }) as { + packages: Array<{ advisories: Array<{ id: string; cvss: number | null; cvssDiagnostic?: { code: string } }> }>; + }; + const attrBad = attributed.packages[0].advisories.find((a) => a.id === 'GHSA-bad')!; + const attrNone = attributed.packages[0].advisories.find((a) => a.id === 'GHSA-none')!; + expect(attrBad.cvssDiagnostic?.code).toBe('cvss-vector-parse-failed'); + expect(attrNone.cvssDiagnostic).toBeUndefined(); + expect(attrNone.cvss).toBeNull(); + }); });