diff --git a/Cargo.lock b/Cargo.lock index 0f79316..27885ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -208,6 +208,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "crossbeam-epoch", "globset", "ignore", "serde", @@ -251,9 +252,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -368,9 +369,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.26" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" dependencies = [ "crossbeam-deque", "globset", @@ -498,9 +499,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", diff --git a/Cargo.toml b/Cargo.toml index 01db281..dd75a20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ path = "src/lib.rs" [dependencies] clap = { version = "4", features = ["derive"] } -ignore = "0.4" +ignore = "0.4.33" anyhow = "1" chrono = "0.4" tree-sitter = "0.25" @@ -41,6 +41,7 @@ tree-sitter-c = "0.24" tree-sitter-c-sharp = "0.23" tree-sitter-zig = "1.1" tree-sitter-odin = "1.3" +crossbeam-epoch = "0.9.20" [profile.release] opt-level = 2 diff --git a/README.md b/README.md index bb2296a..b65e3f9 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Supports: `C99`, `C++ (20 except modules)`, `C#`, `Rust`, `Go`, `Python`, `Zig`, - [Quick-Start](#quick-start) - [Usage](#usage) - [Insights](#insights) -- [VS Code Extension](#extension) +- [Extension](#extension) - [Dependency Map](#dependencymap) - [Cross-Repository Calls](#externals) - [Agents.md](#agentsmd) @@ -84,18 +84,18 @@ Supports: `C99`, `C++ (20 except modules)`, `C#`, `Rust`, `Go`, `Python`, `Zig`, ## Usage ```sh -ccc scan [PATH] # regen PATH/.ccc (PATH defaults to ".") -ccc scan [PATH] --tokens # also pre-encode the cache into a token stream -ccc check [PATH] # exit non-zero if .ccc is stale - for CI -ccc check [PATH] --format json # same, but print changed cache files as JSON -ccc tokenize [PATH] # pre-encode an existing .ccc into tokens.bin + tokens.json -ccc changes [PATH] # what changed vs the base branch + which services to test (JSON) -ccc serve [PATH] # MCP server: agents query the in-memory map (REST + MCP) -ccc serve [PATH] --html # render the insights UI at /insights -ccc export [PATH] # publish what this project serves/calls, for other repos -ccc insights [PATH] # the insights analysis as JSON (call graph, triggers, lints) -ccc insights [PATH] --html F # as one self-contained page -ccc install [--dir DIR] # install the ccc binary onto your PATH (Linux) +ccc changes [PATH] --telemetry # changes vs the base branch: services to test, dependencies, otel +ccc check [PATH] --format json # exit non-zero if .ccc is stale - for CI +ccc tokenize [PATH] # pre-encode an existing .ccc into tokens.bin + tokens.json +ccc deps [PATH] # just the dependency delta of that report, for CI (JSON) +ccc prompts [PATH] # which claude/copilot request produced each change (JSON) +ccc serve [PATH] --html # MCP server and optional insights UI: agents query the in-memory map +ccc export [PATH] # publish what this project serves/calls, for other repos +ccc insights [PATH] --html # the insights analysis as JSON (call graph, triggers, lints) +ccc sast [PATH] # security findings; defaults to non-zero on a high finding +ccc audit [PATH] # resolve lockfiles and check against the OSV advisory db +ccc install [--dir] # install the ccc binary onto your PATH (Linux) +ccc scan [PATH] --tokens # regen PATH/.ccc (PATH defaults to ".") (opt: output token stream) ``` ## Insights @@ -104,11 +104,10 @@ The command `(ccc serve --html)` starts the MCP server with the insights UI on ` `/insights.json` from the running server, so it tracks the in-memory ccc map at runtime. ```sh -ccc serve --html # then open http://127.0.0.1:6767/insights -curl -s localhost:6767/insights.json # the same data, for scripting - -ccc insights # the same analysis as JSON, no server -ccc insights --html page.html # ...as one self-contained page, for static hosting +ccc serve --html # then open http://127.0.0.1:6767/insights +curl -s localhost:6767/insights.json # the same data, for scripting +ccc insights # the same analysis as JSON, no server +ccc insights --html page.html # insights as a single page, for static hosting ``` ## Extension @@ -117,86 +116,7 @@ ccc insights --html page.html # ...as one self-contained page, for static host `ccc serve` in the background for each workspace folder and reads it over loopback HTTP, so nothing leaves the machine and no configuration is needed to get started. -### Install - -`cargo build` packages the extension alongside the binary: - -```sh -cargo build --release # -> dist/ccc-codecache.vsix -code --install-extension dist/ccc-codecache.vsix -``` - -The packaging step is best-effort: it is skipped without `npm`, under `CI`, or with `CCC_SKIP_VSIX` -set, and never fails the Rust build. To build the extension on its own, `cd extensions/vscode` and -run `npm run package` (or `npm run watch` and press F5 for an Extension Development Host). - -### Using it - -Open a file with work in progress. Each changed function carries a CodeLens above it, and every lens -is clickable: - -| lens | what it means | -|---|---| -| `3 tests` | Tests cover this change - opens them, nearest call hop first. | -| `no smoke test` | Nothing covers this change, and a smoke test is the kind worth writing. | -| `calls billing` | This call crosses a service boundary - opens the handler, in a peer checkout if that is where it lives. | -| `called by gateway` | Another service calls this function - opens the callers. | -| `37 callers`, `cycle of 3` | A hot path, or a call cycle. From the call graph, so these show on files nobody has touched. | -| `billing.v1.Charge unanswered` | A `ccc:calls` whose key nothing serves - a typo at one end, or a peer missing from `externals`. | - -Every function the analyser parsed also carries its complexity as a filled circled number between -its name and its signature - `fn parse โธ (s: &str)`. It is a cyclomatic-style count (one path, plus -one per decision point and loop) banded onto 1-10, and the colour runs grey, plain, green, blue, -purple, yellow, brown, amber, orange, red as it climbs. Unlike the hints above it is not diff-driven: -it describes the code as written, so it shows on files nobody has touched. Hover it for the raw -count, the branches and the loop depth behind the band. The `โš ` (no test covers this) and `๐Ÿ”ฅ` -(hot path) verdicts sit inline in the same spot, right after the band, rather than out in the -gutter; the other hint kinds keep their gutter icons. `ccc.complexity.minScore` raises the floor if -you only want to see the functions worth a second look, and `ccc.complexity.enabled` turns it off; -the ten colours are contributed theme colours, so a theme or a `workbench.colorCustomizations` entry -can restyle any of them. - -Click the **CodeCaChe** mark in the activity bar to open the panel, and again to close it. It has -two views. **Triggers** is the tests your changes invoke: a triggered test usually lives in a -different file from the change that triggered it, so this is the only place that shows the whole set -- **Run these** (click to open; the tooltip carries how many call hops it sits from the change and -why), **No test covers**, and **Commands** - the suggested command for running exactly that set, -click to run it in a terminal. The badge is the number of tests worth running before you push. - -**Complexity** is every measured function grouped by band, worst first, with the count per band on -the group row. The title-bar buttons filter it: by name (substring), by parameter count (niladic, -monadic, dyadic, variadic - pick several), and by band (a 1-10 range). Test functions are measured -but hidden by default; the beaker button shows them, and the clear button appears whenever any -filter is active. The view's subtitle always says how many of the measured functions you are -looking at, so a filtered list can never pass itself off as the whole map. - -The status bar entry on the right is the summary - counts, the base ref being compared, and, when a -file has no marks at all, which of the two reasons applies: nothing in it changed, or it is not in -the ccc map. - -Everything runs against the **working tree**, so untracked and uncommitted files count. Hints reflect -the last *saved* state, since the analyser reads files rather than editor buffers; they fade while a -file is dirty and refresh on save. - -Coverage and boundary hints are diff-driven, so a file identical to the base ref has no changed -functions and therefore no hints - that is the design, not a fault. Hot paths come from the call -graph alone and appear regardless. - -### Worth knowing - -- Cross-service hints need a `services` block in [`.ccc/map.json`](#dependencymap); cross-repository - hints need [`externals` and `ccc:` comments](#externals). With no map, ccc groups by directory and - the hints still mean something; where even that degenerates to one unit per file, it stays quiet - rather than calling every import a service call. -- Coverage is matched through the static call graph by name - not by running anything. A same-named - function elsewhere can produce a false positive, and a test that reaches code only through a - framework is invisible. -- Useful settings: `ccc.baseRef` (what to diff against), `ccc.binaryPath`, `ccc.hints.crossServiceMode`, - and `ccc.hints.codeLens` - set that to `false` with `ccc.decorations.style` as `badge+gutter` for - end-of-line badges instead of lenses. Commands are under **ccc:** in the palette. - -Full details, every setting, and the troubleshooting list are in -[`extensions/vscode/README.md`](extensions/vscode/README.md). +See ['EXTENSION.md'](docs/EXTENSION.md) for more information. ## DependencyMap diff --git a/docs/EXTENSION.md b/docs/EXTENSION.md new file mode 100644 index 0000000..262653d --- /dev/null +++ b/docs/EXTENSION.md @@ -0,0 +1,86 @@ +# Extension + +[`extensions/vscode`](extensions/vscode) is an editor client for the same analysis. It runs +`ccc serve` in the background for each workspace folder and reads it over loopback HTTP, so nothing +leaves the machine and no configuration is needed to get started. + +## Install + +`cargo build` packages the extension alongside the binary: + +```sh +cargo build --release # -> dist/ccc-codecache.vsix +code --install-extension dist/ccc-codecache.vsix +``` + +The packaging step is best-effort: it is skipped without `npm`, under `CI`, or with `CCC_SKIP_VSIX` +set, and never fails the Rust build. To build the extension on its own, `cd extensions/vscode` and +run `npm run package` (or `npm run watch` and press F5 for an Extension Development Host). + +## Using the extension + +Open a file with work in progress. Each changed function carries a CodeLens above it, and every lens +is clickable: + +| lens | what it means | +|---|---| +| `3 tests` | Tests cover this change - opens them, nearest call hop first. | +| `no smoke test` | Nothing covers this change, and a smoke test is the kind worth writing. | +| `calls billing` | This call crosses a service boundary - opens the handler, in a peer checkout if that is where it lives. | +| `called by gateway` | Another service calls this function - opens the callers. | +| `37 callers`, `cycle of 3` | A hot path, or a call cycle. From the call graph, so these show on files nobody has touched. | +| `billing.v1.Charge unanswered` | A `ccc:calls` whose key nothing serves - a typo at one end, or a peer missing from `externals`. | + +Every function the analyser parsed also carries its complexity as a filled circled number between +its name and its signature - `fn parse โธ (s: &str)`. It is a cyclomatic-style count (one path, plus +one per decision point and loop) banded onto 1-10, and the colour runs grey, plain, green, blue, +purple, yellow, brown, amber, orange, red as it climbs. Unlike the hints above it is not diff-driven: +it describes the code as written, so it shows on files nobody has touched. Hover it for the raw +count, the branches and the loop depth behind the band. The `โš ` (no test covers this) and `๐Ÿ”ฅ` +(hot path) verdicts sit inline in the same spot, right after the band, rather than out in the +gutter; the other hint kinds keep their gutter icons. `ccc.complexity.minScore` raises the floor if +you only want to see the functions worth a second look, and `ccc.complexity.enabled` turns it off; +the ten colours are contributed theme colours, so a theme or a `workbench.colorCustomizations` entry +can restyle any of them. + +Click the **CodeCaChe** mark in the activity bar to open the panel, and again to close it. It has +two views. **Triggers** is the tests your changes invoke: a triggered test usually lives in a +different file from the change that triggered it, so this is the only place that shows the whole set +- **Run these** (click to open; the tooltip carries how many call hops it sits from the change and +why), **No test covers**, and **Commands** - the suggested command for running exactly that set, +click to run it in a terminal. The badge is the number of tests worth running before you push. + +**Complexity** is every measured function grouped by band, worst first, with the count per band on +the group row. The title-bar buttons filter it: by name (substring), by parameter count (niladic, +monadic, dyadic, variadic - pick several), and by band (a 1-10 range). Test functions are measured +but hidden by default; the beaker button shows them, and the clear button appears whenever any +filter is active. The view's subtitle always says how many of the measured functions you are +looking at, so a filtered list can never pass itself off as the whole map. + +The status bar entry on the right is the summary - counts, the base ref being compared, and, when a +file has no marks at all, which of the two reasons applies: nothing in it changed, or it is not in +the ccc map. + +Everything runs against the **working tree**, so untracked and uncommitted files count. Hints reflect +the last *saved* state, since the analyser reads files rather than editor buffers; they fade while a +file is dirty and refresh on save. + +Coverage and boundary hints are diff-driven, so a file identical to the base ref has no changed +functions and therefore no hints - that is the design, not a fault. Hot paths come from the call +graph alone and appear regardless. + +## Worth knowing + +- Cross-service hints need a `services` block in [`.ccc/map.json`](#dependencymap); cross-repository + hints need [`externals` and `ccc:` comments](#externals). With no map, ccc groups by directory and + the hints still mean something; where even that degenerates to one unit per file, it stays quiet + rather than calling every import a service call. +- Coverage is matched through the static call graph by name - not by running anything. A same-named + function elsewhere can produce a false positive, and a test that reaches code only through a + framework is invisible. +- Useful settings: `ccc.baseRef` (what to diff against), `ccc.binaryPath`, `ccc.hints.crossServiceMode`, + and `ccc.hints.codeLens` - set that to `false` with `ccc.decorations.style` as `badge+gutter` for + end-of-line badges instead of lenses. Commands are under **ccc:** in the palette. + +Full details, every setting, and the troubleshooting list are in +[`extensions/vscode/README.md`](extensions/vscode/README.md). \ No newline at end of file diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index 5f2ea3c..b9ccc7c 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -53,9 +53,13 @@ The panel badge is the number of tests worth running before you push. ## Requirements -**The `ccc` binary.** Install it with `./install.sh` from the repo root, or `cargo build --release` -and point `ccc.binaryPath` at `target/release/ccc`. The extension searches, in order: -`ccc.binaryPath`, your `PATH`, `/target/release/ccc`, `/target/debug/ccc`. +**The `ccc` binary.** The extension installs it for you. When it activates it looks for a working +`ccc` โ€” `ccc.binaryPath`, your `PATH`, `/target/release/ccc`, `/target/debug/ccc`, +then a copy it installed earlier โ€” and, finding none, downloads the release matching your platform +into its own storage before the first analyser starts. Nothing on your `PATH` is modified, and +`ccc.autoInstall: false` turns the download off. To manage the binary yourself, install it with +`./install.sh` from the repo root, or `cargo build --release` and point `ccc.binaryPath` at +`target/release/ccc`; a binary you provide is always preferred to the one the extension installs. **A git repo with a resolvable base ref**, for the coverage hints. The analyser diffs against the first of `origin/main`, `main`, `origin/master`, `master` that exists. On a shallow clone or a repo @@ -211,8 +215,10 @@ carries the analyser's own explanation. **Everything looks like a cross-service call.** Your project has no `.ccc/map.json`, so boundaries were inferred from directories. Add a `services` block. -**"could not find the ccc binary".** Build it (`cargo build --release`) or set `ccc.binaryPath`. The -message lists every path that was searched. +**"could not find the ccc binary".** The download failed, or `ccc.autoInstall` is off and nothing is +installed. The message lists every path that was searched; **Retry Install** tries the download +again, and `ccc: Show Log` says why the last one failed. Failing that, build it +(`cargo build --release`) or set `ccc.binaryPath`. **The analyser keeps restarting.** It is restarted with backoff and gives up after five failures in five minutes. `ccc: Show Log` has the last 20 lines of its stderr for each crash. @@ -223,7 +229,8 @@ same data the hints are built from. ## Performance One analyser process per workspace folder per window, started lazily the first time you open a file -in that folder. It runs with `--no-watch` by default and rescans on save and on window focus, so it +in that folder. Activation itself only probes for the binary โ€” a single `ccc --version` โ€” and costs +a download once, when there is no ccc to find. It runs with `--no-watch` by default and rescans on save and on window focus, so it is idle between edits. Two windows on the same folder run two analysers, each on its own free port; a `ccc serve` you started yourself is neither used nor disturbed. diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index d044ed7..32744fc 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -44,22 +44,52 @@ { "id": "ccc.complexity1", "description": "Complexity band 1 of 10: straight through, no decision points.", - "defaults": { "dark": "descriptionForeground", "light": "descriptionForeground", "highContrast": "descriptionForeground", "highContrastLight": "descriptionForeground" } + "defaults": { + "dark": "descriptionForeground", + "light": "descriptionForeground", + "highContrast": "descriptionForeground", + "highContrastLight": "descriptionForeground" + } }, { "id": "ccc.complexity2", "description": "Complexity band 2 of 10: nearly straight through.", - "defaults": { "dark": "editor.foreground", "light": "editor.foreground", "highContrast": "editor.foreground", "highContrastLight": "editor.foreground" } + "defaults": { + "dark": "editor.foreground", + "light": "editor.foreground", + "highContrast": "editor.foreground", + "highContrastLight": "editor.foreground" + } }, { "id": "ccc.complexity7", "description": "Complexity band 7 of 10: worth a second look.", - "defaults": { "dark": "#b07d48", "light": "#8a5a2b", "highContrast": "#d19a66", "highContrastLight": "#6d4520" } + "defaults": { + "dark": "#b07d48", + "light": "#8a5a2b", + "highContrast": "#d19a66", + "highContrastLight": "#6d4520" + } }, { "id": "ccc.complexity8", "description": "Complexity band 8 of 10: worth a second look.", - "defaults": { "dark": "#d9a441", "light": "#a4700a", "highContrast": "#ffcc66", "highContrastLight": "#7d5400" } + "defaults": { + "dark": "#d9a441", + "light": "#a4700a", + "highContrast": "#ffcc66", + "highContrastLight": "#7d5400" + } + }, + { + "id": "ccc.vulnerable", + "description": "Underline and badge marking a manifest line whose dependency has a known advisory.", + "defaults": { + "dark": "#cc7832", + "light": "#b7601a", + "highContrast": "#ff9e40", + "highContrastLight": "#8a4508" + } } ], "commands": [ @@ -297,7 +327,13 @@ "type": "string", "default": "", "scope": "machine-overridable", - "markdownDescription": "Absolute path to the `ccc` binary. Empty searches PATH, then `target/release/ccc`, then `target/debug/ccc` in the workspace folder." + "markdownDescription": "Absolute path to the `ccc` binary. Empty searches PATH, then `target/release/ccc`, then `target/debug/ccc` in the workspace folder, then a copy installed by `ccc.autoInstall`." + }, + "ccc.autoInstall": { + "type": "boolean", + "default": true, + "scope": "machine", + "markdownDescription": "When no `ccc` binary is found, download the matching release from GitHub into this extension's storage. This runs when the extension activates, so the analyser is ready before the first file is opened, and again after an update if the installed copy is older than the extension. Nothing on your PATH is modified. Turn off to require an install you manage yourself." }, "ccc.baseRef": { "type": "string", @@ -484,13 +520,11 @@ "ccc": [ { "id": "ccc.testTriggers", - "name": "Triggers", - "contextualTitle": "CodeCaChe \u2014 tests your changes invoke" + "name": "[CCC] Test Impact" }, { "id": "ccc.complexity", - "name": "Complexity", - "contextualTitle": "CodeCaChe \u2014 how complex each function is" + "name": "[CCC] Complexity" } ] } @@ -507,7 +541,7 @@ "@types/node": "^20.14.0", "@types/vscode": "^1.85.0", "@vscode/vsce": "^3.2.0", - "esbuild": "^0.24.0", + "esbuild": "^0.25.0", "typescript": "^5.6.0" } } diff --git a/extensions/vscode/src/binary.ts b/extensions/vscode/src/binary.ts index 7bac751..d7eec65 100644 --- a/extensions/vscode/src/binary.ts +++ b/extensions/vscode/src/binary.ts @@ -5,7 +5,7 @@ import * as vscode from 'vscode'; import type { Cfg } from './config'; import type { Log } from './log'; -export type BinarySource = 'config' | 'path' | 'target-release' | 'target-debug'; +export type BinarySource = 'config' | 'path' | 'target-release' | 'target-debug' | 'managed'; export interface BinaryResolution { path: string; @@ -25,54 +25,250 @@ export class CccBinaryError extends Error { const EXE = process.platform === 'win32' ? '.exe' : ''; +// release assets are one binary per platform, named the way install.sh names them +const REPO = 'https://github.com/colwill/ccc'; + +// activation waits on the download, so it must not be able to hang for ever +const DOWNLOAD_TIMEOUT_MS = 60_000; + +// one download at a time: a multi-root window resolves the binary once per folder, +// and two writers racing on the same file is the one way to install a broken copy +let installing: Promise | undefined; + // find a usable `ccc` binary - a broken `ccc.binaryPath` errors rather than silently falling through -export async function resolveCccBinary(folder: vscode.Uri, cfg: Cfg, log: Log): Promise { +export async function resolveCccBinary( + folder: vscode.Uri, + cfg: Cfg, + log: Log, + // where a downloaded binary lives; omitted, auto-install is unavailable + storage?: vscode.Uri, +): Promise { const searched: string[] = []; + const found = await findCcc([folder], cfg, log, storage, searched); + if (found) return found; + + if (storage !== undefined && cfg.autoInstall) { + const installed = await installCccBinary(storage, log); + if (installed !== undefined) return installed; + note(searched, `${REPO}/releases/latest (download failed - see the ccc output channel)`); + } + + throw missingBinary(cfg, searched); +} + +// The install-time path, run once when the extension activates: make sure a +// working ccc exists before anything asks to spawn one. Searches every +// workspace folder before paying for a download, and refreshes a copy this +// extension installed for an older version of itself +export async function bootstrapCccBinary( + folders: readonly vscode.Uri[], + cfg: Cfg, + log: Log, + storage: vscode.Uri, + // this extension's version, on the first activation after an install or an + // update; the binary ships from the same repo on the same version, so a + // managed copy that does not match it is out of date. Undefined skips the check + wantVersion?: string, +): Promise { + const searched: string[] = []; + const found = await findCcc(folders, cfg, log, storage, searched); + + // only a copy we installed is ours to replace - a build or an install the + // user manages is theirs, whatever version it reports + const stale = + found !== undefined && + found.source === 'managed' && + wantVersion !== undefined && + !versionMatches(found.version, wantVersion); + if (found && !stale) return found; + + if (!cfg.autoInstall) { + if (found) return found; + throw missingBinary(cfg, searched); + } + if (stale) log.info(`the installed ${found?.version ?? 'ccc'} predates this extension (${wantVersion})`); + + const installed = await installCccBinary(storage, log); + if (installed !== undefined) return installed; + // a stale copy that still runs beats no analyser at all + if (found) { + log.warn(`could not refresh the installed ccc; keeping ${found.version ?? found.path}`); + return found; + } + note(searched, `${REPO}/releases/latest (download failed - see the ccc output channel)`); + throw missingBinary(cfg, searched); +} - if (cfg.binaryPath.length > 0) { - const configured = path.isAbsolute(cfg.binaryPath) - ? cfg.binaryPath - : path.join(folder.fsPath, cfg.binaryPath); - searched.push(`ccc.binaryPath (${configured})`); - const version = await probe(configured); - if (version === undefined) { - throw new CccBinaryError( - `ccc.binaryPath points at \`${configured}\`, which is not an executable ccc binary.`, - searched, - ); +// search the places a ccc may already be, in order of how much the user meant it. +// Records every place looked at in `searched`, for the error message +async function findCcc( + folders: readonly vscode.Uri[], + cfg: Cfg, + log: Log, + storage: vscode.Uri | undefined, + searched: string[], +): Promise { + const configured = + cfg.binaryPath.length === 0 + ? [] + : path.isAbsolute(cfg.binaryPath) + ? [cfg.binaryPath] + : // a relative binaryPath with no folder open resolves to nothing, so the search goes on + folders.map((folder) => path.join(folder.fsPath, cfg.binaryPath)); + if (configured.length > 0) { + for (const candidate of configured) { + note(searched, `ccc.binaryPath (${candidate})`); + const version = await probe(candidate); + if (version === undefined) continue; + log.info(`using ccc from ccc.binaryPath: ${candidate} (${version})`); + return { path: candidate, source: 'config', version }; } - log.info(`using ccc from ccc.binaryPath: ${configured} (${version})`); - return { path: configured, source: 'config', version }; + throw new CccBinaryError( + `ccc.binaryPath points at \`${cfg.binaryPath}\`, which is not an executable ccc binary.`, + searched, + ); } const onPath = `ccc${EXE}`; - searched.push('PATH'); + note(searched, 'PATH'); const pathVersion = await probe(onPath); if (pathVersion !== undefined) { log.info(`using ccc from PATH (${pathVersion})`); return { path: onPath, source: 'path', version: pathVersion }; } - const candidates: Array<[BinarySource, string]> = [ - ['target-release', path.join(folder.fsPath, 'target', 'release', `ccc${EXE}`)], - ['target-debug', path.join(folder.fsPath, 'target', 'debug', `ccc${EXE}`)], - ]; - for (const [source, candidate] of candidates) { - searched.push(candidate); - if (!fs.existsSync(candidate)) continue; - const version = await probe(candidate); - if (version === undefined) continue; - log.info(`using ccc from ${source}: ${candidate} (${version})`); - return { path: candidate, source, version }; + for (const folder of folders) { + const candidates: Array<[BinarySource, string]> = [ + ['target-release', path.join(folder.fsPath, 'target', 'release', `ccc${EXE}`)], + ['target-debug', path.join(folder.fsPath, 'target', 'debug', `ccc${EXE}`)], + ]; + for (const [source, candidate] of candidates) { + note(searched, candidate); + if (!fs.existsSync(candidate)) continue; + const version = await probe(candidate); + if (version === undefined) continue; + log.info(`using ccc from ${source}: ${candidate} (${version})`); + return { path: candidate, source, version }; + } } - throw new CccBinaryError( - 'could not find the `ccc` binary. Install it with `./install.sh` or `cargo build --release` ' + - 'in the codecache repo, or set `ccc.binaryPath`.', + // a copy this extension installed earlier, before paying for another download + if (storage !== undefined) { + const managed = managedPath(storage); + note(searched, managed); + const version = await probe(managed); + if (version !== undefined) { + log.info(`using ccc installed by the extension: ${managed} (${version})`); + return { path: managed, source: 'managed', version }; + } + } + + return undefined; +} + +function missingBinary(cfg: Cfg, searched: string[]): CccBinaryError { + const hint = cfg.autoInstall + ? 'Automatic install did not produce a usable binary.' + : 'Automatic install is off (`ccc.autoInstall`).'; + return new CccBinaryError( + `could not find the \`ccc\` binary. ${hint} Install it with \`./install.sh\` or ` + + '`cargo build --release` in the codecache repo, or set `ccc.binaryPath`.', searched, ); } +// the same place can be reached from several folders - say it once +function note(searched: string[], place: string): void { + if (!searched.includes(place)) searched.push(place); +} + +// `ccc --version` prints "ccc ", so compare the version token alone +function versionMatches(reported: string | undefined, want: string): boolean { + if (reported === undefined) return false; + return (reported.trim().split(/\s+/).pop() ?? '') === want; +} + +// where a downloaded binary is kept: the extension's own storage, so installing +// never has to write to a directory on the user's PATH +function managedPath(storage: vscode.Uri): string { + return path.join(storage.fsPath, 'bin', `ccc${EXE}`); +} + +// the release asset for this machine, named as install.sh names it +function assetName(): string | undefined { + const os = { linux: 'linux', darwin: 'macos', win32: 'windows' }[process.platform as string]; + const arch = { x64: 'x86_64', arm64: 'aarch64', arm: 'armv7', ia32: 'i686', riscv64: 'riscv64' }[ + process.arch as string + ]; + if (os === undefined || arch === undefined) return undefined; + // armv7/i686/riscv64 are published for linux only + if (os !== 'linux' && arch !== 'x86_64' && arch !== 'aarch64') return undefined; + return `ccc-${os}-${arch}${os === 'windows' ? '.exe' : ''}`; +} + +// Download the matching release into extension storage. Resolves undefined on +// any failure - a missing binary is already handled, and an editor extension +// should not turn a failed download into an unhandled error +export function installCccBinary(storage: vscode.Uri, log: Log): Promise { + // callers that arrive while a download is running join it instead of starting a second one + installing ??= download(storage, log).finally(() => { + installing = undefined; + }); + return installing; +} + +async function download(storage: vscode.Uri, log: Log): Promise { + const asset = assetName(); + if (asset === undefined) { + log.warn(`no ccc release asset for ${process.platform}/${process.arch}; build from source`); + return undefined; + } + const url = `${REPO}/releases/latest/download/${asset}`; + const target = managedPath(storage); + + return vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Installing ccc' }, + async (progress) => { + progress.report({ message: `downloading ${asset}` }); + log.info(`installing ccc from ${url}`); + // write beside the target and rename, so a half-written file is never left + // looking like an installed binary. Two windows can activate at once and + // share this storage, so the staging name is this process's own + const staged = `${target}.${process.pid}.download`; + try { + const body = await fetchAsset(url); + await fs.promises.mkdir(path.dirname(target), { recursive: true }); + await fs.promises.writeFile(staged, body, { mode: 0o755 }); + await fs.promises.rename(staged, target); + } catch (err) { + log.warn(`ccc download failed: ${err instanceof Error ? err.message : String(err)}`); + // a rename can fail over a binary another window is running - leave nothing behind + await fs.promises.rm(staged, { force: true }).catch(() => undefined); + return undefined; + } + // catches a wrong-arch asset, or an HTML error page saved as a binary + const version = await probe(target); + if (version === undefined) { + log.warn(`downloaded ccc does not run on this machine (${process.platform}/${process.arch})`); + await fs.promises.rm(target, { force: true }); + return undefined; + } + log.info(`installed ${version} at ${target}`); + void vscode.window.showInformationMessage(`${version} installed.`); + return { path: target, source: 'managed' as const, version }; + }, + ); +} + +// fetch a release asset, following the redirect GitHub serves for `latest` +async function fetchAsset(url: string): Promise { + const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); + if (!res.ok) { + throw new Error(`${res.status} ${res.statusText} for ${url}`); + } + return Buffer.from(await res.arrayBuffer()); +} + // run ` --version`; undefined means "not a usable ccc binary" function probe(bin: string): Promise { return new Promise((resolve) => { diff --git a/extensions/vscode/src/client.ts b/extensions/vscode/src/client.ts index 08805e4..5c0f2ad 100644 --- a/extensions/vscode/src/client.ts +++ b/extensions/vscode/src/client.ts @@ -1,7 +1,7 @@ import * as http from 'node:http'; import type { Log } from './log'; import type { ServerAddress } from './server'; -import type { FileStructure, Health, InsightsPayload, ReferencesResult, RefreshResult } from './types'; +import type { FileStructure, Health, InsightsPayload, ReferencesResult, RefreshResult, VulnPayload } from './types'; export class CccHttpError extends Error { constructor( @@ -51,6 +51,11 @@ export class CccClient { return this.getJson(`/insights.json${query}`, TIMEOUT_SLOW_MS, signal); } + // dependency advisories; the analyser caches per map generation so this is cheap to re-ask + vulnerabilities(signal?: AbortSignal): Promise { + return this.getJson('/vulnerabilities.json', TIMEOUT_SLOW_MS, signal); + } + // pass the full repo-relative path - the server suffix-matches so a bare `money.rs` can mis-resolve async file(rel: string, signal?: AbortSignal): Promise { try { diff --git a/extensions/vscode/src/config.ts b/extensions/vscode/src/config.ts index b17e9c9..29d1c79 100644 --- a/extensions/vscode/src/config.ts +++ b/extensions/vscode/src/config.ts @@ -8,6 +8,8 @@ export type DecorationStyle = 'badge+gutter' | 'badge' | 'gutter'; export interface Cfg { enable: boolean; binaryPath: string; + // download a matching ccc release into extension storage when none is found + autoInstall: boolean; baseRef: string | undefined; server: { autoStart: boolean; @@ -59,6 +61,7 @@ export function readConfig(scope?: vscode.ConfigurationScope): Cfg { return { enable: c.get('enable', true), binaryPath: c.get('binaryPath', '').trim(), + autoInstall: c.get('autoInstall', true), baseRef: baseRef.length > 0 ? baseRef : undefined, server: { autoStart: c.get('server.autoStart', true), diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index 8e148bc..f0b9536 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { CccBinaryError } from './binary'; +import { bootstrapCccBinary, CccBinaryError } from './binary'; import { CccCodeLensProvider } from './codelens'; import { type CommandHost, registerCommands } from './commands'; import { type Cfg, needsDecorationReload, readConfig } from './config'; @@ -11,11 +11,15 @@ import { WorkspaceSession } from './session'; import { type ActiveFileState, StatusBar } from './status'; import { ComplexityPanel } from './complexitypanel'; import { TestTriggerPanel } from './testpanel'; +import { VulnerabilityMarks } from './vulns'; // re-applying decorations while typing is cheap but not free const DIRTY_DEBOUNCE_MS = 150; // don't rescan on every alt-tab const FOCUS_COOLDOWN_MS = 10_000; +// the extension version the binary check last ran for, so an install or an +// update re-checks once and every later activation stays cheap +const CHECKED_FOR_KEY = 'ccc.binaryCheckedFor'; let extension: Extension | undefined; @@ -40,11 +44,16 @@ class Extension implements CommandHost { private readonly codeLens: CccCodeLensProvider; private readonly hover: CccHoverProvider; private readonly testPanel: TestTriggerPanel; + private readonly vulns = new VulnerabilityMarks(); private readonly complexityPanel: ComplexityPanel; private cfg: Cfg; private lastFocusRefresh = 0; private dirtyTimer: NodeJS.Timeout | undefined; private disposables: vscode.Disposable[] = []; + private version = '0.0.0'; + // a usable analyser binary exists; false means every session start will fail the same way + private binaryReady = false; + private binaryWarned = false; constructor(private readonly context: vscode.ExtensionContext) { this.cfg = readConfig(); @@ -80,9 +89,9 @@ class Extension implements CommandHost { } async start(): Promise { - const version = this.context.extension.packageJSON?.version ?? '0.0.0'; - this.userAgent = `vscode-ccc/${version}`; - this.log.info(`ccc extension ${version} activating`); + this.version = this.context.extension.packageJSON?.version ?? '0.0.0'; + this.userAgent = `vscode-ccc/${this.version}`; + this.log.info(`ccc extension ${this.version} activating`); // commands register unconditionally so `ccc: Show Log` still works when all else failed this.disposables.push( @@ -93,6 +102,7 @@ class Extension implements CommandHost { this.codeLens, this.testPanel, this.complexityPanel, + this.vulns, vscode.languages.registerCodeLensProvider({ scheme: 'file' }, this.codeLens), vscode.languages.registerHoverProvider({ scheme: 'file' }, this.hover), vscode.commands.registerCommand('ccc.refreshTestTriggers', () => @@ -126,13 +136,21 @@ class Extension implements CommandHost { ); await vscode.commands.executeCommand('setContext', 'ccc.active', false); + // the analyser is a separate binary, so nothing else can work until one exists: + // install it here rather than leaving the first session to discover it is missing + await this.ensureBinary(); + this.disposables.push( vscode.workspace.onDidChangeConfiguration((e) => { if (!e.affectsConfiguration('ccc')) return; void this.onConfigChanged(); }), vscode.window.onDidChangeActiveTextEditor(() => void this.onActiveEditor()), - vscode.window.onDidChangeVisibleTextEditors(() => void this.render()), + vscode.window.onDidChangeVisibleTextEditors((editors) => { + // a manifest that just became visible has no decorations of its own yet + for (const editor of editors) this.vulns.apply(editor); + void this.render(); + }), vscode.workspace.onDidSaveTextDocument((doc) => this.onSave(doc)), vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e)), vscode.window.onDidChangeWindowState((state) => this.onWindowState(state)), @@ -144,6 +162,71 @@ class Extension implements CommandHost { private userAgent = 'vscode-ccc'; + // Runs once per activation and only does real work when there is nothing to + // find: it searches PATH, the workspace builds and this extension's own + // storage first, and downloads the matching GitHub release when none of those + // holds a usable ccc. On the first activation after the extension is + // installed or updated it also replaces a copy it installed for an older version + private async ensureBinary(): Promise { + if (!this.cfg.enable || !this.cfg.server.autoStart) { + this.log.info('skipping the binary check: ccc.enable or ccc.server.autoStart is off'); + return; + } + const checkedFor = this.context.globalState.get(CHECKED_FOR_KEY); + const folders = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri); + try { + const resolved = await bootstrapCccBinary( + folders, + this.cfg, + this.log, + this.context.globalStorageUri, + // asking for a version costs a download when the installed copy differs, + // so only the first activation on a new extension version asks + checkedFor === this.version ? undefined : this.version, + ); + this.binaryReady = true; + this.binaryWarned = false; + await this.context.globalState.update(CHECKED_FOR_KEY, this.version); + this.log.info( + `analyser binary ready: ${resolved.path} (${resolved.version ?? 'unknown version'}, ${resolved.source})`, + ); + } catch (err) { + this.binaryReady = false; + this.log.error('no usable ccc binary', err); + this.warnMissingBinary( + binaryMessage(err) ?? 'ccc: the analyser binary could not be installed. See the log for details.', + ); + } + } + + // a missing binary is one problem for the whole window, so it is reported once, + // not once per workspace folder that then fails to start + private warnMissingBinary(message: string): void { + if (this.binaryWarned) return; + this.binaryWarned = true; + void vscode.window + .showWarningMessage(message, 'Retry Install', 'Open Settings', 'Show Log') + .then(async (choice) => { + if (choice === 'Retry Install') { + this.binaryWarned = false; + await this.ensureBinary(); + if (this.binaryReady) await this.retryStart(); + } else if (choice === 'Open Settings') { + void vscode.commands.executeCommand('workbench.action.openSettings', 'ccc.binaryPath'); + } else if (choice === 'Show Log') this.log.show(); + }); + } + + // a binary that arrives late leaves the sessions that wanted it sitting in `failed` + private async retryStart(): Promise { + this.warned.clear(); + for (const session of this.sessionMap.values()) { + if (session.serverState.kind !== 'failed') continue; + await session.restartServer().catch((err) => this.log.error('restart after install failed', err)); + } + await this.onActiveEditor(); + } + // sessions start lazily so a twelve-folder workspace does not spawn twelve analysers private async sessionFor(uri: vscode.Uri): Promise { if (!this.cfg.enable || !this.cfg.server.autoStart) return undefined; @@ -154,9 +237,20 @@ class Extension implements CommandHost { if (existing) return existing; const cfg = readConfig(folder); - const session = new WorkspaceSession(folder, cfg, this.log, this.userAgent); + const session = new WorkspaceSession( + folder, + cfg, + this.log, + this.userAgent, + this.context.globalStorageUri, + ); this.sessionMap.set(key, session); - session.onDidChange(() => void this.render()); + session.onDidChange(() => { + // the refresh path fires twice, once for the analysis and once for the + // advisories - redrawing on both is what made the marks blink + if (this.vulns.update(folder.uri, session.vulnerabilities)) this.vulns.applyAll(); + void this.render(); + }); try { await session.ensureStarted(); await vscode.commands.executeCommand('setContext', 'ccc.active', true); @@ -196,14 +290,21 @@ class Extension implements CommandHost { private reportStartFailure(folder: vscode.WorkspaceFolder, err: unknown): void { const key = folder.uri.toString(); this.log.error(`could not start the analyser for ${folder.name}`, err); + // the binary is a window-wide problem with its own retry, not this folder's + const binary = binaryMessage(err); + if (binary !== undefined) { + this.binaryReady = false; + this.warnMissingBinary(binary); + return; + } if (this.warned.has(key)) return; this.warned.add(key); - const message = - err instanceof CccBinaryError - ? `ccc: ${err.message} Searched: ${err.searched.join(', ')}.` - : `ccc: could not start the analyser for ${folder.name}. See the log for details.`; void vscode.window - .showWarningMessage(message, 'Open Settings', 'Show Log') + .showWarningMessage( + `ccc: could not start the analyser for ${folder.name}. See the log for details.`, + 'Open Settings', + 'Show Log', + ) .then((choice) => { if (choice === 'Open Settings') { void vscode.commands.executeCommand('workbench.action.openSettings', 'ccc.binaryPath'); @@ -220,12 +321,19 @@ class Extension implements CommandHost { this.codeLens.updateConfig(this.cfg); this.hover.updateConfig(this.cfg); + // the fix for a missing binary usually arrives as a settings change - take it + // before the sessions below act on the same change + const wasReady = this.binaryReady; + if (!wasReady && bearsOnBinary(previous, this.cfg)) await this.ensureBinary(); + for (const [key, session] of this.sessionMap) { session.updateConfig(readConfig(vscode.workspace.getWorkspaceFolder(vscode.Uri.parse(key)))); } if (!this.cfg.enable) { this.clearAllDecorations(); } + // a session left in `failed` by the old settings does not restart itself + if (!wasReady && this.binaryReady) await this.retryStart(); await this.render(); } @@ -375,3 +483,19 @@ class Extension implements CommandHost { this.disposables = []; } } + +// the user-facing form of "there is no binary", or undefined for any other failure +function binaryMessage(err: unknown): string | undefined { + if (!(err instanceof CccBinaryError)) return undefined; + return `ccc: ${err.message} Searched: ${err.searched.join(', ')}.`; +} + +// settings that can turn "no binary" into "a binary", so are worth re-checking for +function bearsOnBinary(a: Cfg, b: Cfg): boolean { + return ( + a.enable !== b.enable || + a.binaryPath !== b.binaryPath || + a.autoInstall !== b.autoInstall || + a.server.autoStart !== b.server.autoStart + ); +} diff --git a/extensions/vscode/src/hover.ts b/extensions/vscode/src/hover.ts index fd1d5da..463651c 100644 --- a/extensions/vscode/src/hover.ts +++ b/extensions/vscode/src/hover.ts @@ -450,7 +450,7 @@ function complexityHover(fn: FileFunc): vscode.MarkdownString { const score = fn.complexity_score ?? 0; const md = new vscode.MarkdownString(); md.supportThemeIcons = true; - md.appendMarkdown(`**Complexity ${score}/10** - _${SCORE_DESCRIPTION[score] ?? ''}_`); + md.appendMarkdown(`**[ccc] Complexity ${score}/10** - _${SCORE_DESCRIPTION[score] ?? ''}_`); const parts: string[] = []; if (typeof fn.complexity === 'number') parts.push(`${fn.complexity} independent path(s)`); if (typeof fn.branches === 'number' && fn.branches > 0) { @@ -460,8 +460,8 @@ function complexityHover(fn: FileFunc): vscode.MarkdownString { parts.push(`${fn.loop_depth} nested loop level(s)`); } if (typeof fn.body_lines === 'number' && fn.body_lines > 0) parts.push(`${fn.body_lines} lines`); - if (parts.length > 0) md.appendMarkdown(`\n\nWhy: ${parts.join(' ยท ')}`); - md.appendMarkdown('\n\n_Cyclomatic-style: one path, plus one per decision point and loop._'); + if (parts.length > 0) md.appendMarkdown(`\n\nMeasures ${parts.join(', ')}`); + md.appendMarkdown('\n\n_Cyclomatic-style complexity analysis_'); return md; } diff --git a/extensions/vscode/src/server.ts b/extensions/vscode/src/server.ts index 5892026..641ea4a 100644 --- a/extensions/vscode/src/server.ts +++ b/extensions/vscode/src/server.ts @@ -50,6 +50,8 @@ export class ServerProcess implements vscode.Disposable { private cfg: Cfg, private readonly log: Log, private readonly label: string, + // extension storage, where an auto-installed ccc is kept + private readonly storage?: vscode.Uri, ) { // last-ditch cleanup if the extension host dies without calling deactivate this.exitGuard = () => this.child?.kill(); @@ -96,7 +98,7 @@ export class ServerProcess implements vscode.Disposable { this.setState({ kind: 'starting' }); let bin: string; try { - const resolved = await resolveCccBinary(this.folder, this.cfg, this.log); + const resolved = await resolveCccBinary(this.folder, this.cfg, this.log, this.storage); bin = resolved.path; } catch (err) { const message = diff --git a/extensions/vscode/src/session.ts b/extensions/vscode/src/session.ts index cc27378..1c27bc4 100644 --- a/extensions/vscode/src/session.ts +++ b/extensions/vscode/src/session.ts @@ -3,11 +3,11 @@ import * as vscode from 'vscode'; import { CccClient, isAborted } from './client'; import { type Cfg, needsRebuild, needsServerRestart } from './config'; import { FileStructureCache, refineFileHints } from './enclosing'; -import type { Log } from './log'; +import { describe, type Log } from './log'; import { buildHintIndex, type FileHints, type HintIndex } from './model'; import { keyOf, relOf } from './paths'; import { ServerProcess, type ServerState } from './server'; -import type { FileStructure, InsightsPayload, ReferencesResult } from './types'; +import type { FileStructure, InsightsPayload, ReferencesResult, VulnPayload } from './types'; export interface RefreshOptions { // POST /refresh before reading the analysis - the files on disk changed @@ -27,6 +27,7 @@ export class WorkspaceSession implements vscode.Disposable { private structures: FileStructureCache | undefined; private currentIndex: HintIndex | undefined; private lastPayload: InsightsPayload | undefined; + private lastVulns: VulnPayload | undefined; private lastGenerated: string | undefined; private lastBase: string | undefined; private inFlight: AbortController | undefined; @@ -40,8 +41,10 @@ export class WorkspaceSession implements vscode.Disposable { private cfg: Cfg, private readonly log: Log, private readonly userAgent: string, + // extension storage, where an auto-installed ccc is kept + storage?: vscode.Uri, ) { - this.server = new ServerProcess(folder.uri, cfg, log, folder.name); + this.server = new ServerProcess(folder.uri, cfg, log, folder.name, storage); this.server.onDidChangeState((state) => this.onServerState(state)); } @@ -49,6 +52,10 @@ export class WorkspaceSession implements vscode.Disposable { return this.currentIndex; } + get vulnerabilities(): VulnPayload | undefined { + return this.lastVulns; + } + get serverState(): ServerState { return this.server.state; } @@ -175,6 +182,26 @@ export class WorkspaceSession implements vscode.Disposable { `${this.currentIndex.counts.untested} untested, ${this.currentIndex.files.size} files with hints`, ); this.changed.fire(); + + // advisories are a separate, slower question - a failure here must not + // discard the refresh that already succeeded + try { + const vulns = await client.vulnerabilities(signal); + if (this.disposed || signal.aborted) return; + this.lastVulns = vulns; + const n = vulns.findings.length; + if (n > 0 || vulns.error) { + this.log.info( + `[${this.folder.name}] dependencies: ${vulns.packages.length} resolved, ${n} advisory finding(s)` + + (vulns.error ? ` (not assessed: ${vulns.error})` : ''), + ); + } + this.changed.fire(); + } catch (err) { + if (!isAborted(err)) { + this.log.warn(`[${this.folder.name}] could not read dependency advisories: ${describe(err)}`); + } + } } catch (err) { if (isAborted(err)) return; this.log.error(`[${this.folder.name}] refresh failed (${options.reason})`, err); diff --git a/extensions/vscode/src/types.ts b/extensions/vscode/src/types.ts index 048b855..90b6c88 100644 --- a/extensions/vscode/src/types.ts +++ b/extensions/vscode/src/types.ts @@ -396,3 +396,44 @@ export function lineSpan(value: unknown): [number, number] | undefined { if (start < 1) return undefined; return [start, Math.max(start, end)]; } + +// GET /vulnerabilities.json - dependency advisories with the manifest lines they belong on +export interface VulnLocation { + manifest: string; + line: number; + // the direct dependency whose line this is; the package itself when direct + via: string; +} + +export interface VulnPackage { + ecosystem: string; + name: string; + version: string; + direct: boolean; + dev: boolean; + lockfile: string; +} + +export interface VulnAdvisory { + id: string; + aliases: string[]; + summary: string; + severity: string; + fixed: string | null; + url: string; +} + +export interface VulnFinding { + package: VulnPackage; + advisory: VulnAdvisory; + locations: VulnLocation[]; +} + +export interface VulnPayload { + packages: VulnPackage[]; + findings: VulnFinding[]; + lockfiles: string[]; + unresolved: { manifest: string; reason: string }[]; + assessed: boolean; + error: string | null; +} diff --git a/extensions/vscode/src/vulns.ts b/extensions/vscode/src/vulns.ts new file mode 100644 index 0000000..419e0e3 --- /dev/null +++ b/extensions/vscode/src/vulns.ts @@ -0,0 +1,204 @@ +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import type { VulnFinding, VulnPayload } from './types'; + +// severities the analyser reports, worst first +const ORDER = ['critical', 'high', 'moderate', 'medium', 'low', 'rated', 'unknown']; + +function rank(severity: string): number { + const i = ORDER.indexOf(severity.toLowerCase()); + return i === -1 ? ORDER.length : i; +} + +// advisories on one manifest line, and what to draw for them +interface Mark { + line: number; + via: string; + hits: VulnFinding[]; +} + +const MAX_LISTED = 8; + +// Draws dependency advisories on the manifest lines that declare them. +// +// Decorations rather than diagnostics: a diagnostic is always drawn as a wavy +// underline, and the severity picks the colour. A solid line in one colour with +// a trailing badge is only reachable through a decoration type. +// +// Everything here is idempotent. `update` returns early when the advisories have +// not moved, and drawing re-sets the same ranges rather than clearing first - +// a clear followed by a set is what makes a mark blink. +export class VulnerabilityMarks implements vscode.Disposable { + // the solid line under the declaration itself + private readonly underline = vscode.window.createTextEditorDecorationType({ + borderStyle: 'none none solid none', + borderWidth: '0 0 1px 0', + borderColor: new vscode.ThemeColor('ccc.vulnerable'), + // a mark must not grow while the line is being edited + rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed, + overviewRulerColor: new vscode.ThemeColor('ccc.vulnerable'), + overviewRulerLane: vscode.OverviewRulerLane.Right, + }); + + // the badge sitting past the end of the line + private readonly badge = vscode.window.createTextEditorDecorationType({ + rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed, + }); + + // absolute manifest path -> the marks on it + private marks = new Map(); + // what the current marks were built from, so an unchanged payload redraws nothing + private signature = ''; + private readonly hover: vscode.Disposable; + + constructor() { + this.hover = vscode.languages.registerHoverProvider( + { scheme: 'file' }, + { + provideHover: (doc, pos) => this.hoverFor(doc, pos), + }, + ); + } + + // returns true when the marks actually changed + update(root: vscode.Uri, payload: VulnPayload | undefined): boolean { + // no answer yet is not the same as no findings - keep what is on screen + if (!payload) return false; + + const next = new Map(); + for (const finding of payload.findings) { + for (const loc of finding.locations) { + const file = path.join(root.fsPath, loc.manifest); + const list = next.get(file) ?? []; + const slot = list.find((m) => m.line === loc.line && m.via === loc.via); + // a transitive advisory arrives once per ancestor, so lines merge + if (slot) slot.hits.push(finding); + else list.push({ line: loc.line, via: loc.via, hits: [finding] }); + next.set(file, list); + } + } + + const signature = signatureOf(next); + if (signature === this.signature) return false; + this.signature = signature; + this.marks = next; + return true; + } + + // draw every visible editor that has marks + applyAll(): void { + for (const editor of vscode.window.visibleTextEditors) this.apply(editor); + } + + apply(editor: vscode.TextEditor): void { + const marks = this.marks.get(editor.document.uri.fsPath); + if (!marks || marks.length === 0) { + // only clear an editor that could plausibly have been marked + if (this.marks.size > 0) { + editor.setDecorations(this.underline, []); + editor.setDecorations(this.badge, []); + } + return; + } + + const lines: vscode.Range[] = []; + const badges: vscode.DecorationOptions[] = []; + for (const mark of marks) { + const row = mark.line - 1; + if (row < 0 || row >= editor.document.lineCount) continue; + const text = editor.document.lineAt(row); + // underline the declaration itself, not the indent or the trailing space + const from = text.firstNonWhitespaceCharacterIndex; + const to = text.text.trimEnd().length; + if (to > from) lines.push(new vscode.Range(row, from, row, to)); + + const end = new vscode.Range(row, text.text.length, row, text.text.length); + badges.push({ + range: end, + renderOptions: { + after: { + contentText: ` โš  ${badgeText(mark)}`, + color: new vscode.ThemeColor('ccc.vulnerable'), + fontStyle: 'italic', + }, + }, + }); + } + editor.setDecorations(this.underline, lines); + editor.setDecorations(this.badge, badges); + } + + private hoverFor(doc: vscode.TextDocument, pos: vscode.Position): vscode.Hover | undefined { + const marks = this.marks.get(doc.uri.fsPath); + if (!marks) return undefined; + const mark = marks.find((m) => m.line - 1 === pos.line); + if (!mark) return undefined; + + const md = new vscode.MarkdownString(); + md.supportThemeIcons = true; + md.appendMarkdown(`$(warning) **${headline(mark)}**\n\n`); + + const hits = sorted(mark.hits); + for (const h of hits.slice(0, MAX_LISTED)) { + const a = h.advisory; + const ids = a.aliases.length > 0 ? `${a.id} ยท ${a.aliases.join(' ยท ')}` : a.id; + const scope = h.package.dev ? ' _(dev only)_' : ''; + const fix = a.fixed ? `fixed in \`${a.fixed}\`` : 'no fixed version published'; + md.appendMarkdown( + `- **${a.severity.toUpperCase()}** [${ids}](${a.url})${scope} \n ${a.summary} \n ${fix}\n`, + ); + } + if (hits.length > MAX_LISTED) { + md.appendMarkdown(`\n_...and ${hits.length - MAX_LISTED} more โ€” run \`ccc audit\` for all._\n`); + } + return new vscode.Hover(md, new vscode.Range(pos.line, 0, pos.line, doc.lineAt(pos.line).text.length)); + } + + dispose(): void { + this.hover.dispose(); + this.underline.dispose(); + this.badge.dispose(); + } +} + +function sorted(hits: VulnFinding[]): VulnFinding[] { + return [...hits].sort((a, b) => rank(a.advisory.severity) - rank(b.advisory.severity)); +} + +// the packages a line is answerable for, named so the badge says what is wrong +function packagesOf(mark: Mark): string[] { + return [...new Set(mark.hits.map((h) => `${h.package.name} ${h.package.version}`))]; +} + +function badgeText(mark: Mark): string { + const packages = packagesOf(mark); + const n = mark.hits.length; + const advisories = n === 1 ? '1 advisory' : `${n} advisories`; + return packages.length === 1 + ? `${packages[0]} โ€” ${advisories}` + : `${packages.length} vulnerable packages โ€” ${advisories}`; +} + +function headline(mark: Mark): string { + const direct = mark.hits.filter((h) => h.package.name === mark.via); + const indirect = mark.hits.filter((h) => h.package.name !== mark.via); + if (indirect.length === 0) return `${packagesOf(mark).join(', ')} is vulnerable`; + const names = [...new Set(indirect.map((h) => `${h.package.name} ${h.package.version}`))]; + const lead = `${mark.via} pulls in ${names.join(', ')}`; + return direct.length > 0 ? `${lead}, and is itself vulnerable` : lead; +} + +// identity of the whole mark set, so an unchanged payload redraws nothing +function signatureOf(marks: Map): string { + const parts: string[] = []; + for (const file of [...marks.keys()].sort()) { + for (const mark of marks.get(file)!) { + const ids = mark.hits + .map((h) => h.advisory.id) + .sort() + .join(','); + parts.push(`${file}:${mark.line}:${mark.via}:${ids}`); + } + } + return parts.sort().join('|'); +} diff --git a/src/audit.rs b/src/audit.rs new file mode 100644 index 0000000..892b647 --- /dev/null +++ b/src/audit.rs @@ -0,0 +1,1991 @@ +// Software composition analysis: what this project actually depends on, and which of +// those dependencies carry known vulnerabilities. +// Resolution reads lockfiles rather than manifests + +use anyhow::Result; +use ignore::WalkBuilder; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::process::Command; +use std::sync::Mutex; + +const OSV_BATCH_URL: &str = "https://api.osv.dev/v1/querybatch"; +const OSV_VULN_URL: &str = "https://api.osv.dev/v1/vulns"; +const FETCH_TIMEOUT_SECS: u64 = 20; +// osv caps a querybatch; stay well under it +const BATCH_SIZE: usize = 500; +// how deep to look for lockfiles - deep enough for a monorepo package, not a full tree walk +const MAX_LOCKFILE_DEPTH: usize = 5; + +// the ecosystems we resolve, spelled the way the osv api expects +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub enum Ecosystem { + CratesIo, + Npm, + Go, + PyPi, + NuGet, +} + +impl Ecosystem { + pub fn osv(&self) -> &'static str { + match self { + Ecosystem::CratesIo => "crates.io", + Ecosystem::Npm => "npm", + Ecosystem::Go => "Go", + Ecosystem::PyPi => "PyPI", + Ecosystem::NuGet => "NuGet", + } + } + + pub fn label(&self) -> &'static str { + match self { + Ecosystem::CratesIo => "cargo", + Ecosystem::Npm => "npm", + Ecosystem::Go => "go", + Ecosystem::PyPi => "pypi", + Ecosystem::NuGet => "nuget", + } + } +} + +// one resolved package at an exact version +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct Package { + pub ecosystem: Ecosystem, + pub name: String, + pub version: String, + // named by a manifest rather than pulled in by something else + pub direct: bool, + // build/test only - a finding here does not reach production + pub dev: bool, + pub lockfile: String, +} + +impl Package { + fn key(&self) -> (Ecosystem, String, String) { + (self.ecosystem, self.name.clone(), self.version.clone()) + } +} + +// one advisory, as much of it as is worth carrying +#[derive(Debug, Clone, Serialize)] +pub struct Advisory { + pub id: String, + pub aliases: Vec, + pub summary: String, + pub severity: String, + // first version that is not affected, when the advisory names one + pub fixed: Option, + pub url: String, +} + +// an advisory matched to a package we actually depend on +#[derive(Debug, Clone, Serialize)] +pub struct Finding { + pub package: Package, + pub advisory: Advisory, + // manifest lines an editor should draw this on; empty when nothing declared it + #[serde(default)] + pub locations: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AuditReport { + pub packages: Vec, + pub findings: Vec, + // lockfiles the resolution came from, relative to the root + pub lockfiles: Vec, + // manifests found but not pinned by any lockfile - coverage gaps, reported + // so a clean result cannot be mistaken for a resolved one + pub unresolved: Vec, + // whether the advisory database was actually consulted + pub assessed: bool, + // why it was not, when it was not - never fatal + pub error: Option, +} + +impl AuditReport { + pub fn direct_count(&self) -> usize { + self.packages.iter().filter(|p| p.direct).count() + } + + // findings that reach production, which is the set worth acting on first + pub fn runtime_findings(&self) -> Vec<&Finding> { + self.findings.iter().filter(|f| !f.package.dev).collect() + } +} + +// a manifest that names dependencies but that no lockfile pinned, so nothing +// here could be matched against an advisory range. Reported rather than skipped +// silently - a count of "0 findings" means nothing if the packages never resolved. +#[derive(Debug, Clone, Serialize)] +pub struct Unresolved { + pub manifest: String, + pub reason: String, +} + +// lockfiles, which pin exact versions +const LOCK_NAMES: &[&str] = &[ + "Cargo.lock", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "go.sum", + "requirements.txt", + "poetry.lock", + "Pipfile.lock", + "uv.lock", + "pdm.lock", + "packages.lock.json", +]; + +// manifests, which declare ranges - present only so an unpinned project is reported +const MANIFEST_NAMES: &[&str] = &[ + "Cargo.toml", + "package.json", + "go.mod", + "pyproject.toml", + "Pipfile", +]; + +// A resolution needs text, not a filesystem. Reading a committed tree through +// the same parsers is what lets a caller compare two sides of a branch without +// one of them being whatever happens to sit on disk right now. +pub trait Source { + // every lockfile and manifest path this source holds, relative to the root + fn inputs(&self) -> Vec; + fn read(&self, rel: &str) -> Option; +} + +pub struct DiskSource<'a> { + pub root: &'a Path, +} + +impl Source for DiskSource<'_> { + fn inputs(&self) -> Vec { + let mut out = Vec::new(); + let walk = WalkBuilder::new(self.root) + .max_depth(Some(MAX_LOCKFILE_DEPTH)) + .hidden(false) + .git_ignore(true) + .filter_entry(|e| !is_vendored(e.file_name().to_str().unwrap_or(""))) + .build(); + for entry in walk.flatten() { + if !entry.file_type().is_some_and(|t| t.is_file()) { + continue; + } + if entry.file_name().to_str().is_some_and(is_input_name) { + out.push(rel_of(self.root, entry.path())); + } + } + out.sort(); + out.dedup(); + out + } + + fn read(&self, rel: &str) -> Option { + std::fs::read_to_string(self.root.join(rel)).ok() + } +} + +// A source that lists its inputs once. A resolution, its manifest declarations +// and its lockfile graph are three passes over the same file set, and a walk +// per pass is two walks too many. +pub struct Cached<'a> { + inner: &'a dyn Source, + inputs: Mutex>>, +} + +impl<'a> Cached<'a> { + pub fn new(inner: &'a dyn Source) -> Cached<'a> { + Cached { + inner, + inputs: Mutex::new(None), + } + } +} + +impl Source for Cached<'_> { + fn inputs(&self) -> Vec { + let mut slot = self.inputs.lock().unwrap_or_else(|p| p.into_inner()); + slot.get_or_insert_with(|| self.inner.inputs()).clone() + } + + fn read(&self, rel: &str) -> Option { + self.inner.read(rel) + } +} + +// a committed tree, read with `git show :` +pub struct GitSource<'a> { + root: &'a Path, + sha: &'a str, + // `root` may sit below the repository root, and git speaks repo-relative + prefix: String, + // several passes read the same lockfile; one `git show` per blob is enough + blobs: Mutex>>, +} + +impl<'a> GitSource<'a> { + pub fn new(root: &'a Path, sha: &'a str) -> GitSource<'a> { + GitSource { + root, + sha, + prefix: git_prefix(root), + blobs: Mutex::new(BTreeMap::new()), + } + } +} + +impl Source for GitSource<'_> { + fn inputs(&self) -> Vec { + let Some(raw) = git_out(self.root, &["ls-tree", "-r", "--name-only", "-z", self.sha]) + else { + return Vec::new(); + }; + let mut out: Vec = raw + .split('\0') + .filter(|s| !s.is_empty()) + .filter_map(|full| full.strip_prefix(self.prefix.as_str())) + .filter(|rel| is_input_name(base_name(rel))) + // the same exclusions the disk walk makes, and for the same reasons + .filter(|rel| { + !rel.split('/').any(is_vendored) && rel.split('/').count() <= MAX_LOCKFILE_DEPTH + }) + .map(str::to_string) + .collect(); + out.sort(); + out.dedup(); + out + } + + fn read(&self, rel: &str) -> Option { + let mut blobs = self.blobs.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(hit) = blobs.get(rel) { + return hit.clone(); + } + let spec = format!("{}:{}{rel}", self.sha, self.prefix); + let text = git_out(self.root, &["show", &spec]); + blobs.insert(rel.to_string(), text.clone()); + text + } +} + +// stdout of a git command, or None when git is absent or the command failed - +// a missing blob and a missing git are both "this source does not hold it" +pub(crate) fn git_out(root: &Path, args: &[&str]) -> Option { + let out = Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .output() + .ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).into_owned()) +} + +// where `root` sits inside its repository, "" at the top +fn git_prefix(root: &Path) -> String { + git_out(root, &["rev-parse", "--show-prefix"]) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +// a lockfile inside a dependency tree describes that dependency, not us +fn is_vendored(name: &str) -> bool { + matches!(name, "node_modules" | "target" | "vendor" | ".git") +} + +// the file names a resolution reads: lockfiles, and the manifests beside them +pub fn is_input_name(name: &str) -> bool { + LOCK_NAMES.contains(&name) + || MANIFEST_NAMES.contains(&name) + || name.ends_with(".csproj") + || name.ends_with(".fsproj") +} + +fn is_lock_name(name: &str) -> bool { + LOCK_NAMES.contains(&name) || name.ends_with(".csproj") || name.ends_with(".fsproj") +} + +pub(crate) fn base_name(rel: &str) -> &str { + match rel.rfind('/') { + Some(i) => &rel[i + 1..], + None => rel, + } +} + +// join a root-relative directory and a file name, "" being the root itself +pub(crate) fn join_rel(dir: &str, name: &str) -> String { + if dir.is_empty() { + name.to_string() + } else { + format!("{dir}/{name}") + } +} + +// find every lockfile under `root` and resolve it to exact packages +pub fn resolve(root: &Path) -> AuditReport { + resolve_with(&DiskSource { root }) +} + +// the same resolution against any source, so a committed tree and a working +// tree go through one code path rather than two that can drift apart +pub fn resolve_with(src: &dyn Source) -> AuditReport { + let mut packages: Vec = Vec::new(); + let mut lockfiles: Vec = Vec::new(); + let mut unresolved: Vec = Vec::new(); + let inputs = src.inputs(); + let present: BTreeSet<&str> = inputs.iter().map(String::as_str).collect(); + + for rel in inputs.iter().filter(|r| is_lock_name(base_name(r))) { + let Some(text) = src.read(rel) else { continue }; + let dir = rel_dir_of(rel); + let name = base_name(rel); + + let found = match name { + "Cargo.lock" => { + parse_toml_lock(&text, rel, Ecosystem::CratesIo, &direct_cargo(src, &dir)) + } + "package-lock.json" => parse_package_lock(&text, rel, &direct_npm(src, &dir)), + "yarn.lock" => parse_yarn_lock(&text, rel, &direct_npm(src, &dir)), + "pnpm-lock.yaml" => parse_pnpm_lock(&text, rel, &direct_npm(src, &dir)), + "go.sum" => parse_go_sum(&text, rel, &direct_go(src, &dir)), + "poetry.lock" | "uv.lock" | "pdm.lock" => { + parse_toml_lock(&text, rel, Ecosystem::PyPi, &direct_python(src, &dir)) + } + "Pipfile.lock" => parse_pipfile_lock(&text, rel), + "packages.lock.json" => parse_nuget_lock(&text, rel), + "requirements.txt" => { + let (found, skipped) = parse_requirements(&text, rel); + if skipped > 0 { + unresolved.push(Unresolved { + manifest: rel.clone(), + reason: format!( + "{skipped} requirement(s) are ranges rather than `==` pins, so no \ + version could be matched - lock them with `pip freeze` or `pip-compile`" + ), + }); + } + found + } + _ if name.ends_with(".csproj") || name.ends_with(".fsproj") => { + // only when no packages.lock.json in the same directory already pinned it + if present.contains(join_rel(&dir, "packages.lock.json").as_str()) { + Vec::new() + } else { + let (found, skipped) = parse_msbuild_project(&text, rel); + if skipped > 0 { + unresolved.push(Unresolved { + manifest: rel.clone(), + reason: format!( + "{skipped} PackageReference(s) use a version range rather than an \ + exact version - enable NuGet lockfiles with \ + RestorePackagesWithLockFile for the transitive closure" + ), + }); + } + found + } + } + _ => Vec::new(), + }; + + if found.is_empty() { + continue; + } + lockfiles.push(rel.clone()); + packages.extend(found); + } + + // a manifest whose ecosystem produced nothing means this project was never resolved + for rel in inputs.iter().filter(|r| MANIFEST_NAMES.contains(&base_name(r))) { + let (eco, want) = match base_name(rel) { + "Cargo.toml" => (Ecosystem::CratesIo, "Cargo.lock (`cargo generate-lockfile`)"), + "package.json" => ( + Ecosystem::Npm, + "package-lock.json, yarn.lock or pnpm-lock.yaml (`npm install`)", + ), + "go.mod" => (Ecosystem::Go, "go.sum (`go mod download`)"), + "pyproject.toml" | "Pipfile" => ( + Ecosystem::PyPi, + "poetry.lock, uv.lock, pdm.lock or Pipfile.lock", + ), + _ => continue, + }; + let dir_rel = rel_dir_of(rel); + let covered = packages + .iter() + .any(|p| p.ecosystem == eco && rel_dir_of(&p.lockfile) == dir_rel); + if !covered { + unresolved.push(Unresolved { + manifest: rel.clone(), + reason: format!("no lockfile beside it - versions come from {want}"), + }); + } + } + + packages.sort(); + packages.dedup_by(|a, b| a.key() == b.key()); + lockfiles.sort(); + unresolved.sort_by(|a, b| a.manifest.cmp(&b.manifest)); + unresolved.dedup_by(|a, b| a.manifest == b.manifest); + + AuditReport { + packages, + findings: Vec::new(), + lockfiles, + unresolved, + assessed: false, + error: None, + } +} + +fn rel_of(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +// the directory part of a repo-relative file path, "" at the root +pub(crate) fn rel_dir_of(rel: &str) -> String { + match rel.rfind('/') { + Some(i) => rel[..i].to_string(), + None => String::new(), + } +} + +// `[[package]]` blocks with a name and an exact version. Cargo, poetry, uv and +// pdm all write this same shape, so one parser serves four ecosystems. +fn parse_toml_lock( + text: &str, + lockfile: &str, + ecosystem: Ecosystem, + direct: &BTreeSet, +) -> Vec { + let mut out = Vec::new(); + let mut name: Option = None; + let mut version: Option = None; + let mut dev = false; + let mut in_package = false; + + let flush = |name: &mut Option, + version: &mut Option, + dev: &mut bool, + out: &mut Vec| { + if let (Some(n), Some(v)) = (name.take(), version.take()) { + let key = n.to_ascii_lowercase(); + out.push(Package { + ecosystem, + direct: direct.contains(&key) || direct.contains(&n), + name: n, + version: v, + dev: *dev, + lockfile: lockfile.to_string(), + }); + } + *dev = false; + }; + + for line in text.lines() { + let t = line.trim(); + if t == "[[package]]" { + flush(&mut name, &mut version, &mut dev, &mut out); + in_package = true; + continue; + } + if t.starts_with('[') && t != "[[package]]" { + flush(&mut name, &mut version, &mut dev, &mut out); + in_package = false; + continue; + } + if !in_package { + continue; + } + if let Some(v) = t.strip_prefix("name = ") { + name = Some(v.trim().trim_matches('"').to_string()); + } else if let Some(v) = t.strip_prefix("version = ") { + version = Some(v.trim().trim_matches('"').to_string()); + } else if let Some(v) = t.strip_prefix("category = ") { + // poetry before 1.5 marked the group this way + dev = v.trim().trim_matches('"') == "dev"; + } else if let Some(v) = t.strip_prefix("groups = ") { + // poetry 1.5+ and pdm; dev only when no runtime group claims it + let groups = v.trim(); + dev = !groups.contains("\"main\"") && !groups.contains("\"default\""); + } + } + flush(&mut name, &mut version, &mut dev, &mut out); + out +} + +// npm lockfile v2/v3 keep a flat `packages` map keyed by install path; v1 nests `dependencies` +fn parse_package_lock(text: &str, lockfile: &str, direct: &BTreeSet) -> Vec { + let Ok(doc) = serde_json::from_str::(text) else { + return Vec::new(); + }; + let mut out = Vec::new(); + + if let Some(map) = doc.get("packages").and_then(|v| v.as_object()) { + for (path, entry) in map { + // the root project itself is not a dependency + if path.is_empty() { + continue; + } + // nested installs read `node_modules/a/node_modules/b` - the package is the last hop + let Some(idx) = path.rfind("node_modules/") else { + continue; + }; + let name = &path[idx + "node_modules/".len()..]; + let Some(version) = entry.get("version").and_then(|v| v.as_str()) else { + continue; + }; + let dev = entry.get("dev").and_then(|v| v.as_bool()).unwrap_or(false) + || entry + .get("devOptional") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + out.push(Package { + ecosystem: Ecosystem::Npm, + direct: direct.contains(name), + name: name.to_string(), + version: version.to_string(), + dev, + lockfile: lockfile.to_string(), + }); + } + return out; + } + + // v1 fallback: walk the nested `dependencies` tree + fn walk( + node: &serde_json::Value, + lockfile: &str, + direct: &BTreeSet, + out: &mut Vec, + ) { + let Some(map) = node.get("dependencies").and_then(|v| v.as_object()) else { + return; + }; + for (name, entry) in map { + if let Some(version) = entry.get("version").and_then(|v| v.as_str()) { + out.push(Package { + ecosystem: Ecosystem::Npm, + direct: direct.contains(name.as_str()), + name: name.clone(), + version: version.to_string(), + dev: entry.get("dev").and_then(|v| v.as_bool()).unwrap_or(false), + lockfile: lockfile.to_string(), + }); + } + walk(entry, lockfile, direct, out); + } + } + walk(&doc, lockfile, direct, &mut out); + out +} + +// yarn classic and berry both write `:` then an indented `version` +fn parse_yarn_lock(text: &str, lockfile: &str, direct: &BTreeSet) -> Vec { + let mut out = Vec::new(); + let mut pending: Option = None; + for line in text.lines() { + if line.trim().is_empty() || line.trim_start().starts_with('#') { + continue; + } + let indented = line.starts_with(' ') || line.starts_with('\t'); + if !indented { + pending = line + .trim() + .strip_suffix(':') + .and_then(|h| h.split(',').next()) + .map(|spec| spec.trim().trim_matches('"').to_string()) + .and_then(|spec| yarn_name(&spec)); + continue; + } + let t = line.trim(); + let value = t + .strip_prefix("version ") + .or_else(|| t.strip_prefix("version: ")) + .or_else(|| t.strip_prefix("version:")); + if let (Some(name), Some(v)) = (pending.as_ref(), value) { + let version = v.trim().trim_matches('"').to_string(); + if !version.is_empty() { + out.push(Package { + ecosystem: Ecosystem::Npm, + direct: direct.contains(name.as_str()), + name: name.clone(), + version, + // yarn does not record the group in the lockfile + dev: false, + lockfile: lockfile.to_string(), + }); + } + pending = None; + } + } + out +} + +// `pkg@^1.0.0` / `@scope/pkg@npm:^1.0.0` -> the package name +fn yarn_name(spec: &str) -> Option { + let at = spec.get(1..)?.rfind('@').map(|i| i + 1)?; + let name = &spec[..at]; + (!name.is_empty()).then(|| name.to_string()) +} + +// pnpm keys the `packages` map by `name@version`, with an optional peer suffix +fn parse_pnpm_lock(text: &str, lockfile: &str, direct: &BTreeSet) -> Vec { + let mut out = Vec::new(); + let mut in_packages = false; + let mut pending: Option<(String, String)> = None; + + for line in text.lines() { + let trimmed = line.trim(); + // a blank line is not indented, but it does not end the section either + if trimmed.is_empty() { + continue; + } + if !line.starts_with(' ') && !line.starts_with('\t') { + // `snapshots:` repeats the same set, so only `packages:` is read + in_packages = trimmed == "packages:"; + pending = None; + continue; + } + if !in_packages { + continue; + } + // a key line is the only thing at two-space depth that ends in a colon + if let Some(key) = trimmed.strip_suffix(':') { + let key = key.trim().trim_matches('\'').trim_matches('"'); + let key = key.strip_prefix('/').unwrap_or(key); + // drop a peer-dependency suffix: `pkg@1.0.0(react@18.0.0)` + let key = key.split('(').next().unwrap_or(key); + if let Some((name, version)) = pnpm_split(key) { + pending = Some((name, version)); + if let Some((n, v)) = pending.clone() { + out.push(Package { + ecosystem: Ecosystem::Npm, + direct: direct.contains(n.as_str()), + name: n, + version: v, + dev: false, + lockfile: lockfile.to_string(), + }); + } + } + continue; + } + // `dev: true` belongs to the key above it + if trimmed == "dev: true" { + if let Some((n, v)) = pending.take() { + if let Some(p) = out + .iter_mut() + .rfind(|p: &&mut Package| p.name == n && p.version == v) + { + p.dev = true; + } + } + } + } + out +} + +fn pnpm_split(key: &str) -> Option<(String, String)> { + let at = key.get(1..)?.rfind('@').map(|i| i + 1)?; + let (name, version) = key.split_at(at); + let version = version.strip_prefix('@')?; + if name.is_empty() || version.is_empty() || !version.starts_with(|c: char| c.is_ascii_digit()) { + return None; + } + Some((name.to_string(), version.to_string())) +} + +// pipenv splits runtime and dev into two maps, each `name -> {version: "==x"}` +fn parse_pipfile_lock(text: &str, lockfile: &str) -> Vec { + let Ok(doc) = serde_json::from_str::(text) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for (section, dev) in [("default", false), ("develop", true)] { + let Some(map) = doc.get(section).and_then(|v| v.as_object()) else { + continue; + }; + for (name, entry) in map { + let Some(version) = entry.get("version").and_then(|v| v.as_str()) else { + continue; + }; + let version = version.trim_start_matches('=').trim(); + if version.is_empty() { + continue; + } + out.push(Package { + ecosystem: Ecosystem::PyPi, + // everything a Pipfile.lock names was asked for by the Pipfile + direct: true, + name: name.clone(), + version: version.to_string(), + dev, + lockfile: lockfile.to_string(), + }); + } + } + out +} + +// nuget's lockfile nests by target framework, and marks each entry Direct or Transitive +fn parse_nuget_lock(text: &str, lockfile: &str) -> Vec { + let Ok(doc) = serde_json::from_str::(text) else { + return Vec::new(); + }; + let Some(frameworks) = doc.get("dependencies").and_then(|v| v.as_object()) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for entries in frameworks.values() { + let Some(map) = entries.as_object() else { + continue; + }; + for (name, entry) in map { + let Some(version) = entry.get("resolved").and_then(|v| v.as_str()) else { + continue; + }; + out.push(Package { + ecosystem: Ecosystem::NuGet, + direct: entry.get("type").and_then(|v| v.as_str()) == Some("Direct"), + name: name.clone(), + version: version.to_string(), + dev: false, + lockfile: lockfile.to_string(), + }); + } + } + out +} + +// `` - direct dependencies only, +// and only where the version is exact rather than a range +fn parse_msbuild_project(text: &str, lockfile: &str) -> (Vec, usize) { + let mut out = Vec::new(); + let mut skipped = 0usize; + for line in text.lines() { + let t = line.trim(); + if !t.contains("PackageReference") { + continue; + } + let Some(name) = xml_attr(t, "Include").or_else(|| xml_attr(t, "Update")) else { + continue; + }; + let Some(version) = xml_attr(t, "Version") else { + // a version held in a child element or a central package file + skipped += 1; + continue; + }; + // `[1.0,2.0)` and `1.*` are ranges, which pin nothing + if version.contains(['[', ']', '(', ')', '*', ',']) { + skipped += 1; + continue; + } + out.push(Package { + ecosystem: Ecosystem::NuGet, + direct: true, + name, + version, + dev: false, + lockfile: lockfile.to_string(), + }); + } + (out, skipped) +} + +fn xml_attr(line: &str, attr: &str) -> Option { + let at = line.find(&format!("{attr}=\""))? + attr.len() + 2; + let rest = line.get(at..)?; + let end = rest.find('"')?; + let value = &rest[..end]; + (!value.is_empty()).then(|| value.to_string()) +} + +// `module version hash` lines; the `/go.mod` rows repeat a module already listed +fn parse_go_sum(text: &str, lockfile: &str, direct: &BTreeSet) -> Vec { + let mut out = Vec::new(); + for line in text.lines() { + let mut parts = line.split_whitespace(); + let (Some(name), Some(version)) = (parts.next(), parts.next()) else { + continue; + }; + let version = version.trim_end_matches("/go.mod"); + if name.is_empty() || !version.starts_with('v') { + continue; + } + out.push(Package { + ecosystem: Ecosystem::Go, + direct: direct.contains(name), + name: name.to_string(), + version: version.trim_start_matches('v').to_string(), + dev: false, + lockfile: lockfile.to_string(), + }); + } + out +} + +// only `name==version` pins resolve to something an advisory can be matched against; +// the count of everything else is returned so the gap can be reported +fn parse_requirements(text: &str, lockfile: &str) -> (Vec, usize) { + let mut out = Vec::new(); + let mut skipped = 0usize; + for line in text.lines() { + let t = line.split('#').next().unwrap_or("").trim(); + if t.is_empty() || t.starts_with('-') { + continue; + } + let Some((name, version)) = t.split_once("==") else { + // a range, a url or an extras-only line: nothing to match on + skipped += 1; + continue; + }; + let name = name.trim().split('[').next().unwrap_or("").trim(); + let version = version + .trim() + .split(|c: char| c == ' ' || c == ';') + .next() + .unwrap_or(""); + if name.is_empty() || version.is_empty() { + skipped += 1; + continue; + } + out.push(Package { + ecosystem: Ecosystem::PyPi, + name: name.to_string(), + version: version.to_string(), + // a requirements file is the manifest, so everything in it is declared + direct: true, + dev: false, + lockfile: lockfile.to_string(), + }); + } + (out, skipped) +} + +// names a manifest declares, used only to mark a resolved package as direct +fn direct_cargo(src: &dyn Source, dir: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + let Some(text) = src.read(&join_rel(dir, "Cargo.toml")) else { + return out; + }; + let mut in_deps = false; + for line in text.lines() { + let t = line.split('#').next().unwrap_or("").trim(); + if let Some(h) = t.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + let h = h.trim(); + in_deps = h.ends_with("dependencies"); + // `[dependencies.serde]` declares one by section name + if let Some((kind, name)) = h.split_once('.') { + if kind.ends_with("dependencies") { + out.insert(name.trim_matches('"').to_string()); + } + } + continue; + } + if !in_deps { + continue; + } + if let Some((name, _)) = t.split_once('=') { + let name = name.trim().trim_matches('"'); + if !name.is_empty() { + out.insert(name.to_string()); + } + } + } + out +} + +fn direct_npm(src: &dyn Source, dir: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + let Some(text) = src.read(&join_rel(dir, "package.json")) else { + return out; + }; + let Ok(doc) = serde_json::from_str::(&text) else { + return out; + }; + for kind in ["dependencies", "devDependencies", "optionalDependencies"] { + if let Some(map) = doc.get(kind).and_then(|v| v.as_object()) { + out.extend(map.keys().cloned()); + } + } + out +} + +// pyproject and Pipfile both list what was asked for, whatever tool locked it +fn direct_python(src: &dyn Source, dir: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + for file in ["pyproject.toml", "Pipfile"] { + let Some(text) = src.read(&join_rel(dir, file)) else { + continue; + }; + let mut in_deps = false; + for line in text.lines() { + let t = line.split('#').next().unwrap_or("").trim(); + if let Some(h) = t.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + let h = h.trim().to_ascii_lowercase(); + in_deps = h.contains("dependencies") || h == "packages" || h == "dev-packages"; + continue; + } + // `dependencies = ["flask>=2", ...]` in [project] + if t.starts_with("dependencies = [") || (in_deps && t.starts_with('"')) { + for chunk in t.split('"').filter(|c| !c.trim().is_empty()) { + let name = chunk + .split(|c: char| "=<>!~[;(".contains(c)) + .next() + .unwrap_or("") + .trim(); + if !name.is_empty() && name.chars().next().is_some_and(|c| c.is_alphanumeric()) { + out.insert(name.to_ascii_lowercase()); + } + } + continue; + } + if !in_deps { + continue; + } + if let Some((name, _)) = t.split_once('=') { + let name = name.trim().trim_matches('"'); + if !name.is_empty() { + out.insert(name.to_ascii_lowercase()); + } + } + } + } + out +} + +fn direct_go(src: &dyn Source, dir: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + let Some(text) = src.read(&join_rel(dir, "go.mod")) else { + return out; + }; + let mut in_block = false; + for line in text.lines() { + let t = line.split("//").next().unwrap_or("").trim(); + if t.starts_with("require (") { + in_block = true; + continue; + } + if in_block && t == ")" { + in_block = false; + continue; + } + let spec = if in_block { + t + } else { + match t.strip_prefix("require ") { + Some(s) => s.trim(), + None => continue, + } + }; + if let Some(path) = spec.split_whitespace().next() { + if !path.is_empty() { + out.insert(path.to_string()); + } + } + } + out +} + + +// attribution: which manifest line a finding belongs on + +// where an editor should draw a finding. A direct dependency points at its own +// declaration; a transitive one points at each direct dependency that pulls it +// in, because that is the line a person can actually change. +#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct Location { + pub manifest: String, + pub line: usize, + // the direct dependency whose line this is - the package itself when direct + pub via: String, +} + +// manifests that declare dependencies by name, per ecosystem +const DECLARING: &[(&str, Ecosystem)] = &[ + ("Cargo.toml", Ecosystem::CratesIo), + ("package.json", Ecosystem::Npm), + ("pyproject.toml", Ecosystem::PyPi), + ("Pipfile", Ecosystem::PyPi), + ("requirements.txt", Ecosystem::PyPi), + ("go.mod", Ecosystem::Go), +]; + +// the manifest lines to draw on, resolved once and reused. A caller with many +// packages to place - a dependency delta, say - pays for the manifest scan and +// the lockfile graph once rather than once per package. +pub struct Locator { + decls: Decls, + parents: BTreeMap>, +} + +impl Locator { + pub fn build(src: &dyn Source) -> Locator { + Locator { + decls: manifest_declarations(src), + parents: reverse_edges(src), + } + } + + pub fn locate(&self, pkg: &Package) -> Vec { + locations_for(pkg, &self.decls, &self.parents) + } +} + +// fill in `locations` for every finding, so an editor can draw them in a manifest +pub fn locate(root: &Path, report: &mut AuditReport) { + locate_with(&Cached::new(&DiskSource { root }), report); +} + +pub fn locate_with(src: &dyn Source, report: &mut AuditReport) { + let loc = Locator::build(src); + for f in &mut report.findings { + f.locations = loc.locate(&f.package); + } +} + +type Decls = BTreeMap<(Ecosystem, String), (String, usize)>; + +fn locations_for( + pkg: &Package, + decls: &Decls, + parents: &BTreeMap>, +) -> Vec { + let key = |n: &str| (pkg.ecosystem, n.to_ascii_lowercase()); + let mut out = Vec::new(); + + // a declared package points at its own line + if let Some((manifest, line)) = decls.get(&key(&pkg.name)) { + out.push(Location { + manifest: manifest.clone(), + line: *line, + via: pkg.name.clone(), + }); + return out; + } + + // otherwise walk up the lockfile graph to whatever declared it + let mut seen: BTreeSet = BTreeSet::new(); + let mut queue = vec![pkg.name.to_ascii_lowercase()]; + while let Some(name) = queue.pop() { + if !seen.insert(name.clone()) { + continue; + } + // a bounded walk - a pathological graph must not hang the scan + if seen.len() > 4096 { + break; + } + for parent in parents.get(&name).into_iter().flatten() { + if let Some((manifest, line)) = decls.get(&key(parent)) { + out.push(Location { + manifest: manifest.clone(), + line: *line, + via: parent.clone(), + }); + } else { + queue.push(parent.to_ascii_lowercase()); + } + } + } + out.sort(); + out.dedup(); + out +} + +// every name a manifest declares, with the line it is declared on +fn manifest_declarations(src: &dyn Source) -> Decls { + let mut out: Decls = BTreeMap::new(); + for rel in src.inputs() { + let name = base_name(&rel); + let eco = DECLARING + .iter() + .find(|(n, _)| *n == name) + .map(|(_, e)| *e) + .or_else(|| { + (name.ends_with(".csproj") || name.ends_with(".fsproj")).then_some(Ecosystem::NuGet) + }); + let Some(eco) = eco else { continue }; + let Some(text) = src.read(&rel) else { continue }; + for (declared, line) in declarations_in(&text, eco) { + // the first manifest to declare a name wins, which keeps the mapping stable + out.entry((eco, declared)).or_insert((rel.clone(), line)); + } + } + out +} + +// names declared by this manifest text, with 1-based line numbers +fn declarations_in(text: &str, eco: Ecosystem) -> Vec<(String, usize)> { + let mut out = Vec::new(); + let mut in_deps = false; + + for (i, raw) in text.lines().enumerate() { + let line = i + 1; + let t = raw.split('#').next().unwrap_or("").trim(); + if t.is_empty() { + continue; + } + + match eco { + Ecosystem::NuGet => { + if t.contains("PackageReference") { + if let Some(n) = xml_attr(t, "Include").or_else(|| xml_attr(t, "Update")) { + out.push((n.to_ascii_lowercase(), line)); + } + } + } + Ecosystem::Go => { + // `require path v1.2.3`, in a block or on its own + let spec = t.strip_prefix("require ").unwrap_or(t); + let mut parts = spec.split_whitespace(); + if let (Some(p), Some(v)) = (parts.next(), parts.next()) { + if p.contains('/') && v.starts_with('v') { + out.push((p.to_ascii_lowercase(), line)); + } + } + } + Ecosystem::CratesIo | Ecosystem::PyPi => { + // a table header both switches section and can declare a name + if let Some(h) = t.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + let h = h.trim(); + let lower = h.to_ascii_lowercase(); + in_deps = lower.ends_with("dependencies") + || lower == "packages" + || lower == "dev-packages"; + if let Some((kind, n)) = h.split_once('.') { + if kind.to_ascii_lowercase().ends_with("dependencies") { + out.push((n.trim_matches('"').to_ascii_lowercase(), line)); + } + } + continue; + } + // a pep 621 / requirements entry is a bare string + if t.starts_with('"') || t.starts_with('\'') { + if let Some(n) = t.trim_matches(|c| c == '"' || c == '\'' || c == ',').split(|c: char| "=<>!~[;( ".contains(c)).next() { + if !n.is_empty() && n.chars().next().is_some_and(|c| c.is_alphanumeric()) { + out.push((n.to_ascii_lowercase(), line)); + } + } + continue; + } + if in_deps || eco == Ecosystem::PyPi { + if let Some((n, _)) = t.split_once('=') { + let n = n.trim().trim_matches('"'); + if !n.is_empty() && !n.contains(' ') { + out.push((n.to_ascii_lowercase(), line)); + } + } + } + } + Ecosystem::Npm => { + // `"lodash": "^4.17.15"` - a quoted key with a string value + let Some(rest) = t.strip_prefix('"') else { + continue; + }; + let Some(end) = rest.find('"') else { continue }; + let key = &rest[..end]; + let after = rest[end + 1..].trim_start(); + if after.starts_with(':') && !key.is_empty() { + out.push((key.to_ascii_lowercase(), line)); + } + } + } + } + out +} + +// child -> the packages that require it, from whichever lockfiles record edges +fn reverse_edges(src: &dyn Source) -> BTreeMap> { + let mut out: BTreeMap> = BTreeMap::new(); + for rel in src.inputs() { + let name = base_name(&rel); + if !matches!(name, "Cargo.lock" | "package-lock.json") { + continue; + } + let Some(text) = src.read(&rel) else { continue }; + match name { + "Cargo.lock" => cargo_edges(&text, &mut out), + _ => npm_edges(&text, &mut out), + } + } + out +} + +// `dependencies = [ "b", "c 1.0" ]` inside each `[[package]]` block +fn cargo_edges(text: &str, out: &mut BTreeMap>) { + let mut current: Option = None; + let mut in_deps = false; + for line in text.lines() { + let t = line.trim(); + if t == "[[package]]" { + current = None; + in_deps = false; + continue; + } + if let Some(v) = t.strip_prefix("name = ") { + current = Some(v.trim().trim_matches('"').to_ascii_lowercase()); + continue; + } + if t.starts_with("dependencies = [") { + in_deps = true; + continue; + } + if in_deps { + if t == "]" { + in_deps = false; + continue; + } + // an entry is `"name"` or `"name version"` + let child = t.trim_matches(|c| c == '"' || c == ',').trim(); + if let (Some(parent), Some(first)) = (current.as_ref(), child.split_whitespace().next()) + { + if !first.is_empty() { + out.entry(first.to_ascii_lowercase()) + .or_default() + .insert(parent.clone()); + } + } + } + } +} + +// v2/v3 record each install path's own `dependencies` map +fn npm_edges(text: &str, out: &mut BTreeMap>) { + let Ok(doc) = serde_json::from_str::(text) else { + return; + }; + let Some(map) = doc.get("packages").and_then(|v| v.as_object()) else { + return; + }; + for (path, entry) in map { + let parent = match path.rfind("node_modules/") { + Some(i) => path[i + "node_modules/".len()..].to_ascii_lowercase(), + // the root entry's dependencies are the direct ones, already declared + None => continue, + }; + for kind in ["dependencies", "peerDependencies", "optionalDependencies"] { + let Some(deps) = entry.get(kind).and_then(|v| v.as_object()) else { + continue; + }; + for child in deps.keys() { + out.entry(child.to_ascii_lowercase()) + .or_default() + .insert(parent.clone()); + } + } + } +} + +#[derive(Deserialize)] +struct BatchResponse { + results: Vec, +} + +#[derive(Deserialize)] +struct BatchResult { + #[serde(default)] + vulns: Vec, +} + +#[derive(Deserialize)] +struct BatchVuln { + id: String, +} + +// ask osv which of the resolved packages are affected, and fill in the advisories +pub fn assess(report: &mut AuditReport) { + if report.packages.is_empty() { + report.assessed = true; + return; + } + match query_osv(&report.packages) { + Ok(findings) => { + report.findings = findings; + report.assessed = true; + } + Err(err) => { + report.error = Some(format!("{err:#}")); + report.assessed = false; + } + } +} + +fn query_osv(packages: &[Package]) -> Result> { + // package index -> advisory ids affecting it + let mut hits: Vec<(usize, Vec)> = Vec::new(); + + for (chunk_no, chunk) in packages.chunks(BATCH_SIZE).enumerate() { + let queries: Vec = chunk + .iter() + .map(|p| { + serde_json::json!({ + "package": { "name": p.name, "ecosystem": p.ecosystem.osv() }, + "version": p.version, + }) + }) + .collect(); + let body = serde_json::json!({ "queries": queries }).to_string(); + let raw = post(OSV_BATCH_URL, &body)?; + let parsed: BatchResponse = serde_json::from_str(&raw) + .map_err(|e| anyhow::anyhow!("osv returned an unreadable batch response: {e}"))?; + for (i, result) in parsed.results.into_iter().enumerate() { + if result.vulns.is_empty() { + continue; + } + let index = chunk_no * BATCH_SIZE + i; + hits.push((index, result.vulns.into_iter().map(|v| v.id).collect())); + } + } + + // one detail fetch per distinct advisory, however many packages it touches + let mut details: BTreeMap = BTreeMap::new(); + for id in hits.iter().flat_map(|(_, ids)| ids).collect::>() { + if let Ok(raw) = get(&format!("{OSV_VULN_URL}/{id}")) { + if let Ok(doc) = serde_json::from_str::(&raw) { + details.insert(id.clone(), advisory_from(id, &doc)); + } + } + } + + let mut findings = Vec::new(); + for (index, ids) in hits { + let Some(package) = packages.get(index) else { + continue; + }; + for id in ids { + let advisory = details.get(&id).cloned().unwrap_or_else(|| Advisory { + id: id.clone(), + aliases: Vec::new(), + summary: "advisory details could not be fetched".into(), + severity: "unknown".into(), + fixed: None, + url: format!("https://osv.dev/vulnerability/{id}"), + }); + findings.push(Finding { + package: package.clone(), + advisory, + locations: Vec::new(), + }); + } + } + + // what reaches production first, then worst first, then stable by name + findings.sort_by(|a, b| { + a.package + .dev + .cmp(&b.package.dev) + .then_with(|| { + severity_rank(&a.advisory.severity).cmp(&severity_rank(&b.advisory.severity)) + }) + .then_with(|| a.package.name.cmp(&b.package.name)) + .then_with(|| a.advisory.id.cmp(&b.advisory.id)) + }); + Ok(findings) +} + +fn advisory_from(id: &str, doc: &serde_json::Value) -> Advisory { + let summary = doc + .get("summary") + .and_then(|v| v.as_str()) + .or_else(|| doc.get("details").and_then(|v| v.as_str())) + .unwrap_or("no summary") + .lines() + .next() + .unwrap_or("no summary") + .trim() + .to_string(); + + let severity = doc + .get("database_specific") + .and_then(|d| d.get("severity")) + .and_then(|v| v.as_str()) + .map(|s| s.to_ascii_lowercase()) + .or_else(|| { + // rustsec and friends often ship only a vector; score it rather than shrug + doc.get("severity") + .and_then(|v| v.as_array()) + .and_then(|a| { + a.iter() + .filter_map(|s| s.get("score").and_then(|v| v.as_str())) + .find_map(cvss_v3_band) + }) + }) + .unwrap_or_else(|| "unknown".to_string()); + + let aliases = doc + .get("aliases") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + + // the first `fixed` event in any affected range is the version to move to + let fixed = doc + .get("affected") + .and_then(|v| v.as_array()) + .and_then(|affected| { + affected.iter().find_map(|a| { + a.get("ranges")?.as_array()?.iter().find_map(|r| { + r.get("events")?.as_array()?.iter().find_map(|e| { + e.get("fixed").and_then(|v| v.as_str()).map(str::to_string) + }) + }) + }) + }); + + Advisory { + id: id.to_string(), + aliases, + summary, + severity, + fixed, + url: format!("https://osv.dev/vulnerability/{id}"), + } +} + +// CVSS v3.x base score from its vector, so an advisory carrying only a vector still +// gets a band. v2 and v4 use different formulas and are left to read as unknown. +fn cvss_v3_band(vector: &str) -> Option { + if !vector.starts_with("CVSS:3") { + return None; + } + let mut m: BTreeMap<&str, &str> = BTreeMap::new(); + for part in vector.split('/').skip(1) { + if let Some((k, v)) = part.split_once(':') { + m.insert(k, v); + } + } + let changed = m.get("S") == Some(&"C"); + let av = match *m.get("AV")? { + "N" => 0.85, + "A" => 0.62, + "L" => 0.55, + "P" => 0.2, + _ => return None, + }; + let ac = match *m.get("AC")? { + "L" => 0.77, + "H" => 0.44, + _ => return None, + }; + // privileges required is scored differently once scope changes + let pr = match (*m.get("PR")?, changed) { + ("N", _) => 0.85, + ("L", false) => 0.62, + ("L", true) => 0.68, + ("H", false) => 0.27, + ("H", true) => 0.5, + _ => return None, + }; + let ui = match *m.get("UI")? { + "N" => 0.85, + "R" => 0.62, + _ => return None, + }; + let impact_of = |k: &str| -> Option { + Some(match *m.get(k)? { + "H" => 0.56, + "L" => 0.22, + "N" => 0.0, + _ => return None, + }) + }; + let (c, i, a) = (impact_of("C")?, impact_of("I")?, impact_of("A")?); + + let iss = 1.0 - ((1.0 - c) * (1.0 - i) * (1.0 - a)); + let impact = if changed { + 7.52 * (iss - 0.029) - 3.25 * (iss - 0.02).powi(15) + } else { + 6.42 * iss + }; + if impact <= 0.0 { + return Some("none".to_string()); + } + let exploitability = 8.22 * av * ac * pr * ui; + let raw = if changed { + (1.08 * (impact + exploitability)).min(10.0) + } else { + (impact + exploitability).min(10.0) + }; + let score = roundup(raw); + Some( + match score { + s if s >= 9.0 => "critical", + s if s >= 7.0 => "high", + s if s >= 4.0 => "moderate", + s if s > 0.0 => "low", + _ => "none", + } + .to_string(), + ) +} + +// the spec's own rounding, which is not the same as rounding to one decimal +fn roundup(input: f64) -> f64 { + let scaled = (input * 100_000.0).round() as i64; + if scaled % 10_000 == 0 { + scaled as f64 / 100_000.0 + } else { + ((scaled as f64 / 10_000.0).floor() + 1.0) / 10.0 + } +} + +pub fn severity_rank(severity: &str) -> u8 { + match severity { + "critical" => 0, + "high" => 1, + "moderate" | "medium" => 2, + "low" => 3, + "rated" => 4, + _ => 5, + } +} + +// post json by shelling out to curl, for the same reason `externals` does +fn post(url: &str, body: &str) -> Result { + run_curl(&[ + "--silent", + "--show-error", + "--location", + "--fail", + "--max-time", + &FETCH_TIMEOUT_SECS.to_string(), + "--header", + "Content-Type: application/json", + "--data-binary", + body, + url, + ]) +} + +fn get(url: &str) -> Result { + run_curl(&[ + "--silent", + "--show-error", + "--location", + "--fail", + "--max-time", + &FETCH_TIMEOUT_SECS.to_string(), + url, + ]) +} + +fn run_curl(args: &[&str]) -> Result { + let output = Command::new("curl") + .args(args) + .output() + .map_err(|e| anyhow::anyhow!("running curl failed (is curl installed?): {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("the advisory database is unreachable: {}", stderr.trim()); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cargo_lock_yields_exact_versions_and_marks_direct_ones() { + let lock = r#" +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "memchr" +version = "2.7.6" +"#; + let direct: BTreeSet = ["anyhow".to_string()].into_iter().collect(); + let got = parse_toml_lock(lock, "Cargo.lock", Ecosystem::CratesIo, &direct); + assert_eq!(got.len(), 2); + assert_eq!(got[0].name, "anyhow"); + assert_eq!(got[0].version, "1.0.100"); + assert!(got[0].direct); + // the transitive one is exactly what a manifest scan would have missed + assert_eq!(got[1].name, "memchr"); + assert!(!got[1].direct); + } + + #[test] + fn package_lock_v3_reads_the_flat_map_and_keeps_dev_apart() { + let lock = r#"{ + "lockfileVersion": 3, + "packages": { + "": { "version": "0.1.0" }, + "node_modules/esbuild": { "version": "0.21.5", "dev": true }, + "node_modules/left-pad": { "version": "1.3.0" }, + "node_modules/a/node_modules/nested": { "version": "2.0.0" } + } + }"#; + let direct: BTreeSet = ["esbuild".to_string()].into_iter().collect(); + let mut got = parse_package_lock(lock, "package-lock.json", &direct); + got.sort(); + let names: Vec<&str> = got.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, vec!["esbuild", "left-pad", "nested"]); + assert!(got.iter().find(|p| p.name == "esbuild").unwrap().dev); + assert!(!got.iter().find(|p| p.name == "left-pad").unwrap().dev); + // the root entry is the project, never a dependency of itself + assert!(!got.iter().any(|p| p.version == "0.1.0")); + } + + #[test] + fn go_sum_collapses_the_go_mod_rows() { + let sum = "golang.org/x/text v0.3.7 h1:abc=\ngolang.org/x/text v0.3.7/go.mod h1:def=\n"; + let got = parse_go_sum(sum, "go.sum", &BTreeSet::new()); + assert_eq!(got.len(), 2); + assert!(got.iter().all(|p| p.version == "0.3.7")); + } + + #[test] + fn requirements_takes_pins_and_ignores_ranges() { + let req = "flask==2.0.1\nrequests>=2.0\n# comment\n-r other.txt\ndjango==4.2.1 ; python_version > '3'\n"; + let (got, skipped) = parse_requirements(req, "requirements.txt"); + let names: Vec<&str> = got.iter().map(|p| p.name.as_str()).collect(); + // only the pins resolve to something matchable + assert_eq!(names, vec!["flask", "django"]); + assert_eq!(got[1].version, "4.2.1"); + // the range is counted rather than dropped in silence + assert_eq!(skipped, 1); + } + + #[test] + fn an_unreachable_database_is_reported_rather_than_fatal() { + let mut report = AuditReport { + packages: vec![Package { + ecosystem: Ecosystem::CratesIo, + name: "anyhow".into(), + version: "1.0.100".into(), + direct: true, + dev: false, + lockfile: "Cargo.lock".into(), + }], + findings: Vec::new(), + lockfiles: vec!["Cargo.lock".into()], + unresolved: Vec::new(), + assessed: false, + error: None, + }; + // point the assessment at nothing by breaking curl's argument list + if let Err(err) = query_osv(&[]) { + let _ = err; + } + // an empty package set short-circuits rather than calling out + report.packages.clear(); + assess(&mut report); + assert!(report.assessed); + assert!(report.error.is_none()); + } + + #[test] + fn cvss_vectors_score_into_the_bands_the_databases_publish() { + // the esbuild advisory: github rates this one MODERATE, and the vector agrees + assert_eq!( + cvss_v3_band("CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N").as_deref(), + Some("moderate") + ); + // log4shell, the canonical critical + assert_eq!( + cvss_v3_band("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H").as_deref(), + Some("critical") + ); + // no impact at all scores zero rather than falling through + assert_eq!( + cvss_v3_band("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N").as_deref(), + Some("none") + ); + // v2 and v4 are not this formula, so they are left alone + assert!(cvss_v3_band("AV:N/AC:L/Au:N/C:P/I:P/A:P").is_none()); + assert!(cvss_v3_band("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N").is_none()); + } + + #[test] + fn a_runtime_advisory_outranks_a_worse_dev_only_one() { + let pkg = |dev: bool, name: &str| Package { + ecosystem: Ecosystem::Npm, + name: name.into(), + version: "1.0.0".into(), + direct: true, + dev, + lockfile: "package-lock.json".into(), + }; + let adv = |sev: &str| Advisory { + id: "GHSA-x".into(), + aliases: vec![], + summary: "s".into(), + severity: sev.into(), + fixed: None, + url: "u".into(), + }; + let mut findings = vec![ + Finding { + package: pkg(true, "dev-critical"), + advisory: adv("critical"), + locations: Vec::new(), + }, + Finding { + package: pkg(false, "runtime-low"), + advisory: adv("low"), + locations: Vec::new(), + }, + ]; + findings.sort_by(|a, b| { + a.package + .dev + .cmp(&b.package.dev) + .then_with(|| { + severity_rank(&a.advisory.severity).cmp(&severity_rank(&b.advisory.severity)) + }) + .then_with(|| a.package.name.cmp(&b.package.name)) + }); + // a low that ships beats a critical that only ever ran on a build agent + assert_eq!(findings[0].package.name, "runtime-low"); + } + + #[test] + fn yarn_classic_and_berry_both_resolve_including_scoped_names() { + let v1 = "\ +# yarn lockfile v1 +ansi-regex@^5.0.1: + version \"5.0.1\" + +\"@babel/code-frame@^7.0.0\", \"@babel/code-frame@^7.10.4\": + version \"7.12.11\" +"; + let got = parse_yarn_lock(v1, "yarn.lock", &BTreeSet::new()); + let names: Vec<(&str, &str)> = got + .iter() + .map(|p| (p.name.as_str(), p.version.as_str())) + .collect(); + assert_eq!( + names, + vec![("ansi-regex", "5.0.1"), ("@babel/code-frame", "7.12.11")] + ); + + // berry writes yaml and a `npm:` protocol in the spec + let berry = "\ +\"lodash@npm:^4.17.21\": + version: 4.17.21 +"; + let got = parse_yarn_lock(berry, "yarn.lock", &BTreeSet::new()); + assert_eq!(got.len(), 1); + assert_eq!(got[0].name, "lodash"); + assert_eq!(got[0].version, "4.17.21"); + } + + #[test] + fn pnpm_strips_the_leading_slash_and_any_peer_suffix() { + let lock = "\ +lockfileVersion: '9.0' + +packages: + + /@babel/code-frame@7.12.11: + resolution: {integrity: sha512-x} + dev: true + + react-dom@18.2.0(react@18.2.0): + resolution: {integrity: sha512-y} + +snapshots: + + /@babel/code-frame@7.12.11: + dependencies: {} +"; + let got = parse_pnpm_lock(lock, "pnpm-lock.yaml", &BTreeSet::new()); + let names: Vec<(&str, &str)> = got + .iter() + .map(|p| (p.name.as_str(), p.version.as_str())) + .collect(); + // the snapshots section repeats the set, so it must not double-count + assert_eq!( + names, + vec![("@babel/code-frame", "7.12.11"), ("react-dom", "18.2.0")] + ); + assert!(got[0].dev, "dev: true belongs to the key above it"); + assert!(!got[1].dev); + } + + #[test] + fn poetry_and_uv_share_cargos_package_block_shape() { + let lock = r#" +[[package]] +name = "flask" +version = "2.0.1" +category = "main" + +[[package]] +name = "pytest" +version = "7.4.0" +category = "dev" +"#; + let direct: BTreeSet = ["flask".to_string()].into_iter().collect(); + let got = parse_toml_lock(lock, "poetry.lock", Ecosystem::PyPi, &direct); + assert_eq!(got.len(), 2); + assert_eq!(got[0].ecosystem, Ecosystem::PyPi); + assert!(got[0].direct && !got[0].dev); + // the dev group is kept apart so it can be filtered from what ships + assert!(got[1].dev); + } + + #[test] + fn pipfile_splits_runtime_from_develop() { + let lock = r#"{ + "default": { "flask": { "version": "==2.0.1" } }, + "develop": { "pytest": { "version": "==7.4.0" } } + }"#; + let mut got = parse_pipfile_lock(lock, "Pipfile.lock"); + got.sort(); + assert_eq!(got.len(), 2); + let flask = got.iter().find(|p| p.name == "flask").unwrap(); + assert_eq!(flask.version, "2.0.1"); + assert!(!flask.dev); + assert!(got.iter().find(|p| p.name == "pytest").unwrap().dev); + } + + #[test] + fn nuget_lockfile_carries_the_transitive_closure_and_marks_direct_ones() { + let lock = r#"{ + "version": 1, + "dependencies": { + ".NETCoreApp,Version=v8.0": { + "Newtonsoft.Json": { "type": "Direct", "resolved": "13.0.1" }, + "System.Buffers": { "type": "Transitive", "resolved": "4.5.1" } + } + } + }"#; + let mut got = parse_nuget_lock(lock, "packages.lock.json"); + got.sort(); + assert_eq!(got.len(), 2); + assert!(got.iter().all(|p| p.ecosystem == Ecosystem::NuGet)); + assert!(got.iter().find(|p| p.name == "Newtonsoft.Json").unwrap().direct); + // the transitive one is what a csproj alone would never have named + assert!(!got.iter().find(|p| p.name == "System.Buffers").unwrap().direct); + } + + #[test] + fn a_csproj_resolves_exact_versions_and_counts_the_ranges() { + let proj = r#" + + + + + + + +"#; + let (got, skipped) = parse_msbuild_project(proj, "App.csproj"); + assert_eq!(got.len(), 1); + assert_eq!(got[0].name, "Newtonsoft.Json"); + assert_eq!(got[0].version, "13.0.1"); + // a range and a missing version pin nothing, and are reported rather than dropped + assert_eq!(skipped, 2); + } + + // a throwaway repo whose only commit holds `files`, returning (dir, sha) + fn one_commit_repo(tag: &str, files: &[(&str, &str)]) -> (std::path::PathBuf, String) { + let dir = std::env::temp_dir().join(format!("ccc-audit-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for (rel, text) in files { + let to = dir.join(rel); + std::fs::create_dir_all(to.parent().unwrap()).unwrap(); + std::fs::write(to, text).unwrap(); + } + let git = |args: &[&str]| { + let out = Command::new("git") + .arg("-C") + .arg(&dir) + .args(args) + .output() + .unwrap_or_else(|e| panic!("git {args:?}: {e}")); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + git(&["init", "-q"]); + git(&["add", "-A"]); + git(&[ + "-c", + "user.name=audit-test", + "-c", + "user.email=audit@test", + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "-m", + "base", + ]); + let sha = git(&["rev-parse", "HEAD"]); + (dir, sha) + } + + #[test] + fn a_committed_tree_resolves_to_what_the_same_content_on_disk_does() { + let manifest = "[package]\nname = \"app\"\n\n[dependencies]\nserde = \"1\"\n"; + let lock = "version = 4\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.203\"\n\n\ + [[package]]\nname = \"memchr\"\nversion = \"2.7.6\"\n"; + let (dir, sha) = one_commit_repo( + "gitsource", + &[("Cargo.toml", manifest), ("Cargo.lock", lock)], + ); + + let from_disk = resolve(&dir); + let from_git = resolve_with(&GitSource::new(&dir, &sha)); + assert_eq!(from_git.lockfiles, from_disk.lockfiles); + assert_eq!(from_git.packages, from_disk.packages); + assert_eq!(from_git.packages.len(), 2); + // the manifest is read through the source too, so `direct` survives the trip + let serde = from_git.packages.iter().find(|p| p.name == "serde").unwrap(); + assert!(serde.direct); + assert!(!from_git.packages.iter().find(|p| p.name == "memchr").unwrap().direct); + + // and the working tree diverging does not move the committed answer + std::fs::write(dir.join("Cargo.lock"), lock.replace("1.0.203", "1.0.210")).unwrap(); + let again = resolve_with(&GitSource::new(&dir, &sha)); + assert_eq!(again.packages, from_git.packages); + assert_eq!( + resolve(&dir) + .packages + .iter() + .find(|p| p.name == "serde") + .unwrap() + .version, + "1.0.210" + ); + + // a sha that holds nothing resolves to nothing rather than falling back to disk + let empty = resolve_with(&GitSource::new(&dir, "0000000000000000000000000000000000000000")); + assert!(empty.packages.is_empty() && empty.lockfiles.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn severity_orders_worst_first() { + assert!(severity_rank("critical") < severity_rank("high")); + assert!(severity_rank("high") < severity_rank("moderate")); + assert!(severity_rank("low") < severity_rank("unknown")); + } +} diff --git a/src/changes.rs b/src/changes.rs index 7de20cd..ed1534a 100644 --- a/src/changes.rs +++ b/src/changes.rs @@ -2,6 +2,7 @@ //! //! Groups source files into named services (from `.ccc/map.json` and/or //! `--service` flags), diffs the branch against a base ref. +// ccc:skip use crate::coverage; use crate::extract::BDD_REGISTRARS; @@ -34,6 +35,11 @@ pub struct ChangesOptions { // uncommitted edits and untracked files count as changes. CI wants the // committed view (the default); an engineer wants this one. pub worktree: bool, + // Off by default: it walks transcripts a CI run has no use for + pub prompts: bool, + // Work out what this branch did to the dependency tree. `ccc changes` + // always asks for it + pub deps: bool, } @@ -79,6 +85,10 @@ pub struct ChangedFile { pub services: Vec, // changed relative to HEAD as well as to the base: not committed yet pub uncommitted: bool, + // the requests sent to claude/copilot that produced this file's changes, + // strongest evidence first. Only populated with `prompts` + #[serde(skip_serializing_if = "Vec::is_empty")] + pub prompted_by: Vec, } #[derive(Debug, Serialize, Clone)] @@ -98,6 +108,10 @@ pub struct ChangedFunction { // same-named test in another language. pub tested_by_sites: Vec, pub called_from: Vec, + // the requests behind this function's changes: the same references the + // file carries, narrowed to the ones whose evidence reaches this span + #[serde(skip_serializing_if = "Vec::is_empty")] + pub prompted_by: Vec, } #[derive(Debug, Serialize, Clone)] @@ -161,6 +175,12 @@ pub struct ChangesCounts { pub crossings: usize, // crossings whose key nothing answers: a typo, or a peer not configured pub unmatched_crossings: usize, + // changed functions a request accounts for + pub prompted_functions: usize, + pub unattributed_files: usize, + // telemetric definitions this branch moved + pub telemetry_changes: usize, + pub telemetry_breaking: usize, } #[derive(Debug, Serialize)] @@ -185,6 +205,16 @@ pub struct ChangesReport { pub externals: Vec, // `ccc:calls` / `ccc:serves` pairs, including the ones that leave the repo pub crossings: Vec, + // requests sent to an agent + #[serde(skip_serializing_if = "Vec::is_empty")] + pub turns: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub unattributed: Vec, + // What this branch did to the dependency tree + #[serde(skip_serializing_if = "Option::is_none")] + pub deps: Option, + // what this branch did to the OpenTelemetry metrics + pub telemetry: crate::telemetry::TelemetryReport, pub counts: ChangesCounts, } @@ -289,6 +319,40 @@ pub fn changes_with_caches( BTreeSet::new() }; + // What the branch did to the dependency tree + let deps = opts.deps.then(|| { + crate::deps::analyse( + root, + &crate::deps::DepsOptions { + base_sha: &base_sha, + head_sha: &head_sha, + worktree: opts.worktree, + touched: &statuses, + offline: false, + }, + ) + }); + + // What the branch did to the metrics the source emits + let telemetry = crate::telemetry::analyse( + root, + &crate::telemetry::TelemetryOptions { + base_sha: &base_sha, + head_sha: &head_sha, + worktree: opts.worktree, + }, + ); + + // Which request produced which change + let (turns, attribution) = if opts.prompts { + let (turns, _) = crate::prompts::collect(root, &crate::prompts::PromptsOptions::default()); + let times = write_times(root, &base_sha, &hunks, &uncommitted); + let attribution = crate::prompts::attribute(root, &turns, &hunks, ×); + (turns, attribution) + } else { + (Vec::new(), crate::prompts::Attribution::default()) + }; + let idx = build_indexes(root, caches, &matchers); // Which tests reach which definitions. One relation, shared with // `insights`, so the two reports cannot disagree about what is covered. @@ -321,6 +385,7 @@ pub fn changes_with_caches( status: status.clone(), services, uncommitted: uncommitted.contains(path), + prompted_by: attribution.by_file.get(path).cloned().unwrap_or_default(), }); } changed_files.sort_by(|a, b| a.path.cmp(&b.path)); @@ -368,11 +433,23 @@ pub fn changes_with_caches( .collect() }) .unwrap_or_default(); + // A file-wide reference explains every span in it + let prompted_by: Vec = attribution + .by_file + .get(&rel) + .map(|refs| { + refs.iter() + .filter(|r| r.covers(f.start_line, f.end_line)) + .cloned() + .collect() + }) + .unwrap_or_default(); changed_functions.push(ChangedFunction { services: services.clone(), file: rel.clone(), function: f.name.clone(), lines: [f.start_line, f.end_line], + prompted_by, tested, tested_by, tested_by_sites, @@ -403,6 +480,13 @@ pub fn changes_with_caches( externals: externals.len(), crossings: crossings.len(), unmatched_crossings, + prompted_functions: changed_functions + .iter() + .filter(|f| !f.prompted_by.is_empty()) + .count(), + unattributed_files: attribution.unattributed.len(), + telemetry_changes: telemetry.changes.len(), + telemetry_breaking: telemetry.counts.breaking, }; Ok(ChangesReport { @@ -422,10 +506,52 @@ pub fn changes_with_caches( unresolved_calls, externals: externals.iter().map(|e| e.json()).collect(), crossings: crossings.iter().map(crossing_json).collect(), + turns, + unattributed: attribution.unattributed, + deps, + telemetry, counts, }) } +// The dependency delta on its own +pub fn deps_report( + root: &Path, + base: Option<&str>, + worktree: bool, +) -> Result<(String, crate::deps::DepsReport)> { + let (base_label, base_sha) = resolve_base(root, base)?; + let head_sha = git(root, &["rev-parse", "HEAD"])?.trim().to_string(); + let mut diff_refs: Vec<&str> = vec![&base_sha]; + if !worktree { + diff_refs.push("HEAD"); + } + let mut args = vec!["diff", "--relative", "--name-status", "-z", "-M"]; + args.extend(&diff_refs); + let mut touched = parse_name_status(&git_bytes(root, &args)?); + // a lockfile git has never seen is still a lockfile this branch added + if worktree { + for path in git_bytes(root, &["ls-files", "--others", "--exclude-standard", "-z"])? + .split(|&b| b == 0) + .map(|s| String::from_utf8_lossy(s).into_owned()) + .filter(|s| !s.is_empty()) + { + touched.push(("added".to_string(), path)); + } + } + let report = crate::deps::analyse( + root, + &crate::deps::DepsOptions { + base_sha: &base_sha, + head_sha: &head_sha, + worktree, + touched: &touched, + offline: false, + }, + ); + Ok((base_label, report)) +} + fn crossing_json(c: &crate::externals::Crossing) -> Value { serde_json::json!({ "key": c.key, @@ -1446,8 +1572,84 @@ fn parse_hunks(diff: &str) -> BTreeMap> { out } +// The changed line ranges per file, plus when each of those files was last +// written. `prompts` needs both: the ranges say what moved, the times say +// which request was in flight when it moved. +pub fn changed_line_times( + root: &Path, + base: Option<&str>, + worktree: bool, +) -> Result<(String, BTreeMap>, BTreeMap)> { + let (base_label, base_sha) = resolve_base(root, base)?; + let mut diff_refs: Vec<&str> = vec![&base_sha]; + if !worktree { + diff_refs.push("HEAD"); + } + let mut hunk_args = vec!["diff", "--relative", "--unified=0", "-M"]; + hunk_args.extend(&diff_refs); + let mut hunks = parse_hunks(&git(root, &hunk_args)?); + let mut uncommitted = BTreeSet::new(); + if worktree { + for path in git_bytes(root, &["ls-files", "--others", "--exclude-standard", "-z"])? + .split(|&b| b == 0) + .map(|s| String::from_utf8_lossy(s).into_owned()) + .filter(|s| !s.is_empty()) + { + hunks.entry(path.clone()).or_insert_with(|| vec![(1, usize::MAX)]); + uncommitted.insert(path); + } + for (_, p) in parse_name_status(&git_bytes( + root, + &["diff", "--relative", "--name-status", "-z", "-M", "HEAD"], + )?) { + uncommitted.insert(p); + } + } + let times = write_times(root, &base_sha, &hunks, &uncommitted); + Ok((base_label, hunks, times)) +} + +// When each changed file was last written, in epoch seconds +pub(crate) fn write_times( + root: &Path, + base_sha: &str, + hunks: &BTreeMap>, + uncommitted: &BTreeSet, +) -> BTreeMap { + let mut out = BTreeMap::new(); + let range = format!("{base_sha}..HEAD"); + if let Ok(log) = git( + root, + &["log", "--format=@%ct", "--name-only", "--relative", &range], + ) { + // newest commit first, so the first mention of a path is its latest + let mut stamp = 0i64; + for line in log.lines() { + match line.strip_prefix('@') { + Some(t) => stamp = t.trim().parse().unwrap_or(0), + None if hunks.contains_key(line) => { + out.entry(line.to_string()).or_insert(stamp); + } + None => {} + } + } + } + for path in uncommitted { + let Ok(mtime) = fs::metadata(root.join(path)) + .and_then(|m| m.modified()) + .and_then(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) + }) + else { + continue; + }; + out.insert(path.clone(), mtime.as_secs() as i64); + } + out +} -// build one matcher per service; bare pattern with no glob is a dir prefix +// build one matcher per service; bare pattern with no glob is a dir prefix pub(crate) fn build_matchers(services: &BTreeMap>) -> Result> { let mut out = Vec::new(); for (name, patterns) in services { @@ -2155,6 +2357,8 @@ diff --git a/gone.rs b/gone.rs base: Some(base_sha), service_flags: vec![], worktree: false, + prompts: false, + deps: false, }; let report = changes(&dir, ".", &opts).unwrap_or_else(|e| panic!("{tag}: {e}")); let _ = fs::remove_dir_all(&dir); @@ -2603,6 +2807,8 @@ diff --git a/gone.rs b/gone.rs base: Some(base_sha.clone()), service_flags: vec![], worktree: false, + prompts: false, + deps: false, }; let report = changes(&dir, ".", &opts).unwrap(); diff --git a/src/deps.rs b/src/deps.rs new file mode 100644 index 0000000..4a12ed4 --- /dev/null +++ b/src/deps.rs @@ -0,0 +1,1346 @@ +//! `ccc changes` and `ccc deps` - what this branch did to the dependency tree. +//! +//! `audit` answers what we depend on right now, and never looks at git. This +//! answers the other half: which packages entered the tree, which left, which +//! moved version, and whether that brought in an advisory that was not there +//! before. +//! +//! Both sides resolve through the same lockfile parsers `audit` uses, reading +//! text out of a committed tree rather than off disk, so the committed view a +//! CI run wants cannot be contaminated by a dirty working copy. Only the delta +//! is checked against the advisory database - the standing set is `audit`'s +//! job, and a branch that bumps one package should cost one bounded query. + +use crate::audit::{ + self, base_name, join_rel, rel_dir_of, Cached, DiskSource, Ecosystem, Finding, GitSource, + Location, Package, Source, Unresolved, +}; +use serde::Serialize; +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +pub const SCHEMA: &str = "ccc-deps/1"; + +// the lockfiles a manifest is regenerated into, so an edit to one without the +// other can be named +const LOCKS_FOR: &[(&str, &[&str])] = &[ + ("Cargo.toml", &["Cargo.lock"]), + ( + "package.json", + &["package-lock.json", "yarn.lock", "pnpm-lock.yaml"], + ), + ("go.mod", &["go.sum"]), + ( + "pyproject.toml", + &[ + "poetry.lock", + "uv.lock", + "pdm.lock", + "Pipfile.lock", + "requirements.txt", + ], + ), + ("Pipfile", &["Pipfile.lock", "requirements.txt"]), +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum DepChangeKind { + Added, + Removed, + Upgraded, + Downgraded, + // npm's multi-version reality: many in, many out, reported with both lists + // rather than flattened into a bump that did not happen + VersionsChanged, + Promoted, + Demoted, + NowShips, + NoLongerShips, +} + +impl DepChangeKind { + pub fn label(&self) -> &'static str { + match self { + DepChangeKind::Added => "added", + DepChangeKind::Removed => "removed", + DepChangeKind::Upgraded => "upgraded", + DepChangeKind::Downgraded => "downgraded", + DepChangeKind::VersionsChanged => "versions-changed", + DepChangeKind::Promoted => "promoted", + DepChangeKind::Demoted => "demoted", + DepChangeKind::NowShips => "now-ships", + DepChangeKind::NoLongerShips => "no-longer-ships", + } + } + + // the marker the text report draws it with + fn marker(&self) -> char { + match self { + DepChangeKind::Added => '+', + DepChangeKind::Removed => '-', + DepChangeKind::Upgraded | DepChangeKind::Downgraded | DepChangeKind::VersionsChanged => { + '~' + } + _ => '*', + } + } + + // report order: the kinds a reader acts on first + fn rank(&self) -> u8 { + match self { + DepChangeKind::Added => 0, + DepChangeKind::Removed => 1, + DepChangeKind::Upgraded => 2, + DepChangeKind::Downgraded => 3, + _ => 4, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct DepChange { + pub kind: DepChangeKind, + pub ecosystem: Ecosystem, + pub name: String, + pub lockfile: String, + // versions at base, and at head + pub from: Vec, + pub to: Vec, + // as of the head side, or the base side for a package head no longer holds + pub direct: bool, + pub dev: bool, + // head manifest lines to draw this on; empty for a removed package nothing declares + pub locations: Vec, +} + +#[derive(Debug, Clone, Copy, Default, Serialize)] +pub struct DepsCounts { + pub added: usize, + pub removed: usize, + pub upgraded: usize, + pub downgraded: usize, + pub other: usize, + pub introduced: usize, + pub resolved: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DepsReport { + pub schema: &'static str, + pub base_sha: String, + pub head_sha: String, + // false when no manifest or lockfile was touched: everything below is empty + pub changed: bool, + // the base side had no lockfile at all - the whole closure reads as `added` + pub baseline: bool, + pub changes: Vec, + // advisories this change brings in, and ones it clears + pub introduced: Vec, + pub resolved: Vec, + // manifests edited without their lockfile being regenerated, and lockfiles + // that could not be read at one end + pub drift: Vec, + pub assessed: bool, + pub error: Option, + pub counts: DepsCounts, +} + +impl DepsReport { + // whether a gate should fail: an advisory came in, or the branch moved + // dependencies and we could not check - "we could not check" is not "it is + // fine" + pub fn gates(&self) -> bool { + !self.introduced.is_empty() || (self.changed && !self.assessed) + } +} + +pub struct DepsOptions<'a> { + pub base_sha: &'a str, + pub head_sha: &'a str, + // the head side is the working tree rather than the committed head + pub worktree: bool, + // (status, path) rows from the branch diff; only the manifest and lockfile + // rows are read, and an empty result stops the pass before any blob is read + pub touched: &'a [(String, String)], + // resolve without consulting the advisory database + pub offline: bool, +} + +// --------------------------------------------------------------------------- +// the pass +// --------------------------------------------------------------------------- + +pub fn analyse(root: &Path, opts: &DepsOptions) -> DepsReport { + let touched: Vec<&str> = opts + .touched + .iter() + .map(|(_, p)| p.as_str()) + .filter(|p| audit::is_input_name(base_name(p))) + .collect::>() + .into_iter() + .collect(); + // a branch that touched no manifest costs nothing, which is most branches + if touched.is_empty() { + return empty(opts, None); + } + if audit::git_out(root, &["rev-parse", "--git-dir"]).is_none() { + return empty( + opts, + Some("git could not be run, so the base side could not be read".into()), + ); + } + + let base = audit::resolve_with(&GitSource::new(root, opts.base_sha)); + // the head side is read four times over - resolution, the input set, and + // the two halves of the locator - so it lists itself once + let head_inner: Box = if opts.worktree { + Box::new(DiskSource { root }) + } else { + Box::new(GitSource::new(root, opts.head_sha)) + }; + let head_src = Cached::new(head_inner.as_ref()); + let head = audit::resolve_with(&head_src); + let head_inputs: BTreeSet = head_src.inputs().into_iter().collect(); + + // a moved lockfile is a move, not a wholesale remove plus add + let renames = rename_map(root, opts, &touched); + let base_sides = sides(&base.packages, &renames); + let head_sides = sides(&head.packages, &BTreeMap::new()); + + let keys: BTreeSet = base_sides.keys().chain(head_sides.keys()).cloned().collect(); + let mut pending: Vec<(DepChange, Package)> = Vec::new(); + for key in &keys { + let b = base_sides.get(key); + let h = head_sides.get(key); + let Some(kind) = classify(b, h) else { continue }; + let side = h.or(b).expect("a key exists on at least one side"); + let Some(sample) = side.packages.first().cloned() else { + continue; + }; + pending.push(( + DepChange { + kind, + ecosystem: key.0, + name: key.2.clone(), + lockfile: key.1.clone(), + from: b.map(|s| s.versions.iter().cloned().collect()).unwrap_or_default(), + to: h.map(|s| s.versions.iter().cloned().collect()).unwrap_or_default(), + direct: side.direct, + dev: side.dev, + locations: Vec::new(), + }, + sample, + )); + } + + // only the versions that exist on exactly one side are worth a query: a + // version present at both ends carried whatever it carried before this + // branch, and that is the standing set `audit` already reports + let mut base_only: BTreeSet = BTreeSet::new(); + let mut head_only: BTreeSet = BTreeSet::new(); + let mut probe: BTreeMap = BTreeMap::new(); + for key in &keys { + let at_base = base_sides.get(key).map(|s| &s.versions); + let at_head = head_sides.get(key).map(|s| &s.versions); + for p in head_sides.get(key).map(|s| &s.packages).into_iter().flatten() { + if !at_base.is_some_and(|v| v.contains(&p.version)) { + head_only.insert(pv(p)); + probe.insert(pv(p), p.clone()); + } + } + for p in base_sides.get(key).map(|s| &s.packages).into_iter().flatten() { + if !at_head.is_some_and(|v| v.contains(&p.version)) { + base_only.insert(pv(p)); + // the head-side package is the one a reader can act on, so it wins + probe.entry(pv(p)).or_insert_with(|| p.clone()); + } + } + } + + let mut probe_report = audit::AuditReport { + packages: probe.into_values().collect(), + findings: Vec::new(), + lockfiles: Vec::new(), + unresolved: Vec::new(), + assessed: false, + error: None, + }; + if opts.offline { + probe_report.error = Some("the advisory database was not consulted".into()); + } else { + audit::assess(&mut probe_report); + } + + let mut came: Vec = Vec::new(); + let mut went: Vec = Vec::new(); + for f in &probe_report.findings { + if head_only.contains(&pv(&f.package)) { + came.push(f.clone()); + } + if base_only.contains(&pv(&f.package)) { + went.push(f.clone()); + } + } + // an advisory that lands on both sides was already here: this branch + // neither brought it in nor cleared it + let on_head: BTreeSet = came.iter().map(ident).collect(); + let on_base: BTreeSet = went.iter().map(ident).collect(); + let mut introduced: Vec = came + .into_iter() + .filter(|f| !on_base.contains(&ident(f))) + .collect(); + let mut resolved: Vec = went + .into_iter() + .filter(|f| !on_head.contains(&ident(f))) + .collect(); + + // one manifest scan and one lockfile graph for every package to be placed + let loc = (!pending.is_empty()).then(|| audit::Locator::build(&head_src)); + let place = |pkg: &Package| -> Vec { + loc.as_ref().map(|l| l.locate(pkg)).unwrap_or_default() + }; + let mut changes: Vec = pending + .into_iter() + .map(|(mut c, sample)| { + c.locations = place(&sample); + c + }) + .collect(); + changes.sort_by(|a, b| { + (a.kind.rank(), a.ecosystem, &a.name, &a.lockfile) + .cmp(&(b.kind.rank(), b.ecosystem, &b.name, &b.lockfile)) + }); + for f in introduced.iter_mut().chain(resolved.iter_mut()) { + f.locations = place(&f.package); + } + + let base_locks: BTreeSet = base + .lockfiles + .iter() + .map(|l| renames.get(l).cloned().unwrap_or_else(|| l.clone())) + .collect(); + let drift = drift(&touched, &head, &head_inputs, &base_locks); + + let counts = DepsCounts { + added: count(&changes, DepChangeKind::Added), + removed: count(&changes, DepChangeKind::Removed), + upgraded: count(&changes, DepChangeKind::Upgraded), + downgraded: count(&changes, DepChangeKind::Downgraded), + other: changes.iter().filter(|c| c.kind.rank() == 4).count(), + introduced: introduced.len(), + resolved: resolved.len(), + }; + + DepsReport { + schema: SCHEMA, + base_sha: opts.base_sha.to_string(), + head_sha: opts.head_sha.to_string(), + changed: true, + // no lockfile at base at all: every package reads as added, and saying + // so is the difference between that and a branch adding 1,204 packages + baseline: base.lockfiles.is_empty() && !head.lockfiles.is_empty(), + changes, + introduced, + resolved, + drift, + assessed: probe_report.assessed, + error: probe_report.error, + counts, + } +} + +fn empty(opts: &DepsOptions, error: Option) -> DepsReport { + DepsReport { + schema: SCHEMA, + base_sha: opts.base_sha.to_string(), + head_sha: opts.head_sha.to_string(), + changed: false, + baseline: false, + changes: Vec::new(), + introduced: Vec::new(), + resolved: Vec::new(), + drift: Vec::new(), + assessed: false, + error, + counts: DepsCounts::default(), + } +} + +fn count(changes: &[DepChange], kind: DepChangeKind) -> usize { + changes.iter().filter(|c| c.kind == kind).count() +} + +// --------------------------------------------------------------------------- +// keying and classification +// --------------------------------------------------------------------------- + +// Keyed per lockfile, not per ecosystem: npm legitimately holds several +// versions of one package in one lockfile, and a monorepo holds one package at +// different versions in different lockfiles. Diffing version sets under this +// key keeps both honest. +type Key = (Ecosystem, String, String); +// a package at an exact version, which is what an advisory range matches +type Pv = (Ecosystem, String, String); +type Ident = (Ecosystem, String, String); + +struct Side { + versions: BTreeSet, + direct: bool, + dev: bool, + // lockfile paths already mapped through any rename + packages: Vec, +} + +fn pv(p: &Package) -> Pv { + (p.ecosystem, p.name.clone(), p.version.clone()) +} + +fn ident(f: &Finding) -> Ident { + ( + f.package.ecosystem, + f.package.name.clone(), + f.advisory.id.clone(), + ) +} + +fn sides(pkgs: &[Package], renames: &BTreeMap) -> BTreeMap { + let mut out: BTreeMap = BTreeMap::new(); + for p in pkgs { + let lockfile = renames + .get(&p.lockfile) + .cloned() + .unwrap_or_else(|| p.lockfile.clone()); + let side = out + .entry((p.ecosystem, lockfile.clone(), p.name.clone())) + .or_insert_with(|| Side { + versions: BTreeSet::new(), + direct: false, + // a key ships unless every version of it is dev-only + dev: true, + packages: Vec::new(), + }); + side.versions.insert(p.version.clone()); + side.direct |= p.direct; + side.dev &= p.dev; + let mut mapped = p.clone(); + mapped.lockfile = lockfile; + side.packages.push(mapped); + } + out +} + +fn classify(base: Option<&Side>, head: Option<&Side>) -> Option { + match (base, head) { + (None, Some(_)) => Some(DepChangeKind::Added), + (Some(_), None) => Some(DepChangeKind::Removed), + (Some(b), Some(h)) => { + if b.versions != h.versions { + let gone: Vec<&String> = b.versions.difference(&h.versions).collect(); + let came: Vec<&String> = h.versions.difference(&b.versions).collect(); + // the ordinary bump: exactly one version out, exactly one in + if let ([from], [to]) = (gone.as_slice(), came.as_slice()) { + return Some(match version_direction(from, to) { + Some(Ordering::Less) => DepChangeKind::Upgraded, + Some(Ordering::Greater) => DepChangeKind::Downgraded, + // a pair this ecosystem does not order: still a change, + // with no claim about which way it went + _ => DepChangeKind::VersionsChanged, + }); + } + return Some(DepChangeKind::VersionsChanged); + } + // a flag flip at the same version. Whether it ships outranks whether + // a manifest names it: a dev advisory that never shipped now does. + if b.dev != h.dev { + return Some(if h.dev { + DepChangeKind::NoLongerShips + } else { + DepChangeKind::NowShips + }); + } + if b.direct != h.direct { + return Some(if h.direct { + DepChangeKind::Promoted + } else { + DepChangeKind::Demoted + }); + } + None + } + (None, None) => None, + } +} + +// Which way a version moved, or None when the two cannot be ordered honestly. +// A plain `major.minor.patch` is the one shape all five ecosystems order the +// same way; a Go pseudo-version, a PyPI epoch, an npm prerelease tag and a +// NuGet build suffix each order by rules of their own, so they read as unknown +// rather than being guessed at. +fn version_direction(from: &str, to: &str) -> Option { + Some(triple(from)?.cmp(&triple(to)?)) +} + +fn triple(v: &str) -> Option<(u64, u64, u64)> { + let mut parts = v.split('.'); + let mut num = [0u64; 3]; + for slot in num.iter_mut() { + let Some(part) = parts.next() else { break }; + *slot = part.parse().ok()?; + } + // a fourth component is a shape this comparison does not cover + parts + .next() + .is_none() + .then_some((num[0], num[1], num[2])) +} + +// --------------------------------------------------------------------------- +// renames and drift +// --------------------------------------------------------------------------- + +// lockfiles that moved, base path -> head path. `changes::parse_name_status` +// reads rename records but flattens them into `renamed(new)` + `deleted(old)`, +// which is all its callers need; this pass needs the pairing, so it asks git +// for it over the handful of paths involved rather than disturbing a hot parser. +fn rename_map(root: &Path, opts: &DepsOptions, touched: &[&str]) -> BTreeMap { + let mut args: Vec<&str> = vec![ + "diff", + "--relative", + "--name-status", + "-z", + "-M", + opts.base_sha, + ]; + if !opts.worktree { + args.push(opts.head_sha); + } + args.push("--"); + args.extend(touched.iter().copied()); + let Some(raw) = audit::git_out(root, &args) else { + return BTreeMap::new(); + }; + + let mut out = BTreeMap::new(); + let mut it = raw.split('\0').filter(|s| !s.is_empty()); + while let Some(status) = it.next() { + match status.chars().next().unwrap_or('?') { + 'R' => { + let (Some(old), Some(new)) = (it.next(), it.next()) else { + break; + }; + out.insert(old.to_string(), new.to_string()); + } + // a copy leaves the original in place, so it renames nothing + 'C' => { + if it.next().is_none() || it.next().is_none() { + break; + } + } + _ => { + if it.next().is_none() { + break; + } + } + } + } + out +} + +// A resolution that half-worked must never read as a clean result. Everything +// here is a gap between what the branch declared and what it pinned. +fn drift( + touched: &[&str], + head: &audit::AuditReport, + head_inputs: &BTreeSet, + base_locks: &BTreeSet, +) -> Vec { + let seen: BTreeSet<&str> = touched.iter().copied().collect(); + let mut out: Vec = Vec::new(); + + for path in touched { + let name = base_name(path); + let dir = rel_dir_of(path); + let siblings: Vec<&str> = LOCKS_FOR + .iter() + .find(|(m, _)| *m == name) + .map(|(_, l)| l.to_vec()) + .or_else(|| { + (name.ends_with(".csproj") || name.ends_with(".fsproj")) + .then(|| vec!["packages.lock.json"]) + }) + .unwrap_or_default(); + if siblings.is_empty() { + continue; + } + // the manifest moved and one of its lockfiles moved with it: nothing to say + if siblings + .iter() + .any(|l| seen.contains(join_rel(&dir, l).as_str())) + { + continue; + } + // a manifest with no lockfile beside it is already an `unresolved` in + // the head resolution, and reporting it twice says nothing new + let Some(lock) = siblings + .iter() + .find(|l| head_inputs.contains(&join_rel(&dir, l))) + else { + continue; + }; + out.push(Unresolved { + manifest: path.to_string(), + reason: format!( + "{name} changed but {lock} did not - the declared range moved and the pinned \ + version did not" + ), + }); + } + + for lock in base_locks { + if head_inputs.contains(lock) { + continue; + } + let dir = rel_dir_of(lock); + let manifest = LOCKS_FOR + .iter() + .find(|(_, locks)| locks.contains(&base_name(lock))) + .map(|(m, _)| join_rel(&dir, m)) + .filter(|m| head_inputs.contains(m)); + out.push(Unresolved { + manifest: lock.clone(), + reason: match manifest { + Some(m) => format!("{lock} was removed - {m} now pins nothing"), + None => format!("{lock} was removed - nothing pins the packages it held"), + }, + }); + } + + // gaps the head resolution already found, for the files this branch touched + for u in &head.unresolved { + if seen.contains(u.manifest.as_str()) { + out.push(u.clone()); + } + } + + out.sort_by(|a, b| a.manifest.cmp(&b.manifest)); + out.dedup_by(|a, b| a.manifest == b.manifest && a.reason == b.reason); + out +} + +// --------------------------------------------------------------------------- +// rendering +// --------------------------------------------------------------------------- + +// the versions cell: `name 1.0.0`, or `name 1.0.0 -> 1.1.0` for a move +fn subject(c: &DepChange) -> String { + let join = |v: &[String]| v.join(", "); + match (c.from.is_empty(), c.to.is_empty()) { + (true, _) => format!("{} {}", c.name, join(&c.to)), + (_, true) => format!("{} {}", c.name, join(&c.from)), + _ if c.from == c.to => format!("{} {}", c.name, join(&c.to)), + _ => format!("{} {} -> {}", c.name, join(&c.from), join(&c.to)), + } +} + +// how it reaches us: the line a person can actually edit, and whether it ships +fn reach(c: &DepChange) -> String { + let mut out = if c.direct { + "direct".to_string() + } else { + match c.locations.first() { + Some(l) => format!("transitive, via {}", l.via), + None => "transitive".to_string(), + } + }; + if c.dev { + out.push_str(", dev"); + } + out +} + +fn headline(r: &DepsReport, base: &str) -> String { + if !r.changed { + return format!("dependencies: unchanged against {base}"); + } + let c = &r.counts; + let parts: Vec = [ + (c.added, "added"), + (c.removed, "removed"), + (c.upgraded, "upgraded"), + (c.downgraded, "downgraded"), + (c.other, "other"), + ] + .iter() + .filter(|(n, _)| *n > 0) + .map(|(n, label)| format!("{n} {label}")) + .collect(); + let detail = if parts.is_empty() { + String::new() + } else { + format!(" ({})", parts.join(", ")) + }; + format!( + "dependencies: {} changed{detail} against {base}", + r.changes.len() + ) +} + +// one line per finding, plus the manifest lines it should be read at +fn finding_lines(out: &mut String, f: &Finding, indent: &str) { + use std::fmt::Write; + let fixed = match &f.advisory.fixed { + Some(v) => format!(" - fixed in {v}"), + None => " - no fixed version published".to_string(), + }; + let _ = writeln!( + out, + "{indent}{} {} {} {}{fixed}", + f.advisory.id, f.advisory.severity, f.package.name, f.package.version + ); + for l in &f.locations { + let _ = writeln!(out, "{indent} {}:{} - via {}", l.manifest, l.line, l.via); + } +} + +// the `changes` text report's voice, appended to it +pub fn text(r: &DepsReport, base: &str) -> String { + use std::fmt::Write; + let mut out = String::new(); + let _ = writeln!(out, "{}", headline(r, base)); + if let Some(err) = &r.error { + // the resolution above still stands; only the assessment is missing + let _ = writeln!(out, " not assessed - {err}"); + } + if !r.changed { + return out; + } + if r.baseline { + let _ = writeln!( + out, + " no lockfile at the base, so the whole closure reads as added" + ); + } + + let width = r + .changes + .iter() + .map(|c| subject(c).chars().count()) + .max() + .unwrap_or(0) + .min(48); + let eco = r + .changes + .iter() + .map(|c| c.ecosystem.label().len()) + .max() + .unwrap_or(0); + let lock = r + .changes + .iter() + .map(|c| c.lockfile.chars().count()) + .max() + .unwrap_or(0) + .min(40); + for c in &r.changes { + // `+`, `-` and `~` say what happened; `*` covers four kinds, so it names its own + let kind = if c.kind.rank() == 4 { + format!(", {}", c.kind.label()) + } else { + String::new() + }; + let _ = writeln!( + out, + " {} {:width$} {:eco$} {:lock$} {}{kind}", + c.kind.marker(), + subject(c), + c.ecosystem.label(), + c.lockfile, + reach(c) + ); + } + + if !r.introduced.is_empty() { + let _ = write!(out, "\nintroduced ({}):\n", r.introduced.len()); + for f in &r.introduced { + finding_lines(&mut out, f, " "); + } + } + if !r.resolved.is_empty() { + let _ = write!(out, "\nresolved ({}):\n", r.resolved.len()); + for f in &r.resolved { + finding_lines(&mut out, f, " "); + } + } + if !r.drift.is_empty() { + let _ = write!(out, "\ndrift ({}):\n", r.drift.len()); + for d in &r.drift { + let _ = writeln!(out, " {} - {}", d.manifest, d.reason); + } + } + out +} + +// the same answer for an agent, in the tone `vulnerabilities` uses +pub fn markdown(r: &DepsReport, base: &str) -> String { + use std::fmt::Write; + let mut out = String::new(); + let _ = writeln!(out, "# {}", headline(r, base)); + let _ = writeln!( + out, + "\nbase {} -> head {}", + short(&r.base_sha), + short(&r.head_sha) + ); + if let Some(err) = &r.error { + let _ = writeln!(out, "\nadvisories not assessed - {err}"); + } + if !r.changed { + out.push_str( + "\nno manifest or lockfile changed on this branch, so nothing entered, left or moved \ + version. What the project depends on right now is `vulnerabilities`, not this.\n", + ); + return out; + } + if r.baseline { + out.push_str( + "\nthe base side held no lockfile, so the whole resolved closure reads as added \ + rather than as a change this branch made\n", + ); + } + + let _ = write!(out, "\n## changes ({})\n", r.changes.len()); + for c in &r.changes { + let _ = writeln!( + out, + "- {} {} - {}, {}, {}", + c.kind.label(), + subject(c), + c.ecosystem.label(), + c.lockfile, + reach(c) + ); + } + + if r.introduced.is_empty() && r.assessed { + out.push_str("\nno advisory was introduced by this change\n"); + } + if !r.introduced.is_empty() { + let _ = write!(out, "\n## introduced ({})\n", r.introduced.len()); + for f in &r.introduced { + let _ = writeln!(out, "\n{}", f.advisory.summary); + finding_lines(&mut out, f, ""); + } + } + if !r.resolved.is_empty() { + let _ = write!(out, "\n## resolved ({})\n", r.resolved.len()); + for f in &r.resolved { + finding_lines(&mut out, f, ""); + } + } + if !r.drift.is_empty() { + let _ = write!(out, "\n## drift ({})\n", r.drift.len()); + for d in &r.drift { + let _ = writeln!(out, "- {} - {}", d.manifest, d.reason); + } + } + out.push_str( + "\nOnly the versions that exist on exactly one side of this branch were checked against \ + the advisory database. Standing advisories against everything else are `vulnerabilities`.\n", + ); + out +} + +fn short(sha: &str) -> &str { + &sha[..sha.len().min(9)] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pkg(name: &str, version: &str, direct: bool, dev: bool) -> Package { + Package { + ecosystem: Ecosystem::CratesIo, + name: name.into(), + version: version.into(), + direct, + dev, + lockfile: "Cargo.lock".into(), + } + } + + fn kinds(base: &[Package], head: &[Package]) -> Vec<(String, DepChangeKind)> { + let b = sides(base, &BTreeMap::new()); + let h = sides(head, &BTreeMap::new()); + let keys: BTreeSet = b.keys().chain(h.keys()).cloned().collect(); + keys.iter() + .filter_map(|k| classify(b.get(k), h.get(k)).map(|kind| (k.2.clone(), kind))) + .collect() + } + + #[test] + fn every_row_of_the_classification_table() { + let base = vec![ + pkg("gone", "1.0.0", true, false), + pkg("bumped", "1.0.0", true, false), + pkg("dropped", "2.0.0", true, false), + pkg("transitive-then-named", "1.0.0", false, false), + pkg("named-then-transitive", "1.0.0", true, false), + pkg("build-only", "1.0.0", true, true), + pkg("shipped", "1.0.0", true, false), + pkg("still", "1.0.0", true, false), + ]; + let head = vec![ + pkg("arrived", "0.1.0", true, false), + pkg("bumped", "1.1.0", true, false), + pkg("dropped", "1.9.0", true, false), + pkg("transitive-then-named", "1.0.0", true, false), + pkg("named-then-transitive", "1.0.0", false, false), + pkg("build-only", "1.0.0", true, false), + pkg("shipped", "1.0.0", true, true), + pkg("still", "1.0.0", true, false), + ]; + let got: BTreeMap = kinds(&base, &head).into_iter().collect(); + assert_eq!(got.get("arrived"), Some(&DepChangeKind::Added)); + assert_eq!(got.get("gone"), Some(&DepChangeKind::Removed)); + assert_eq!(got.get("bumped"), Some(&DepChangeKind::Upgraded)); + assert_eq!(got.get("dropped"), Some(&DepChangeKind::Downgraded)); + assert_eq!( + got.get("transitive-then-named"), + Some(&DepChangeKind::Promoted) + ); + assert_eq!( + got.get("named-then-transitive"), + Some(&DepChangeKind::Demoted) + ); + assert_eq!(got.get("build-only"), Some(&DepChangeKind::NowShips)); + assert_eq!(got.get("shipped"), Some(&DepChangeKind::NoLongerShips)); + // an untouched package is not a change, and must not be reported as one + assert!(!got.contains_key("still")); + } + + #[test] + fn npm_multi_version_reports_both_lists_rather_than_a_fake_bump() { + let npm = |v: &str| Package { + ecosystem: Ecosystem::Npm, + name: "lodash".into(), + version: v.into(), + direct: false, + dev: false, + lockfile: "package-lock.json".into(), + }; + let base = vec![npm("4.17.20"), npm("3.10.1")]; + let head = vec![npm("4.17.21"), npm("3.10.2")]; + let got = kinds(&base, &head); + assert_eq!(got.len(), 1); + assert_eq!(got[0].1, DepChangeKind::VersionsChanged); + + // and the same package in two lockfiles is two keys, not one conflict + let mut other = npm("4.17.21"); + other.lockfile = "web/package-lock.json".into(); + let got = kinds(&[npm("4.17.20")], &[npm("4.17.20"), other]); + assert_eq!(got.len(), 1, "the second lockfile is an addition of its own"); + assert_eq!(got[0].1, DepChangeKind::Added); + } + + #[test] + fn a_version_pair_the_ecosystem_does_not_order_claims_no_direction() { + // a go pseudo-version, a pypi epoch and an npm prerelease tag each order + // by rules of their own + assert!(version_direction("0.0.0-20191109021931-daa7c04131f5", "1.2.3").is_none()); + assert!(version_direction("1.0.0", "1.0.1-rc.1").is_none()); + assert!(version_direction("1.0.0", "1!2.0.0").is_none()); + assert!(version_direction("1.2.3", "1.2.3.4").is_none()); + // the shape every ecosystem here does order the same way + assert_eq!(version_direction("1.0.0", "1.1.0"), Some(Ordering::Less)); + assert_eq!(version_direction("2.0.0", "1.9.9"), Some(Ordering::Greater)); + // a two-component version is the same version as its padded form + assert_eq!(version_direction("2.0", "2.0.0"), Some(Ordering::Equal)); + + // an unorderable pair is still reported as a change + let got = kinds( + &[pkg("mystery", "1.0.0", true, false)], + &[pkg("mystery", "1.0.1-rc.1", true, false)], + ); + assert_eq!(got[0].1, DepChangeKind::VersionsChanged); + } + + #[test] + fn a_moved_lockfile_is_a_move_rather_than_a_remove_and_an_add() { + let mut moved = pkg("serde", "1.0.203", true, false); + moved.lockfile = "crates/x/Cargo.lock".into(); + let renames: BTreeMap = [( + "Cargo.lock".to_string(), + "crates/x/Cargo.lock".to_string(), + )] + .into_iter() + .collect(); + + let b = sides(&[pkg("serde", "1.0.203", true, false)], &renames); + let h = sides(&[moved], &BTreeMap::new()); + let keys: BTreeSet = b.keys().chain(h.keys()).cloned().collect(); + assert_eq!(keys.len(), 1, "both sides key onto the head path"); + let key = keys.iter().next().unwrap(); + assert!(classify(b.get(key), h.get(key)).is_none()); + } + + #[test] + fn a_branch_that_touched_no_manifest_reads_no_blob() { + let touched = vec![ + ("modified".to_string(), "src/main.rs".to_string()), + ("added".to_string(), "README.md".to_string()), + ]; + let report = analyse( + Path::new("/nonexistent-root-for-the-short-circuit"), + &DepsOptions { + base_sha: "base", + head_sha: "head", + worktree: false, + touched: &touched, + offline: true, + }, + ); + assert!(!report.changed); + assert!(report.changes.is_empty() && report.drift.is_empty()); + // the root does not exist, so any git call or blob read would have left + // an error behind: a clean report is proof nothing was read + assert!(report.error.is_none()); + } + + #[test] + fn drift_names_a_manifest_whose_lockfile_did_not_move() { + let head = audit::AuditReport { + packages: Vec::new(), + findings: Vec::new(), + lockfiles: vec!["Cargo.lock".into()], + unresolved: vec![Unresolved { + manifest: "requirements.txt".into(), + reason: "1 requirement(s) are ranges rather than `==` pins".into(), + }], + assessed: true, + error: None, + }; + let inputs: BTreeSet = ["Cargo.toml", "Cargo.lock", "requirements.txt"] + .iter() + .map(|s| s.to_string()) + .collect(); + + let got = drift( + &["Cargo.toml", "requirements.txt"], + &head, + &inputs, + &BTreeSet::from(["Cargo.lock".to_string(), "web/yarn.lock".to_string()]), + ); + let by: BTreeMap<&str, &str> = got + .iter() + .map(|u| (u.manifest.as_str(), u.reason.as_str())) + .collect(); + assert!(by["Cargo.toml"].contains("Cargo.lock did not")); + // a lockfile the head no longer holds pins nothing + assert!(by["web/yarn.lock"].contains("was removed")); + // an unpinned requirements.txt is carried through unchanged + assert!(by["requirements.txt"].contains("`==` pins")); + // the lockfile that is still there is not drift + assert!(!by.contains_key("Cargo.lock")); + } + + // ------------------------------------------------------------------ + // end to end, over a real repository + // ------------------------------------------------------------------ + + const MANIFEST: &str = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1\"\n"; + + fn lock_with(version: &str) -> String { + format!( + "version = 4\n\n[[package]]\nname = \"serde\"\nversion = \"{version}\"\n\n\ + [[package]]\nname = \"serde_core\"\nversion = \"1.0.0\"\n" + ) + } + + fn git(dir: &Path, args: &[&str]) -> String { + let out = std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .unwrap_or_else(|e| panic!("git {args:?}: {e}")); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + + fn commit(dir: &Path, msg: &str) -> String { + git(dir, &["add", "-A"]); + git( + dir, + &[ + "-c", + "user.name=deps-test", + "-c", + "user.email=deps@test", + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "-m", + msg, + ], + ); + git(dir, &["rev-parse", "HEAD"]) + } + + fn write(dir: &Path, files: &[(&str, &str)]) { + for (rel, text) in files { + let to = dir.join(rel); + std::fs::create_dir_all(to.parent().unwrap()).unwrap(); + std::fs::write(to, text).unwrap(); + } + } + + fn repo(tag: &str, files: &[(&str, &str)]) -> (std::path::PathBuf, String) { + let dir = std::env::temp_dir().join(format!("ccc-deps-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + write(&dir, files); + git(&dir, &["init", "-q"]); + let sha = commit(&dir, "base"); + (dir, sha) + } + + // the rows `changes` hands over: only the paths are read, so a status + // label good enough to be distinguishable is good enough here + fn touched(dir: &Path, base: &str, worktree: bool) -> Vec<(String, String)> { + let mut args = vec!["diff", "--relative", "--name-status", "-z", "-M", base]; + if !worktree { + args.push("HEAD"); + } + let raw = audit::git_out(dir, &args).unwrap_or_default(); + let mut out = Vec::new(); + let mut it = raw.split('\0').filter(|s| !s.is_empty()); + while let Some(status) = it.next() { + match status.chars().next().unwrap_or('?') { + 'R' | 'C' => { + let (Some(old), Some(new)) = (it.next(), it.next()) else { + break; + }; + out.push(("renamed".to_string(), new.to_string())); + out.push(("deleted".to_string(), old.to_string())); + } + _ => { + let Some(path) = it.next() else { break }; + out.push(("modified".to_string(), path.to_string())); + } + } + } + if worktree { + let raw = + audit::git_out(dir, &["ls-files", "--others", "--exclude-standard", "-z"]) + .unwrap_or_default(); + out.extend( + raw.split('\0') + .filter(|s| !s.is_empty()) + .map(|p| ("added".to_string(), p.to_string())), + ); + } + out + } + + fn run(dir: &Path, base: &str, worktree: bool) -> DepsReport { + let head = git(dir, &["rev-parse", "HEAD"]); + analyse( + dir, + &DepsOptions { + base_sha: base, + head_sha: &head, + worktree, + touched: &touched(dir, base, worktree), + // the advisory database is a network round trip, and the + // change set is what this asserts + offline: true, + }, + ) + } + + #[test] + fn a_bump_on_a_branch_is_one_upgrade_and_nothing_else() { + let (dir, base) = repo( + "bump", + &[("Cargo.toml", MANIFEST), ("Cargo.lock", &lock_with("1.0.203"))], + ); + write(&dir, &[("Cargo.lock", &lock_with("1.0.210"))]); + commit(&dir, "bump serde"); + + let r = run(&dir, &base, false); + assert!(r.changed && !r.baseline); + assert_eq!(r.changes.len(), 1, "{:?}", r.changes); + let c = &r.changes[0]; + assert_eq!(c.kind, DepChangeKind::Upgraded); + assert_eq!(c.name, "serde"); + assert_eq!(c.from, vec!["1.0.203".to_string()]); + assert_eq!(c.to, vec!["1.0.210".to_string()]); + assert!(c.direct && !c.dev); + assert_eq!(c.lockfile, "Cargo.lock"); + // the line a person can actually edit + assert_eq!(c.locations.len(), 1); + assert_eq!(c.locations[0].manifest, "Cargo.toml"); + assert_eq!(c.locations[0].line, 6); + assert_eq!(r.counts.upgraded, 1); + assert_eq!(r.counts.added + r.counts.removed + r.counts.other, 0); + assert!(r.drift.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_same_bump_left_uncommitted_needs_worktree_to_be_seen() { + let (dir, base) = repo( + "worktree", + &[("Cargo.toml", MANIFEST), ("Cargo.lock", &lock_with("1.0.203"))], + ); + write(&dir, &[("Cargo.lock", &lock_with("1.0.210"))]); + + // the committed view is what CI wants, and nothing was committed + let committed = run(&dir, &base, false); + assert!(!committed.changed, "{:?}", committed.changes); + + let live = run(&dir, &base, true); + assert_eq!(live.changes.len(), 1, "{:?}", live.changes); + assert_eq!(live.changes[0].kind, DepChangeKind::Upgraded); + assert_eq!(live.changes[0].to, vec!["1.0.210".to_string()]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_first_lockfile_reads_as_a_baseline_rather_than_a_thousand_additions() { + let (dir, base) = repo("baseline", &[("Cargo.toml", MANIFEST)]); + write(&dir, &[("Cargo.lock", &lock_with("1.0.203"))]); + commit(&dir, "lock it"); + + let r = run(&dir, &base, false); + assert!(r.changed && r.baseline); + assert_eq!(r.counts.added, r.changes.len()); + assert!(r.changes.iter().all(|c| c.kind == DepChangeKind::Added)); + assert!(text(&r, "origin/main").contains("no lockfile at the base")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_manifest_edited_without_its_lockfile_is_drift_rather_than_a_change() { + let (dir, base) = repo( + "drift", + &[("Cargo.toml", MANIFEST), ("Cargo.lock", &lock_with("1.0.203"))], + ); + write(&dir, &[("Cargo.toml", &MANIFEST.replace("serde = \"1\"", "serde = \"2\""))]); + commit(&dir, "widen the range"); + + let r = run(&dir, &base, false); + assert!(r.changed); + // the declared range moved and the pinned version did not, which is + // exactly nothing to the resolved closure + assert!(r.changes.is_empty(), "{:?}", r.changes); + assert_eq!(r.drift.len(), 1); + assert_eq!(r.drift[0].manifest, "Cargo.toml"); + assert!(r.drift[0].reason.contains("Cargo.lock did not")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_lockfile_that_moved_is_followed_rather_than_emptied_and_refilled() { + let (dir, base) = repo( + "rename", + &[ + ("deps/Cargo.toml", MANIFEST), + ("deps/Cargo.lock", &lock_with("1.0.203")), + ], + ); + std::fs::create_dir_all(dir.join("crates/x")).unwrap(); + git(&dir, &["mv", "deps/Cargo.toml", "crates/x/Cargo.toml"]); + git(&dir, &["mv", "deps/Cargo.lock", "crates/x/Cargo.lock"]); + commit(&dir, "move the crate"); + + let r = run(&dir, &base, false); + assert!(r.changed, "the lockfile path is a touched path"); + // the packages did not move, only the file did + assert!(r.changes.is_empty(), "{:?}", r.changes); + assert!(r.drift.is_empty(), "{:?}", r.drift); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_deleted_lockfile_removes_its_packages_and_says_nothing_pins_them() { + let (dir, base) = repo( + "deleted", + &[("Cargo.toml", MANIFEST), ("Cargo.lock", &lock_with("1.0.203"))], + ); + std::fs::remove_file(dir.join("Cargo.lock")).unwrap(); + commit(&dir, "drop the lockfile"); + + let r = run(&dir, &base, false); + assert!(r.changes.iter().all(|c| c.kind == DepChangeKind::Removed)); + assert_eq!(r.counts.removed, r.changes.len()); + assert!(r.counts.removed >= 2); + let reasons: Vec<&str> = r.drift.iter().map(|d| d.reason.as_str()).collect(); + assert!( + reasons.iter().any(|m| m.contains("Cargo.toml now pins nothing")), + "{reasons:?}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_unchanged_branch_says_so_in_one_line() { + let r = empty( + &DepsOptions { + base_sha: "aaaaaaaaaaaa", + head_sha: "bbbbbbbbbbbb", + worktree: false, + touched: &[], + offline: false, + }, + None, + ); + assert_eq!( + text(&r, "origin/main").trim(), + "dependencies: unchanged against origin/main" + ); + // and it gates nothing: nothing needed checking + assert!(!r.gates()); + } + + #[test] + fn an_unreachable_database_is_reported_and_still_gates() { + let mut r = empty( + &DepsOptions { + base_sha: "a", + head_sha: "b", + worktree: false, + touched: &[], + offline: false, + }, + Some("the advisory database is unreachable".into()), + ); + r.changed = true; + r.changes.push(DepChange { + kind: DepChangeKind::Added, + ecosystem: Ecosystem::CratesIo, + name: "tokio".into(), + lockfile: "Cargo.lock".into(), + from: Vec::new(), + to: vec!["1.40.0".into()], + direct: true, + dev: false, + locations: Vec::new(), + }); + // the change set is complete even though the assessment is not + let out = text(&r, "origin/main"); + assert!(out.contains("not assessed - the advisory database is unreachable")); + assert!(out.contains("+ tokio 1.40.0")); + assert!(markdown(&r, "origin/main").contains("- added tokio 1.40.0")); + // "we could not check" is not "it is fine" + assert!(r.gates()); + } +} diff --git a/src/insights.rs b/src/insights.rs index 176ba69..56b8533 100644 --- a/src/insights.rs +++ b/src/insights.rs @@ -1378,6 +1378,7 @@ fn arity(params: usize) -> &'static str { // // The working-tree view: an engineer's uncommitted edit has to count, and CI // re-runs this against the committed tree anyway. +// ccc:skip fn change_set( g: &Graph, root: &Path, @@ -1390,6 +1391,8 @@ fn change_set( base: base.map(str::to_string), service_flags: Vec::new(), worktree: true, + prompts: true, + deps: false, }; changes::changes_with_caches(root, root_label, &opts, g.caches).map_err(|e| format!("{e:#}")) } @@ -1673,15 +1676,11 @@ fn test_triggers(report: &changes::ChangesReport, trig: &Triggered, targets: &Va "direct": run.iter().filter(|(_, d, _)| *d == 0).count(), }, "note": "Tests are matched to changes by name through the call graph, so this is the \ - set worth running - not proof that running it covers the change. A test that \ - exercises code without naming it, or through dynamic dispatch, cannot be seen. \ - `distance` is call hops from the changed function to what the test names. \ - Each `add` entry is a `target` id into `test_targets`, where the \ - recommendation itself lives.", + estimated set worth running, not proof that running it covers the change.", "changed_note": if *any_change { "changed functions are diffed against the merge-base, including uncommitted edits" } else { - "nothing changed against the base" + "no change(s) detected, create a branch from base first." }, }) } diff --git a/src/lib.rs b/src/lib.rs index 34aeb1d..3f653db 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,8 @@ -//! ContextCodeCache +//! CodeCache +pub mod audit; pub mod coverage; +pub mod deps; pub mod extract; pub mod externals; pub mod html; @@ -8,14 +10,18 @@ pub mod insights; pub mod languages; pub mod model; pub mod naming; +pub mod prompts; pub mod render; +pub mod sast; pub mod scan; pub mod serve; pub mod changes; +pub mod telemetry; pub mod tokenize; pub use scan::{check, scan, Change, ChangeKind, CheckReport, ScanReport}; pub use serve::{serve, ServeOptions}; pub use changes::{init_config, changes, ChangesOptions, ChangesReport}; pub use externals::{ExternalRepo, ExternalService, Surface}; +pub use prompts::{prompts, PromptRef, PromptsOptions, PromptsReport, Turn}; pub use tokenize::{tokenize, Encoding, TokenCache, TokenizeReport}; diff --git a/src/main.rs b/src/main.rs index f1c413f..0ed146c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,6 +91,20 @@ enum Command { // the committed view (the default); a local run usually wants this #[arg(long)] worktree: bool, + // name the request behind each change + #[arg(long)] + prompts: bool, + #[arg(long)] + deps: bool, + // narrow the output to what this branch did to the OpenTelemetry + #[arg(long)] + telemetry: bool, + // render one section as markdown for an agent + #[arg(long)] + markdown: bool, + // exit non-zero when the change introduces an advisory + #[arg(long)] + fail_introduced: bool, // also write a single-file HTML view of the report (Tailwind + HTMX // live-query panel against `ccc serve`), e.g. ccc-changes-rust.html #[arg(long, value_name = "FILE")] @@ -100,6 +114,42 @@ enum Command { #[arg(long, value_name = "REPORT.json", requires = "html")] from: Option, }, + // what this branch did to the dependency tree + Deps { + #[arg(default_value = ".")] + path: PathBuf, + // base ref to diff against + #[arg(long)] + base: Option, + // include uncommitted edits and untracked files in the diff + #[arg(long)] + worktree: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Json)] + format: OutputFormat, + // render the dependency delta as markdown for an agent + #[arg(long)] + markdown: bool, + // exit non-zero when the change introduces an advisory + #[arg(long)] + fail_introduced: bool, + }, + Prompts { + #[arg(default_value = ".")] + path: PathBuf, + // base ref to diff against + #[arg(long)] + base: Option, + #[arg(long, value_name = "NAME")] + agent: Option, + #[arg(long, value_name = "DAYS")] + since: Option, + #[arg(long)] + worktree: bool, + #[arg(long)] + record: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Json)] + format: OutputFormat, + }, // serve the code map over HTTP for AI agents: REST endpoints // (/find /references /dependencies ...) + an MCP endpoint at /mcp Serve { @@ -120,6 +170,8 @@ enum Command { // also serve the human-facing insights UI at /insights #[arg(long)] html: bool, + #[arg(long)] + deps: bool, }, // analyse the project and emit the insights payload Insights { @@ -131,6 +183,32 @@ enum Command { #[arg(long)] base: Option, }, + // scan this project's own source for security findings + Sast { + #[arg(default_value = ".")] + path: PathBuf, + // include test files, which normally carry fixtures rather than leaks + #[arg(long)] + include_tests: bool, + // only report findings at or above this severity + #[arg(long, default_value = "low")] + min_severity: String, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, + // resolve dependencies from lockfiles and check them against the OSV advisory database + Audit { + #[arg(default_value = ".")] + path: PathBuf, + // resolve only; never reach for the advisory database + #[arg(long)] + offline: bool, + // let a dev/build-only advisory fail the run too + #[arg(long)] + dev: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, // install this `ccc` binary onto your PATH (Linux; defaults to ~/.local/bin) Install { // directory to install into (default: ~/.local/bin) @@ -152,6 +230,7 @@ fn main() -> ExitCode { } } +// ccc:skip fn run() -> Result { let cli = Cli::parse(); match cli.command { @@ -249,9 +328,15 @@ fn run() -> Result { fail_untested, init, worktree, + prompts, + deps, + telemetry, + markdown, + fail_introduced, html, from, } => { + let _ = deps; let root = canonical(&path); if init { let cfg = codecache::init_config(&root)?; @@ -283,6 +368,8 @@ fn run() -> Result { worktree, base, service_flags, + prompts, + deps: true, }; let report = codecache::changes(&root, &path_str(&path), &opts)?; if let Some(html_path) = &html { @@ -291,9 +378,29 @@ fn run() -> Result { // stderr so stdout stays pure JSON for pipelines eprintln!("wrote {}", html_path.display()); } - match format { - OutputFormat::Json => println!("{}", serde_json::to_string(&report)?), - OutputFormat::Text => print_changes_text(&report), + + match (telemetry, markdown, format) { + (true, true, _) => { + print!( + "{}", + codecache::telemetry::markdown(&report.telemetry, &report.base) + ) + } + (true, false, OutputFormat::Json) => { + println!("{}", serde_json::to_string(&report.telemetry)?) + } + (true, false, OutputFormat::Text) => print!( + "{}", + codecache::telemetry::text(&report.telemetry, &report.base) + ), + (false, true, _) => { + let d = report.deps.as_ref().expect("`changes` always computes the delta"); + print!("{}", codecache::deps::markdown(d, &report.base)); + } + (false, false, OutputFormat::Json) => { + println!("{}", serde_json::to_string(&report)?) + } + (false, false, OutputFormat::Text) => print_changes_text(&report), } if fail_untested && !report.untested.is_empty() { eprintln!( @@ -302,6 +409,64 @@ fn run() -> Result { ); return Ok(ExitCode::FAILURE); } + if fail_introduced { + let d = report.deps.as_ref().expect("`changes` always computes the delta"); + if gate_introduced("changes", d) { + return Ok(ExitCode::FAILURE); + } + } + Ok(ExitCode::SUCCESS) + } + Command::Deps { + path, + base, + worktree, + format, + markdown, + fail_introduced, + } => { + let root = canonical(&path); + let (base_label, report) = + codecache::changes::deps_report(&root, base.as_deref(), worktree)?; + match (markdown, format) { + (true, _) => print!("{}", codecache::deps::markdown(&report, &base_label)), + (false, OutputFormat::Json) => println!("{}", serde_json::to_string(&report)?), + (false, OutputFormat::Text) => { + print!("{}", codecache::deps::text(&report, &base_label)) + } + } + if fail_introduced && gate_introduced("deps", &report) { + return Ok(ExitCode::FAILURE); + } + Ok(ExitCode::SUCCESS) + } + Command::Prompts { + path, + base, + agent, + since, + worktree, + record, + format, + } => { + if let Some(a) = agent.as_deref() { + if !matches!(a, "claude" | "copilot") { + return Err(anyhow!("--agent wants `claude` or `copilot`, got '{a}'")); + } + } + let root = canonical(&path); + let opts = codecache::PromptsOptions { + base, + worktree, + agent, + since_days: since, + record, + }; + let report = codecache::prompts(&root, &path_str(&path), &opts)?; + match format { + OutputFormat::Json => println!("{}", serde_json::to_string(&report)?), + OutputFormat::Text => print_prompts_text(&report), + } Ok(ExitCode::SUCCESS) } Command::Serve { @@ -311,12 +476,14 @@ fn run() -> Result { watch_interval, no_watch, html, + deps } => { let watch = if no_watch || watch_interval == 0 { None } else { Some(std::time::Duration::from_secs(watch_interval)) }; + let _ = deps; // discard let opts = codecache::ServeOptions { addr, port, watch, html }; codecache::serve(&canonical(&path), &opts)?; Ok(ExitCode::SUCCESS) @@ -338,10 +505,212 @@ fn run() -> Result { } Ok(ExitCode::SUCCESS) } + Command::Sast { path, include_tests, min_severity, format } => { + let root = canonical(&path); + let report = codecache::sast::analyse(&root, include_tests); + let floor = match min_severity.to_ascii_lowercase().as_str() { + "high" => codecache::sast::Severity::High, + "medium" | "moderate" => codecache::sast::Severity::Medium, + _ => codecache::sast::Severity::Low, + }; + let shown: Vec<&codecache::sast::Finding> = + report.findings.iter().filter(|f| f.severity <= floor).collect(); + match format { + OutputFormat::Text => print_sast_text(&report, &shown), + OutputFormat::Json => println!("{}", serde_json::to_string(&report)?), + } + // only a high finding fails the run, so CI does not break on a checksum + let high = shown + .iter() + .filter(|f| f.severity == codecache::sast::Severity::High) + .count(); + Ok(if high > 0 { ExitCode::FAILURE } else { ExitCode::SUCCESS }) + } + Command::Audit { path, offline, dev, format } => { + let root = canonical(&path); + let mut report = codecache::audit::resolve(&root); + if !offline { + codecache::audit::assess(&mut report); + } + codecache::audit::locate(&root, &mut report); + match format { + OutputFormat::Text => print_audit_text(&report, offline), + OutputFormat::Json => println!("{}", serde_json::to_string(&report)?), + } + // a runtime advisory fails the run so CI can gate on it; dev ones only with --dev + let gating = if dev { + report.findings.len() + } else { + report.runtime_findings().len() + }; + Ok(if gating > 0 { ExitCode::FAILURE } else { ExitCode::SUCCESS }) + } Command::Install { dir, force } => run_install(dir, force), } } +// `--fail-introduced` shared by `changes` and `deps` when the +// run should fail, with the reason already on stderr +fn gate_introduced(cmd: &str, d: &codecache::deps::DepsReport) -> bool { + if !d.gates() { + return false; + } + match d.error.as_deref() { + // "we could not check" is not "it is fine" + Some(err) => eprintln!("{cmd}: the dependency delta was not assessed - {err}"), + None => { + eprintln!("{cmd}: this branch introduces advisories the base did not carry:"); + for f in &d.introduced { + eprintln!( + " {} {} - {} {}", + f.advisory.id, f.advisory.severity, f.package.name, f.package.version + ); + } + } + } + true +} + +fn print_sast_text(r: &codecache::sast::SastReport, shown: &[&codecache::sast::Finding]) { + use codecache::sast::Severity; + println!( + "security: {} finding(s) across {} file(s) - {} rule(s) applied", + shown.len(), + r.files_scanned, + r.rules.len() + ); + println!( + " high {}, medium {}, low {}", + r.by_severity(Severity::High), + r.by_severity(Severity::Medium), + r.by_severity(Severity::Low) + ); + if shown.is_empty() { + println!("\nnothing matched - these are syntax-level rules, not a proof of safety"); + return; + } + for f in shown { + println!( + "\n [{}] {} {}:{} in {}", + f.severity.as_str(), + f.rule, + f.file, + f.line, + f.function + ); + println!(" {}", f.message); + println!(" evidence: {}", f.evidence); + println!(" {} - {}", f.cwe, f.hint); + } + println!("\nevery finding is a syntax match with no data flow behind it - confirm in the source"); +} + +fn print_audit_text(r: &codecache::audit::AuditReport, offline: bool) { + println!( + "dependencies: {} resolved from {} lockfile(s), {} direct", + r.packages.len(), + r.lockfiles.len(), + r.direct_count() + ); + for lock in &r.lockfiles { + let n = r.packages.iter().filter(|p| &p.lockfile == lock).count(); + println!(" {lock} - {n} package(s)"); + } + if !r.unresolved.is_empty() { + // a coverage gap is not a clean result, so it is never left implicit + println!("\nnot resolved ({}):", r.unresolved.len()); + for u in &r.unresolved { + println!(" {} - {}", u.manifest, u.reason); + } + } + if r.packages.is_empty() { + println!("\nno lockfile found - run your package manager so versions can be resolved"); + return; + } + if offline { + println!("\nadvisory database not consulted (--offline)"); + return; + } + if let Some(err) = &r.error { + // the resolution above still stands; only the assessment is missing + println!("\nvulnerabilities: not assessed - {err}"); + return; + } + let runtime = r.runtime_findings().len(); + let dev = r.findings.len() - runtime; + if r.findings.is_empty() { + println!("\nvulnerabilities: none known against {} package(s)", r.packages.len()); + return; + } + println!("\nvulnerabilities: {} ({runtime} runtime, {dev} dev-only)", r.findings.len()); + for f in &r.findings { + let p = &f.package; + let scope = if p.dev { ", dev" } else { "" }; + let reach = if p.direct { "direct" } else { "transitive" }; + println!( + "\n [{}] {} {} ({}{scope}, {reach})", + f.advisory.severity.to_uppercase(), + p.name, + p.version, + p.ecosystem.label() + ); + println!(" {}", f.advisory.summary); + match &f.advisory.fixed { + Some(v) => println!(" fixed in {v}"), + None => println!(" no fixed version published"), + } + println!(" {} {}", f.advisory.id, f.advisory.url); + } +} + +fn print_prompts_text(r: &codecache::PromptsReport) { + let c = &r.counts; + println!( + "prompts: {} request(s) against {} (claude {}, copilot {}), base {}", + c.turns, r.root, c.claude_turns, c.copilot_turns, r.base + ); + for s in &r.sources { + println!("source: {} - {} session(s) at {}", s.agent, s.sessions, s.location); + } + println!( + "attributed: {} changed file(s), {} unexplained", + c.attributed_files, c.unattributed_files + ); + // the requests once, numbered + let slot: std::collections::BTreeMap<&str, usize> = r + .turns + .iter() + .enumerate() + .map(|(i, t)| (t.id.as_str(), i + 1)) + .collect(); + for (i, t) in r.turns.iter().enumerate() { + let edits = match t.edits.len() { + 0 => " (changed nothing)".to_string(), + n => format!(" ({n} edit(s))"), + }; + println!("#{} {} {}{edits}: {}", i + 1, t.agent, t.ts, t.prompt); + } + for (path, refs) in &r.attributed { + // the evidence is part of the answer, not a footnote: a temporal match + // is a guess and should read as one + let cited: Vec = refs + .iter() + .map(|p| { + let span = match p.lines { + Some([s, e]) => format!("L{s}-{e} "), + None => String::new(), + }; + let n = slot.get(p.turn.as_str()).copied().unwrap_or(0); + format!("{span}[{}] #{n}", p.evidence) + }) + .collect(); + println!("{path}: {}", cited.join(", ")); + } + for path in &r.unattributed { + println!("unexplained: {path}"); + } +} + fn print_changes_text(r: &ChangesReport) { println!( "changes: {} service(s), base {} ({}..{})", @@ -418,6 +787,12 @@ fn print_changes_text(r: &ChangesReport) { if !r.unassigned_files.is_empty() { println!("unassigned: {}", r.unassigned_files.join(", ")); } + if let Some(d) = &r.deps { + print!("{}", codecache::deps::text(d, &r.base)); + } + if r.telemetry.instrumented || r.telemetry.error.is_some() { + print!("{}", codecache::telemetry::text(&r.telemetry, &r.base)); + } } fn print_check_text(report: &CheckReport) { diff --git a/src/prompts.rs b/src/prompts.rs new file mode 100644 index 0000000..91000a7 --- /dev/null +++ b/src/prompts.rs @@ -0,0 +1,1233 @@ +//! `ccc prompts` - pair the requests sent to claude/copilot with the code they +//! changed. +//! +//! Both agents already keep a local record of every exchange. Claude Code writes +//! one JSONL transcript per session under `~/.claude/projects//`, carrying +//! the prompt, the `parentUuid` chain that links a tool call back to it, and the +//! `Edit`/`Write` call itself with the exact text it wrote. Copilot writes a +//! patch log per workspace under VS Code's `workspaceStorage`, carrying the +//! prompt text and its timestamp but no edit payload. +//! +//! Nothing here is asserted with more confidence than its source supports: every +//! attribution carries the evidence that produced it, weakest last, and a +//! changed file that no request claims is reported as unattributed rather than +//! attached to whichever prompt happened to be nearest. + +use anyhow::{Context, Result}; +use serde::Serialize; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub const SCHEMA: &str = "ccc-prompts/1"; + +// the ledger `--record` appends to, under `.ccc/` +pub const LEDGER_NAME: &str = "prompts.jsonl"; + +// tool calls that write source. anything else an agent does to a file - a +// `sed -i`, a heredoc - arrives through Bash, where the command is not +// statically parseable, and is covered by the temporal rung instead +const EDIT_TOOLS: &[&str] = &["Edit", "Write", "MultiEdit", "NotebookEdit"]; + +// prompt text kept per turn. long enough to recognise the request, short +// enough that a report full of them still fits an agent's context +const PROMPT_CAP: usize = 500; + +// how far after a request a file write may still be credited to it. beyond +// this the request is simply not the explanation, and saying so is the point +const MAX_TEMPORAL_GAP_SECS: i64 = 4 * 3600; + +// guards the `parentUuid` walk against a malformed or cyclic transcript +const MAX_PARENT_HOPS: usize = 1000; + +#[derive(Debug, Default, Clone)] +pub struct PromptsOptions { + // base ref to diff against; None resolves the same way `changes` does + pub base: Option, + // include uncommitted edits and untracked files + pub worktree: bool, + // restrict to one agent: `claude` or `copilot`; None reads both + pub agent: Option, + // only turns from the last N days + pub since_days: Option, + // append the collected turns to `.ccc/prompts.jsonl` + pub record: bool, +} + +// one file an agent wrote in service of a request +#[derive(Debug, Clone, Serialize)] +pub struct TurnEdit { + // repo-relative where the write landed inside the project, absolute + // otherwise; a turn may well touch files outside this checkout + pub path: String, + // Edit | Write | MultiEdit | NotebookEdit + pub tool: String, + // a distinctive line of what was written, used to find the edit again in + // the file as it stands today + #[serde(skip_serializing_if = "Option::is_none")] + pub anchor: Option, +} + +// one request sent to a model, and the edits it produced +#[derive(Debug, Clone, Serialize)] +pub struct Turn { + // `::`, stable across runs so a report can be diffed + pub id: String, + // claude | copilot + pub agent: String, + pub session: String, + // RFC3339, when the request was sent + pub ts: String, + // git branch recorded at the time, where the source knows it + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + // the request itself + pub prompt: String, + // the files it wrote; empty is meaningful - a question that changed + // nothing is still an answer + pub edits: Vec, + // epoch seconds, not serialised: ordering and windowing only + #[serde(skip)] + pub epoch: i64, +} + +// a change, and the request that produced it +#[derive(Debug, Clone, Serialize)] +pub struct PromptRef { + // Turn.id + pub turn: String, + pub agent: String, + pub ts: String, + pub prompt: String, + // content-match | tool-edit | temporal, weakest last + pub evidence: String, + // the changed range this reference was pinned to. Absent means the + // reference covers the whole file, which is all `tool-edit` and `temporal` + // can honestly claim + #[serde(skip_serializing_if = "Option::is_none")] + pub lines: Option<[usize; 2]>, +} + +impl PromptRef { + // does this reference cover a span, given that a file-wide one covers all + // ccc:skip + pub fn covers(&self, start: usize, end: usize) -> bool { + match self.lines { + None => true, + Some([s, e]) => s <= end && start <= e, + } + } + + // rank of the evidence that produced it, strongest first + // ccc:skip + fn rank(&self) -> u8 { + match self.evidence.as_str() { + "content-match" => 0, + "tool-edit" => 1, + _ => 2, + } + } +} + +// what the attribution pass concluded +#[derive(Debug, Default, Clone)] +pub struct Attribution { + // repo-relative path -> the requests that produced its changes + pub by_file: BTreeMap>, + // changed files no request claims: written by hand, or by an agent whose + // record ccc cannot read + pub unattributed: Vec, +} + +#[derive(Debug, Serialize, Clone, Copy)] +pub struct PromptsCounts { + pub turns: usize, + pub edits: usize, + pub attributed_files: usize, + pub unattributed_files: usize, + pub claude_turns: usize, + pub copilot_turns: usize, +} + +#[derive(Debug, Serialize)] +pub struct PromptsReport { + pub schema: &'static str, + pub root: String, + pub base: String, + pub turns: Vec, + // repo-relative path -> the requests behind its changes + pub attributed: BTreeMap>, + pub unattributed: Vec, + // agent records ccc looked for and could not read, so an empty report is + // distinguishable from a missing one + pub sources: Vec, + pub counts: PromptsCounts, +} + +#[derive(Debug, Serialize, Clone)] +pub struct SourceStatus { + pub agent: String, + pub location: String, + pub sessions: usize, +} + +// source discovery +// ccc:skip +fn home() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +// the directory claude code keeps its transcripts in +// ccc:skip +fn claude_root() -> Option { + if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") { + return Some(PathBuf::from(dir).join("projects")); + } + home().map(|h| h.join(".claude").join("projects")) +} + +// claude names a project directory after its path with the separators flattened +pub(crate) fn claude_slug(root: &Path) -> String { + root.to_string_lossy() + .chars() + .map(|c| if c == '/' || c == '\\' || c == '.' { '-' } else { c }) + .collect() +} + +// the first record of a transcript names the directory the session ran in. +// The slug is only a hint - this is the fact +// ccc:skip +fn transcript_cwd(path: &Path) -> Option { + let raw = fs::read_to_string(path).ok()?; + for line in raw.lines().take(50) { + let v: Value = serde_json::from_str(line).ok()?; + if let Some(cwd) = v.get("cwd").and_then(|c| c.as_str()) { + return Some(cwd.to_string()); + } + } + None +} + +// ccc:skip +fn jsonl_files(dir: &Path) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + let mut out: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x == "jsonl")) + .collect(); + out.sort(); + out +} + +// transcripts for this project: the slug first, then any other project +// directory whose sessions actually ran here +// ccc:skip +fn claude_transcripts(root: &Path) -> (Vec, String) { + let Some(base) = claude_root() else { + return (Vec::new(), "".into()); + }; + let location = base.display().to_string(); + let want = root.to_string_lossy().to_string(); + let slug_dir = base.join(claude_slug(root)); + let mut found = jsonl_files(&slug_dir); + if !found.is_empty() { + return (found, location); + } + // the slug rule flattens `.` and `/` alike, so it can miss; fall back to + // asking each transcript where it ran + let Ok(entries) = fs::read_dir(&base) else { + return (found, location); + }; + let mut dirs: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .collect(); + dirs.sort(); + for dir in dirs { + for f in jsonl_files(&dir) { + if transcript_cwd(&f).is_some_and(|c| c == want || c.starts_with(&format!("{want}/"))) { + found.push(f); + } + } + } + (found, location) +} + +// every VS Code flavour that keeps a `workspaceStorage` +// ccc:skip +fn vscode_user_dirs() -> Vec { + let Some(h) = home() else { + return Vec::new(); + }; + let flavours = ["Code", "Code - Insiders", "VSCodium", "Cursor"]; + let mut roots = vec![ + h.join(".config"), + h.join("Library").join("Application Support"), + ]; + if let Some(appdata) = std::env::var_os("APPDATA") { + roots.push(PathBuf::from(appdata)); + } + let mut out = Vec::new(); + for r in roots { + for f in flavours { + let p = r.join(f).join("User"); + if p.is_dir() { + out.push(p); + } + } + } + out +} + +// copilot chat sessions for the workspace rooted here +// ccc:skip +fn copilot_sessions(root: &Path) -> (Vec, String) { + let dirs = vscode_user_dirs(); + let location = dirs + .first() + .map(|d| d.join("workspaceStorage").display().to_string()) + .unwrap_or_else(|| "".into()); + let want = format!("file://{}", root.to_string_lossy()); + let mut out = Vec::new(); + for user in dirs { + let storage = user.join("workspaceStorage"); + let Ok(entries) = fs::read_dir(&storage) else { + continue; + }; + let mut hashes: Vec = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect(); + hashes.sort(); + for hash in hashes { + let meta = hash.join("workspace.json"); + let Ok(raw) = fs::read_to_string(&meta) else { + continue; + }; + let Ok(v) = serde_json::from_str::(&raw) else { + continue; + }; + let folder = v.get("folder").and_then(|f| f.as_str()).unwrap_or_default(); + if folder.trim_end_matches('/') != want.trim_end_matches('/') { + continue; + } + out.extend(jsonl_files(&hash.join("chatSessions"))); + } + } + (out, location) +} + +// transcripts +// blocks the harness injects around the user's own words +// ccc:skip +fn strip_injected(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + let tags = [ + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ]; + 'outer: loop { + let next = tags + .iter() + .filter_map(|(open, close)| rest.find(open).map(|i| (i, *open, *close))) + .min_by_key(|(i, _, _)| *i); + match next { + Some((i, open, close)) => { + out.push_str(&rest[..i]); + let after = &rest[i + open.len()..]; + match after.find(close) { + Some(j) => rest = &after[j + close.len()..], + // unterminated: drop the remainder rather than echo it + None => break 'outer, + } + } + None => { + out.push_str(rest); + break 'outer; + } + } + } + out +} + +// condense a request to something a report can carry +fn condense(text: &str) -> String { + let cleaned = strip_injected(text); + let mut flat = String::new(); + for line in cleaned.lines() { + let t = line.trim(); + if t.is_empty() { + continue; + } + if !flat.is_empty() { + flat.push(' '); + } + flat.push_str(t); + if flat.chars().count() > PROMPT_CAP { + break; + } + } + truncate(&flat, PROMPT_CAP) +} + +// ccc:skip +fn truncate(s: &str, cap: usize) -> String { + if s.chars().count() <= cap { + return s.to_string(); + } + let head: String = s.chars().take(cap).collect(); + format!("{}โ€ฆ", head.trim_end()) +} + +// the text of a record, when it is one the user actually typed. Tool results, +// harness metadata and slash-command expansions are not requests +// ccc:skip +fn user_prompt_text(rec: &Value) -> Option { + if rec.get("type").and_then(|t| t.as_str()) != Some("user") { + return None; + } + if rec.get("isMeta").and_then(|m| m.as_bool()).unwrap_or(false) { + return None; + } + let content = rec.get("message")?.get("content")?; + let raw = match content { + Value::String(s) => s.clone(), + Value::Array(blocks) => { + // a tool result is the harness answering the model, not a request + if blocks + .iter() + .any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_result")) + { + return None; + } + blocks + .iter() + .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n") + } + _ => return None, + }; + if raw.trim_start().starts_with("") + || raw.trim_start().starts_with(" Option { + text.lines() + .map(str::trim) + .filter(|l| l.chars().count() >= 12) + .max_by_key(|l| l.chars().count()) + .map(|l| truncate(l, 160).trim_end_matches('โ€ฆ').to_string()) + .filter(|l| l.chars().count() >= 12) +} + +// the files one `tool_use` block wrote, with an anchor for each +// ccc:skip +fn edits_of(block: &Value, root: &Path) -> Vec { + let name = block.get("name").and_then(|n| n.as_str()).unwrap_or_default(); + if !EDIT_TOOLS.contains(&name) { + return Vec::new(); + } + let Some(input) = block.get("input") else { + return Vec::new(); + }; + let raw_path = input + .get("file_path") + .or_else(|| input.get("notebook_path")) + .and_then(|p| p.as_str()); + let Some(raw_path) = raw_path else { + return Vec::new(); + }; + let path = relativise(raw_path, root); + let anchor = match name { + "Write" => input.get("content").and_then(|c| c.as_str()).and_then(anchor_of), + "NotebookEdit" => input + .get("new_source") + .and_then(|c| c.as_str()) + .and_then(anchor_of), + "MultiEdit" => input + .get("edits") + .and_then(|e| e.as_array()) + .and_then(|edits| { + edits + .iter() + .filter_map(|e| e.get("new_string").and_then(|s| s.as_str())) + .filter_map(anchor_of) + .max_by_key(|a| a.chars().count()) + }), + _ => input + .get("new_string") + .and_then(|c| c.as_str()) + .and_then(anchor_of), + }; + vec![TurnEdit { + path, + tool: name.to_string(), + anchor, + }] +} + +// paths inside the project are reported the way every other ccc report reports +// them; paths outside it stay absolute, because that is what they are +// ccc:skip +fn relativise(raw: &str, root: &Path) -> String { + let p = Path::new(raw); + match p.strip_prefix(root) { + Ok(rel) => crate::changes::path_str(rel), + Err(_) => raw.to_string(), + } +} + +// ccc:skip +fn epoch_of(ts: &str) -> i64 { + chrono::DateTime::parse_from_rfc3339(ts) + .map(|d| d.timestamp()) + .unwrap_or(0) +} + +// one claude transcript -> the requests it contains, each with its edits +fn parse_claude(path: &Path, root: &Path) -> Vec { + let Ok(raw) = fs::read_to_string(path) else { + return Vec::new(); + }; + let records: Vec = raw + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|l| serde_json::from_str(l).ok()) + .collect(); + + let mut by_uuid: BTreeMap<&str, usize> = BTreeMap::new(); + for (i, r) in records.iter().enumerate() { + if let Some(u) = r.get("uuid").and_then(|u| u.as_str()) { + by_uuid.insert(u, i); + } + } + + let session = path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + + // every request, in the order it was made, whether or not it produced an + // edit. `at` maps the record it came from to its slot, so the edit pass + // below can find it again by walking the parent chain + let mut out: Vec = Vec::new(); + let mut at: BTreeMap = BTreeMap::new(); + for (i, rec) in records.iter().enumerate() { + let Some(prompt) = user_prompt_text(rec) else { + continue; + }; + let Some(uuid) = rec.get("uuid").and_then(|u| u.as_str()) else { + continue; + }; + let ts = rec + .get("timestamp") + .and_then(|t| t.as_str()) + .unwrap_or_default() + .to_string(); + at.insert(i, out.len()); + out.push(Turn { + id: format!("claude:{session}:{uuid}"), + agent: "claude".into(), + session: session.clone(), + epoch: epoch_of(&ts), + ts, + branch: rec + .get("gitBranch") + .and_then(|b| b.as_str()) + .filter(|b| !b.is_empty()) + .map(str::to_string), + prompt, + edits: Vec::new(), + }); + } + + // then credit each edit to the request that led to it + for rec in &records { + if rec.get("type").and_then(|t| t.as_str()) != Some("assistant") { + continue; + } + let Some(blocks) = rec + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + else { + continue; + }; + let edits: Vec = blocks + .iter() + .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use")) + .flat_map(|b| edits_of(b, root)) + .collect(); + if edits.is_empty() { + continue; + } + let Some(idx) = walk_to_prompt(rec, &records, &by_uuid) else { + continue; + }; + if let Some(turn) = at.get(&idx).and_then(|&slot| out.get_mut(slot)) { + turn.edits.extend(edits); + } + } + + for t in &mut out { + t.edits.sort_by(|a, b| (&a.path, &a.tool).cmp(&(&b.path, &b.tool))); + t.edits.dedup_by(|a, b| a.path == b.path && a.anchor == b.anchor); + } + out +} + +// climb `parentUuid` from an assistant record to the request that led to it +// ccc:skip +fn walk_to_prompt( + from: &Value, + records: &[Value], + by_uuid: &BTreeMap<&str, usize>, +) -> Option { + let mut cursor = from.get("parentUuid").and_then(|p| p.as_str()); + let mut seen = BTreeSet::new(); + for _ in 0..MAX_PARENT_HOPS { + let uuid = cursor?; + if !seen.insert(uuid) { + return None; + } + let idx = *by_uuid.get(uuid)?; + if user_prompt_text(&records[idx]).is_some() { + return Some(idx); + } + cursor = records[idx].get("parentUuid").and_then(|p| p.as_str()); + } + None +} + +// --------------------------------------------------------------------------- +// copilot sessions +// --------------------------------------------------------------------------- + +// VS Code stores a chat session as an initial snapshot plus a patch log: +// `kind:0` seeds the object, `kind:1` sets the value at a path, `kind:2` +// appends to the array at a path. Only `requests` is of interest here +fn parse_copilot(path: &Path) -> Vec { + let Ok(raw) = fs::read_to_string(path) else { + return Vec::new(); + }; + let session = path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + + let mut requests: Vec = Vec::new(); + for line in raw.lines().filter(|l| !l.trim().is_empty()) { + let Ok(v) = serde_json::from_str::(line) else { + continue; + }; + let kind = v.get("kind").and_then(|k| k.as_u64()).unwrap_or(u64::MAX); + let key: Vec<&str> = v + .get("k") + .and_then(|k| k.as_array()) + .map(|a| a.iter().filter_map(|s| s.as_str()).collect()) + .unwrap_or_default(); + match kind { + 0 => { + if let Some(rs) = v.get("v").and_then(|o| o.get("requests")).and_then(|r| r.as_array()) { + requests = rs.clone(); + } + } + 1 if key == ["requests"] => { + if let Some(rs) = v.get("v").and_then(|r| r.as_array()) { + requests = rs.clone(); + } + } + 2 if key == ["requests"] => { + if let Some(rs) = v.get("v").and_then(|r| r.as_array()) { + requests.extend(rs.iter().cloned()); + } + } + _ => {} + } + } + + requests + .iter() + .filter_map(|r| { + let text = r.get("message")?.get("text")?.as_str()?; + let prompt = condense(text); + if prompt.is_empty() { + return None; + } + let ms = r.get("timestamp").and_then(|t| t.as_i64()).unwrap_or(0); + let ts = chrono::DateTime::from_timestamp_millis(ms) + .map(|d| d.to_rfc3339()) + .unwrap_or_default(); + let id = r + .get("requestId") + .and_then(|i| i.as_str()) + .unwrap_or_default(); + Some(Turn { + id: format!("copilot:{session}:{id}"), + agent: "copilot".into(), + session: session.clone(), + ts, + epoch: ms / 1000, + branch: None, + prompt, + // copilot's log records the request and the rendered reply, not + // the edit payload, so its changes can only be placed in time + edits: Vec::new(), + }) + }) + .collect() +} + +// collection +// every request made against this project, oldest first +// ccc:skip +pub fn collect(root: &Path, opts: &PromptsOptions) -> (Vec, Vec) { + let want = |a: &str| opts.agent.as_deref().is_none_or(|w| w == a); + let mut turns = Vec::new(); + let mut sources = Vec::new(); + + if want("claude") { + let (files, location) = claude_transcripts(root); + sources.push(SourceStatus { + agent: "claude".into(), + location, + sessions: files.len(), + }); + for f in files { + turns.extend(parse_claude(&f, root)); + } + } + if want("copilot") { + let (files, location) = copilot_sessions(root); + sources.push(SourceStatus { + agent: "copilot".into(), + location, + sessions: files.len(), + }); + for f in files { + turns.extend(parse_copilot(&f)); + } + } + + if let Some(days) = opts.since_days { + let cutoff = chrono::Utc::now().timestamp() - (days as i64) * 86_400; + turns.retain(|t| t.epoch >= cutoff); + } + turns.sort_by(|a, b| (a.epoch, &a.id).cmp(&(b.epoch, &b.id))); + turns.dedup_by(|a, b| a.id == b.id); + (turns, sources) +} + +// attribution +// ccc:skip +fn as_ref_of(turn: &Turn, evidence: &str, lines: Option<[usize; 2]>) -> PromptRef { + PromptRef { + turn: turn.id.clone(), + agent: turn.agent.clone(), + ts: turn.ts.clone(), + prompt: turn.prompt.clone(), + evidence: evidence.to_string(), + lines, + } +} + +// the 1-based line an anchor sits on in `text` +// ccc:skip +fn line_of(text: &str, anchor: &str) -> Option { + text.lines() + .position(|l| l.trim() == anchor || l.contains(anchor)) + .map(|i| i + 1) +} + +// Tie each changed file to the requests that produced it. +// +// `hunks` is the changed line ranges per repo-relative path, as `changes` +// already computes them. `times` is when each changed file was last written, +// in epoch seconds - a commit time where the change is committed, an mtime +// where it is not. +pub fn attribute( + root: &Path, + turns: &[Turn], + hunks: &BTreeMap>, + times: &BTreeMap, +) -> Attribution { + let mut by_file: BTreeMap> = BTreeMap::new(); + + // the strong rungs: the request said which file it wrote + for turn in turns { + for edit in &turn.edits { + let Some(ranges) = hunks.get(&edit.path) else { + continue; + }; + // is what it wrote still there, inside something that changed? + let pinned = edit.anchor.as_ref().and_then(|a| { + let text = fs::read_to_string(root.join(&edit.path)).ok()?; + let line = line_of(&text, a)?; + ranges + .iter() + .find(|&&(s, e)| s <= line && line <= e) + // a brand new file is one open-ended range: pinning inside + // it would claim a precision the range does not have + .filter(|&&(_, e)| e != usize::MAX) + .map(|&(s, e)| [s, e]) + }); + let r = match pinned { + Some(span) => as_ref_of(turn, "content-match", Some(span)), + None => as_ref_of(turn, "tool-edit", None), + }; + by_file.entry(edit.path.clone()).or_default().push(r); + } + } + + // the weak rung: a file changed inside a request's window, and no request + // named it. Bash edits and copilot edits both land here + for (path, _) in hunks.iter() { + if by_file.contains_key(path) { + continue; + } + let Some(&written) = times.get(path) else { + continue; + }; + if let Some(turn) = window_owner(turns, written) { + by_file + .entry(path.clone()) + .or_default() + .push(as_ref_of(turn, "temporal", None)); + } + } + + for refs in by_file.values_mut() { + refs.sort_by(|a, b| (a.rank(), &a.ts, &a.turn).cmp(&(b.rank(), &b.ts, &b.turn))); + refs.dedup_by(|a, b| a.turn == b.turn && a.lines == b.lines); + // a request that pinned to a span has already proved it wrote the + // file; repeating the claim at a weaker rung says nothing new + let pinned: BTreeSet = refs + .iter() + .filter(|r| r.lines.is_some()) + .map(|r| r.turn.clone()) + .collect(); + refs.retain(|r| r.lines.is_some() || !pinned.contains(&r.turn)); + } + + let unattributed: Vec = hunks + .keys() + .filter(|p| !by_file.contains_key(*p)) + .cloned() + .collect(); + + Attribution { + by_file, + unattributed, + } +} + +// the request that was in flight when `written` happened: the last one sent +// before it, provided it was recent enough to be the explanation +// ccc:skip +fn window_owner(turns: &[Turn], written: i64) -> Option<&Turn> { + let mut best: Option<&Turn> = None; + for t in turns { + if t.epoch > written { + break; + } + best = Some(t); + } + let t = best?; + (written - t.epoch <= MAX_TEMPORAL_GAP_SECS).then_some(t) +} + +// report +// Build the standalone report: collect the requests, diff the branch, and +// attribute. `changes` performs the same join at function granularity. +// ccc:skip +pub fn prompts(root: &Path, root_label: &str, opts: &PromptsOptions) -> Result { + let (turns, sources) = collect(root, opts); + let (base_label, hunks, times) = crate::changes::changed_line_times(root, opts.base.as_deref(), opts.worktree)?; + let attribution = attribute(root, &turns, &hunks, ×); + + if opts.record { + record(root, &turns).context("recording the prompt ledger")?; + } + + let counts = PromptsCounts { + turns: turns.len(), + edits: turns.iter().map(|t| t.edits.len()).sum(), + attributed_files: attribution.by_file.len(), + unattributed_files: attribution.unattributed.len(), + claude_turns: turns.iter().filter(|t| t.agent == "claude").count(), + copilot_turns: turns.iter().filter(|t| t.agent == "copilot").count(), + }; + Ok(PromptsReport { + schema: SCHEMA, + root: root_label.to_string(), + base: base_label, + turns, + attributed: attribution.by_file, + unattributed: attribution.unattributed, + sources, + counts, + }) +} + +// Append the collected requests to `.ccc/prompts.jsonl`, and make sure git is +// not about to commit them: prompt text is the user's own words, and `.ccc/` +// is a tracked directory in most projects +// ccc:skip +fn record(root: &Path, turns: &[Turn]) -> Result<()> { + use std::io::Write; + + let dir = root.join(".ccc"); + fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; + let ledger = dir.join(LEDGER_NAME); + + // only append what is not already there, so repeated runs stay idempotent + let existing: BTreeSet = fs::read_to_string(&ledger) + .unwrap_or_default() + .lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .filter_map(|v| v.get("id").and_then(|i| i.as_str()).map(str::to_string)) + .collect(); + + let mut f = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&ledger) + .with_context(|| format!("opening {}", ledger.display()))?; + for t in turns.iter().filter(|t| !existing.contains(&t.id)) { + writeln!(f, "{}", serde_json::to_string(t)?)?; + } + ignore_ledger(root) +} + +// add to `.gitignore` if it is not already covered +// ccc:skip +fn ignore_ledger(root: &Path) -> Result<()> { + use std::io::Write; + + let entry = format!("/.ccc/{LEDGER_NAME}"); + let path = root.join(".gitignore"); + let current = fs::read_to_string(&path).unwrap_or_default(); + if current.lines().any(|l| l.trim() == entry) { + return Ok(()); + } + let mut f = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("opening {}", path.display()))?; + let lead = if current.is_empty() || current.ends_with('\n') { + "" + } else { + "\n" + }; + writeln!( + f, + "{lead}\n# prompt text recorded by `ccc prompts --record`\n{entry}" + )?; + eprintln!("ccc: added {entry} to .gitignore (recorded prompts stay out of git)"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // a throwaway directory that cleans up on drop + mod tempdir { + pub struct Dir(std::path::PathBuf); + impl Dir { + pub fn new(tag: &str) -> Dir { + let p = std::env::temp_dir().join(format!("ccc-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).unwrap(); + Dir(p) + } + pub fn path(&self) -> &std::path::Path { + &self.0 + } + } + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + } + + fn write(dir: &Path, name: &str, lines: &[Value]) -> PathBuf { + let p = dir.join(name); + let body: String = lines + .iter() + .map(|l| format!("{l}\n")) + .collect::>() + .join(""); + fs::create_dir_all(dir).unwrap(); + fs::write(&p, body).unwrap(); + p + } + + fn user(uuid: &str, parent: Option<&str>, ts: &str, text: Value) -> Value { + serde_json::json!({ + "type": "user", "uuid": uuid, "parentUuid": parent, "timestamp": ts, + "cwd": "/proj", "gitBranch": "feature", + "message": {"content": text}, + }) + } + + fn assistant(uuid: &str, parent: &str, ts: &str, blocks: Value) -> Value { + serde_json::json!({ + "type": "assistant", "uuid": uuid, "parentUuid": parent, "timestamp": ts, + "cwd": "/proj", + "message": {"content": blocks}, + }) + } + + fn tool_use(name: &str, input: Value) -> Value { + serde_json::json!({"type": "tool_use", "id": "t1", "name": name, "input": input}) + } + + #[test] + fn an_edit_is_credited_to_the_request_that_led_to_it_across_a_tool_result_gap() { + let dir = tempdir::Dir::new("prompt-walk"); + let root = Path::new("/proj"); + let file = write( + dir.path(), + "s1.jsonl", + &[ + user("u1", None, "2026-08-20T10:00:00Z", serde_json::json!("add a retry to charge")), + assistant("a1", "u1", "2026-08-20T10:00:01Z", serde_json::json!([ + tool_use("Read", serde_json::json!({"file_path": "/proj/src/pay.rs"})) + ])), + // the harness answering the model sits between the request and the edit + user("r1", Some("a1"), "2026-08-20T10:00:02Z", serde_json::json!([ + {"type": "tool_result", "tool_use_id": "t1", "content": "ok"} + ])), + assistant("a2", "r1", "2026-08-20T10:00:03Z", serde_json::json!([ + tool_use("Edit", serde_json::json!({ + "file_path": "/proj/src/pay.rs", + "new_string": " let mut attempts = 0; // retry the charge", + })) + ])), + ], + ); + let turns = parse_claude(&file, root); + assert_eq!(turns.len(), 1, "one request, not one per assistant record"); + assert_eq!(turns[0].prompt, "add a retry to charge"); + assert_eq!(turns[0].branch.as_deref(), Some("feature")); + assert_eq!(turns[0].edits.len(), 1); + assert_eq!(turns[0].edits[0].path, "src/pay.rs", "paths are repo-relative"); + assert_eq!(turns[0].edits[0].tool, "Edit"); + assert!(turns[0].edits[0].anchor.is_some()); + } + + #[test] + fn harness_noise_is_never_reported_as_a_request() { + let dir = tempdir::Dir::new("prompt-noise"); + let file = write( + dir.path(), + "s2.jsonl", + &[ + // a slash command is an instruction to the harness, not a request + user("u1", None, "2026-08-20T10:00:00Z", + serde_json::json!("/clear")), + // meta records are the harness talking to itself + serde_json::json!({ + "type": "user", "uuid": "u2", "parentUuid": null, "isMeta": true, + "timestamp": "2026-08-20T10:00:01Z", "cwd": "/proj", + "message": {"content": "caveat: this session is being continued"}, + }), + // an injected reminder is stripped, the user's own words survive + user("u3", None, "2026-08-20T10:00:02Z", serde_json::json!([{ + "type": "text", + "text": "ignore merename the port field", + }])), + ], + ); + let turns = parse_claude(&file, Path::new("/proj")); + assert_eq!(turns.len(), 1, "only the real request survives: {turns:#?}"); + assert_eq!(turns[0].prompt, "rename the port field"); + } + + #[test] + fn a_request_that_changed_nothing_is_still_reported() { + let dir = tempdir::Dir::new("prompt-noedit"); + let file = write( + dir.path(), + "s3.jsonl", + &[user("u1", None, "2026-08-20T10:00:00Z", serde_json::json!("what does serve do?"))], + ); + let turns = parse_claude(&file, Path::new("/proj")); + assert_eq!(turns.len(), 1); + assert!(turns[0].edits.is_empty(), "a question is an answer too"); + } + + #[test] + fn copilots_patch_log_replays_into_the_requests_it_recorded() { + let dir = tempdir::Dir::new("prompt-copilot"); + let file = write( + dir.path(), + "c1.jsonl", + &[ + serde_json::json!({"kind": 0, "v": {"sessionId": "c1", "requests": []}}), + serde_json::json!({"kind": 2, "k": ["requests"], "v": [{ + "requestId": "r1", "timestamp": 1_787_000_000_000i64, + "message": {"text": "extract this into a helper"}, + }]}), + // an unrelated set must not clobber the request list + serde_json::json!({"kind": 1, "k": ["inputState", "inputText"], "v": "@workspace "}), + serde_json::json!({"kind": 2, "k": ["requests"], "v": [{ + "requestId": "r2", "timestamp": 1_787_000_060_000i64, + "message": {"text": "now add a test for it"}, + }]}), + ], + ); + let turns = parse_copilot(&file); + assert_eq!(turns.len(), 2, "both appends replayed: {turns:#?}"); + assert_eq!(turns[0].prompt, "extract this into a helper"); + assert_eq!(turns[1].prompt, "now add a test for it"); + assert_eq!(turns[0].agent, "copilot"); + assert!(turns[0].edits.is_empty(), "copilot's log carries no edit payload"); + assert!(turns[1].epoch > turns[0].epoch); + } + + fn turn(id: &str, epoch: i64, edits: Vec) -> Turn { + Turn { + id: id.into(), + agent: "claude".into(), + session: "s".into(), + ts: chrono::DateTime::from_timestamp(epoch, 0).unwrap().to_rfc3339(), + epoch, + branch: None, + prompt: format!("request {id}"), + edits, + } + } + + #[test] + fn evidence_is_graded_from_the_text_still_being_there_down_to_mere_timing() { + let dir = tempdir::Dir::new("prompt-evidence"); + let root = dir.path(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join("src/pay.rs"), + "fn charge() {\n let mut attempts = 0; // retry the charge\n}\n", + ) + .unwrap(); + fs::write(root.join("src/other.rs"), "fn other() {}\n").unwrap(); + + let turns = vec![ + // wrote a line that is still in the file, inside a changed hunk + turn( + "match", + 1000, + vec![TurnEdit { + path: "src/pay.rs".into(), + tool: "Edit".into(), + anchor: Some("let mut attempts = 0; // retry the charge".into()), + }], + ), + // named a file whose text has since moved on + turn( + "named", + 1100, + vec![TurnEdit { + path: "src/other.rs".into(), + tool: "Write".into(), + anchor: Some("a line that is no longer present anywhere".into()), + }], + ), + // named nothing; only its timing places it + turn("timing", 1200, vec![]), + ]; + let hunks = BTreeMap::from([ + ("src/pay.rs".to_string(), vec![(1usize, 3usize)]), + ("src/other.rs".to_string(), vec![(1, 1)]), + ("src/bash.rs".to_string(), vec![(1, 5)]), + ("src/hand.rs".to_string(), vec![(1, 2)]), + ]); + let times = BTreeMap::from([ + ("src/bash.rs".to_string(), 1250i64), + // written days after the last request, so nothing explains it + ("src/hand.rs".to_string(), 1200 + MAX_TEMPORAL_GAP_SECS + 60), + ]); + + let got = attribute(root, &turns, &hunks, ×); + + let ev = |p: &str| got.by_file.get(p).map(|r| r[0].evidence.clone()); + assert_eq!(ev("src/pay.rs").as_deref(), Some("content-match")); + assert_eq!(ev("src/other.rs").as_deref(), Some("tool-edit")); + assert_eq!(ev("src/bash.rs").as_deref(), Some("temporal")); + assert_eq!( + got.by_file["src/bash.rs"][0].turn, "timing", + "the request in flight when the write happened" + ); + // a content match pins the reference to the hunk it landed in, so a + // function join can tell which part of the file it explains + assert_eq!(got.by_file["src/pay.rs"][0].lines, Some([1, 3])); + assert!(got.by_file["src/other.rs"][0].lines.is_none()); + assert_eq!( + got.unattributed, + vec!["src/hand.rs".to_string()], + "a change nothing explains is reported, not guessed at" + ); + } + + #[test] + fn a_file_wide_reference_covers_every_span_and_a_pinned_one_does_not() { + let wide = PromptRef { + turn: "t".into(), + agent: "claude".into(), + ts: "".into(), + prompt: "".into(), + evidence: "tool-edit".into(), + lines: None, + }; + assert!(wide.covers(1, 1) && wide.covers(900, 1000)); + let pinned = PromptRef { + lines: Some([10, 20]), + ..wide + }; + assert!(pinned.covers(15, 30), "overlap at the top"); + assert!(pinned.covers(1, 12), "overlap at the bottom"); + assert!(!pinned.covers(21, 40), "past the end"); + assert!(!pinned.covers(1, 9), "before the start"); + } + + #[test] + fn the_slug_flattens_the_path_the_way_claude_writes_it() { + assert_eq!( + claude_slug(Path::new("/home/a/Work/codecache")), + "-home-a-Work-codecache" + ); + // a dot in a directory name is flattened too, which is why discovery + // confirms against the `cwd` the transcript records + assert_eq!( + claude_slug(Path::new("/home/a/cloudphage.org")), + "-home-a-cloudphage-org" + ); + } + + #[test] + fn a_long_request_is_condensed_rather_than_carried_whole() { + let long = "line one\n\n".to_string() + &"x".repeat(PROMPT_CAP * 2); + let got = condense(&long); + assert!(got.chars().count() <= PROMPT_CAP + 1, "capped: {}", got.chars().count()); + assert!(got.starts_with("line one"), "the opening survives: {got}"); + assert!(got.ends_with('โ€ฆ'), "and the cut is visible: {got}"); + } +} diff --git a/src/sast.rs b/src/sast.rs new file mode 100644 index 0000000..d2c923e --- /dev/null +++ b/src/sast.rs @@ -0,0 +1,919 @@ +// Static application security testing: syntax-level security findings over the +// same tree-sitter grammars the map is built from. +// +// This is deliberately its own pass rather than a reader of the in-memory map. +// The map keeps where code is and how it connects, not what it says: a call site +// records the callee's name but not its arguments, and a constant records its +// name but not its value. Both are exactly what a security rule needs, so this +// re-parses and looks at literals and call arguments directly. +// +// What that buys over grep: a match here is a real literal or a real call node, +// so a rule cannot fire on a comment, on prose, or on the word `password` inside +// a sentence. What it does not buy is data flow. There is no taint tracking and +// no type information behind these findings - each one cites the text it matched +// so it can be confirmed in the source, exactly as `lints` does. + +use crate::languages::Language; +use crate::changes; +use crate::scan::collect_files; +use serde::Serialize; +use std::path::Path; +use tree_sitter::{Node, Parser}; + +// a node's text is only ever used as evidence, so it never needs to be large +const MAX_TEXT: usize = 300; +const MAX_EVIDENCE: usize = 160; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub enum Severity { + High, + Medium, + Low, +} + +impl Severity { + pub fn as_str(self) -> &'static str { + match self { + Severity::High => "high", + Severity::Medium => "medium", + Severity::Low => "low", + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct Finding { + pub rule: &'static str, + pub severity: Severity, + pub cwe: &'static str, + pub file: String, + pub line: usize, + pub function: String, + pub language: &'static str, + pub message: String, + // the text the rule actually matched, redacted where it carries a secret + pub evidence: String, + pub hint: &'static str, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SastReport { + pub findings: Vec, + pub files_scanned: usize, + // rules that ran, so a caller can see what was looked for as well as found + pub rules: Vec<&'static str>, +} + +impl SastReport { + pub fn by_severity(&self, s: Severity) -> usize { + self.findings.iter().filter(|f| f.severity == s).count() + } +} + +pub const RULES: &[&str] = &[ + "hardcoded-secret", + "tls-verification-disabled", + "shell-injection", + "sql-injection", + "weak-hash", + "insecure-random", +]; + +// one call node: what it calls, and the whole call as written +struct Call { + line: usize, + function: String, + callee: String, + // the callee as written, so a receiver can be told from a bare call + callee_full: String, + text: String, + // the statement the call sits in, which is where the result gets its name + context: String, + in_test: bool, +} + +// one string literal: its value, and the statement it sits in +struct Literal { + line: usize, + function: String, + value: String, + context: String, + in_test: bool, +} + +struct Collected { + calls: Vec, + literals: Vec, + // composite/object literals - a struct field can disable TLS without any call + configs: Vec, +} + +pub fn analyse(root: &Path, include_tests: bool) -> SastReport { + let files = collect_files(root).unwrap_or_default(); + let mut findings = Vec::new(); + let mut scanned = 0usize; + + for path in &files { + let rel = path + .strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/"); + // a credential in a fixture is usually a fixture, not a leak + if !include_tests && changes::is_test_path(&rel) { + continue; + } + let Some(lang) = Language::from_path(path) else { + continue; + }; + let Ok(src) = std::fs::read_to_string(path) else { + continue; + }; + let Some(collected) = collect(lang, &src) else { + continue; + }; + scanned += 1; + findings.extend(apply_rules(&rel, lang, &collected, include_tests)); + } + + // worst first, then by location so the order is stable + findings.sort_by(|a, b| { + a.severity + .cmp(&b.severity) + .then_with(|| a.file.cmp(&b.file)) + .then_with(|| a.line.cmp(&b.line)) + .then_with(|| a.rule.cmp(b.rule)) + }); + + SastReport { + findings, + files_scanned: scanned, + rules: RULES.to_vec(), + } +} + +fn collect(lang: Language, src: &str) -> Option { + let mut parser = Parser::new(); + parser.set_language(&lang.ts_language()).ok()?; + let tree = parser.parse(src, None)?; + let mut out = Collected { + calls: Vec::new(), + literals: Vec::new(), + configs: Vec::new(), + }; + walk(tree.root_node(), src, lang, "", false, &mut out); + Some(out) +} + +fn walk(node: Node, src: &str, lang: Language, function: &str, in_test: bool, out: &mut Collected) { + let kind = node.kind(); + + // entering a function renames the scope every finding below it reports + let mut scope = function.to_string(); + let mut test_scope = in_test; + if lang.func_kinds().contains(&kind) { + if let Some(name) = node + .child_by_field_name("name") + .and_then(|n| text_of(n, src)) + { + test_scope = test_scope || changes::is_test_fn_name(&name); + scope = name; + } + } + // an inline `mod tests` is a test scope even though the file is not a test path + if lang.module_kinds().contains(&kind) { + if let Some(name) = node.child_by_field_name("name").and_then(|n| text_of(n, src)) { + let n = name.to_ascii_lowercase(); + test_scope = test_scope || n == "tests" || n == "test"; + } + } + + if lang.call_kinds().contains(&kind) { + let callee = node + .child_by_field_name("function") + .and_then(|n| text_of(n, src)) + .or_else(|| node.child(0).and_then(|n| text_of(n, src))) + .unwrap_or_default(); + out.calls.push(Call { + line: node.start_position().row + 1, + function: scope.clone(), + callee: rightmost(&callee).to_string(), + callee_full: truncate(&callee, MAX_TEXT), + text: truncate(&text_of(node, src).unwrap_or_default(), MAX_TEXT), + context: truncate( + &node.parent().and_then(|p| text_of(p, src)).unwrap_or_default(), + MAX_TEXT, + ), + in_test: test_scope, + }); + } + + // `tls.Config{InsecureSkipVerify: true}` is a struct literal, not a call + if is_config_kind(kind) { + out.configs.push(Call { + line: node.start_position().row + 1, + function: scope.clone(), + callee: String::new(), + callee_full: String::new(), + text: truncate(&text_of(node, src).unwrap_or_default(), MAX_TEXT), + context: String::new(), + in_test: test_scope, + }); + } + + // grammars spell literals differently, but they all say "string" somewhere + if is_string_kind(kind) { + if let Some(raw) = text_of(node, src) { + let value = unquote(&raw); + if !value.is_empty() { + let context = node + .parent() + .and_then(|p| text_of(p, src)) + .unwrap_or_default(); + out.literals.push(Literal { + line: node.start_position().row + 1, + function: scope.clone(), + value, + context: truncate(&context, MAX_TEXT), + in_test: test_scope, + }); + } + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk(child, src, lang, &scope, test_scope, out); + } +} + +// kinds that carry configuration written as fields rather than arguments +fn is_config_kind(kind: &str) -> bool { + matches!( + kind, + "composite_literal" + | "keyed_element" + | "object" + | "pair" + | "initializer_expression" + | "struct_expression" + | "field_initializer" + | "assignment_expression" + | "assignment" + | "dictionary" + ) +} + +pub(crate) fn is_string_kind(kind: &str) -> bool { + // `string_content` would double-report the same literal its parent already carries + (kind.contains("string") || kind == "raw_string_literal" || kind == "interpreted_string_literal") + && kind != "string_content" + && !kind.contains("interpolation") +} + +fn text_of(node: Node, src: &str) -> Option { + src.get(node.byte_range()).map(|s| s.to_string()) +} + +fn truncate(s: &str, max: usize) -> String { + let one_line = s.replace(['\n', '\r'], " "); + if one_line.chars().count() <= max { + return one_line; + } + let cut: String = one_line.chars().take(max).collect(); + format!("{cut}...") +} + +// strip the quoting a grammar hands back, whatever flavour it is +pub(crate) fn unquote(raw: &str) -> String { + let t = raw.trim(); + // rust r#".."#, python/go prefixes, c# verbatim + let t = t + .trim_start_matches(|c: char| c.is_ascii_alphabetic() || c == '@') + .trim_start_matches('#'); + let t = t.trim_start_matches("r#").trim(); + for q in ['"', '\'', '`'] { + if let Some(inner) = t.strip_prefix(q) { + return inner + .strip_suffix(&format!("{q}#")) + .or_else(|| inner.strip_suffix(q)) + .unwrap_or(inner) + .to_string(); + } + } + t.to_string() +} + +// `crypto.createHash` -> `createHash`, `Md5::new` -> `new` +pub(crate) fn rightmost(s: &str) -> &str { + let s = s.trim(); + let cut = s + .rfind("::") + .map(|i| i + 2) + .or_else(|| s.rfind('.').map(|i| i + 1)) + .or_else(|| s.rfind("->").map(|i| i + 2)) + .unwrap_or(0); + &s[cut..] +} + +fn norm(s: &str) -> String { + s.to_ascii_lowercase().replace([' ', '\t'], "") +} + +fn apply_rules(file: &str, lang: Language, c: &Collected, include_tests: bool) -> Vec { + let mut out = Vec::new(); + for lit in &c.literals { + if lit.in_test && !include_tests { + continue; + } + secret_rule(file, lang, lit, &mut out); + } + for call in &c.calls { + if call.in_test && !include_tests { + continue; + } + tls_rule(file, lang, call, &mut out); + shell_rule(file, lang, call, &mut out); + sql_rule(file, lang, call, &mut out); + hash_rule(file, lang, call, &mut out); + random_rule(file, lang, call, &mut out); + } + for cfg in &c.configs { + if cfg.in_test && !include_tests { + continue; + } + tls_rule(file, lang, cfg, &mut out); + } + // a nested node repeats its parent's match; one issue is one finding + let mut seen = std::collections::BTreeSet::new(); + out.retain(|f| seen.insert((f.rule, f.line))); + out +} + +fn push( + out: &mut Vec, + rule: &'static str, + severity: Severity, + cwe: &'static str, + file: &str, + lang: Language, + line: usize, + function: &str, + message: String, + evidence: String, + hint: &'static str, +) { + out.push(Finding { + rule, + severity, + cwe, + file: file.to_string(), + line, + function: function.to_string(), + language: lang.as_str(), + message, + evidence: truncate(&evidence, MAX_EVIDENCE), + hint, + }); +} + +// credentials that announce themselves by shape - prefix, charset and length +const TOKEN_SHAPES: &[(&str, &str, usize)] = &[ + ("AKIA", "an AWS access key id", 20), + ("ASIA", "an AWS temporary access key id", 20), + ("ghp_", "a GitHub personal access token", 40), + ("gho_", "a GitHub OAuth token", 40), + ("ghs_", "a GitHub server token", 40), + ("github_pat_", "a GitHub fine-grained token", 40), + ("xoxb-", "a Slack bot token", 24), + ("xoxp-", "a Slack user token", 24), + ("sk_live_", "a Stripe live secret key", 24), + ("rk_live_", "a Stripe restricted key", 24), + ("AIza", "a Google API key", 39), + ("SG.", "a SendGrid API key", 40), + ("glpat-", "a GitLab personal access token", 20), +]; + +// names that make a literal beside them a credential rather than a string +const SECRET_NAMES: &[&str] = &[ + "password", "passwd", "pwd", "secret", "api_key", "apikey", "access_key", "token", + "credential", "private_key", "auth", "passphrase", "client_secret", +]; + +// values that look like secrets but are placeholders +const PLACEHOLDERS: &[&str] = &[ + "changeme", "change_me", "password", "secret", "token", "example", "placeholder", "your", + "xxx", "todo", "none", "null", "test", "dummy", "sample", "redacted", "hunter2", +]; + +fn secret_rule(file: &str, lang: Language, lit: &Literal, out: &mut Vec) { + let v = lit.value.trim(); + + // a private key pasted into source is unambiguous + if v.contains("-----BEGIN") && v.contains("PRIVATE KEY-----") && v.len() >= 64 { + push( + out, "hardcoded-secret", Severity::High, "CWE-798", file, lang, lit.line, + &lit.function, + "a PEM private key is embedded in source".to_string(), + "-----BEGIN ... PRIVATE KEY----- (redacted)".to_string(), + "move it to a secret store or an environment variable, and rotate it - it is in git history", + ); + return; + } + + // a shaped token needs no surrounding context to be recognised + for (prefix, what, min_len) in TOKEN_SHAPES { + if v.starts_with(prefix) && v.len() >= *min_len && !v.contains(' ') { + push( + out, "hardcoded-secret", Severity::High, "CWE-798", file, lang, lit.line, + &lit.function, + format!("a literal that looks like {what}"), + redact(v), + "move it to a secret store or an environment variable, and rotate it - it is in git history", + ); + return; + } + } + + // a JWT carries its own header + if v.starts_with("eyJ") && v.matches('.').count() >= 2 && v.len() >= 40 { + push( + out, "hardcoded-secret", Severity::Medium, "CWE-798", file, lang, lit.line, + &lit.function, + "a literal that looks like a JSON Web Token".to_string(), + redact(v), + "move it to a secret store or an environment variable, and rotate it - it is in git history", + ); + return; + } + + // otherwise it takes the name beside it to tell a secret from a string + let ctx = norm(&lit.context); + let assigned_to_secret = SECRET_NAMES.iter().any(|n| { + let n = n.replace('_', ""); + ctx.replace('_', "").contains(&format!("{n}=")) || ctx.replace('_', "").contains(&format!("{n}:")) + }); + if !assigned_to_secret { + return; + } + if v.len() < 8 || v.contains(' ') { + return; + } + // an interpolation or an env lookup is the fix, not the bug + if v.contains("${") || v.contains("{}") || v.contains('%') || v.starts_with("$(") { + return; + } + let lower = v.to_ascii_lowercase(); + if PLACEHOLDERS.iter().any(|p| lower.contains(p)) { + return; + } + // a real credential is not a word; entropy is what separates the two + if entropy(v) < 3.0 { + return; + } + push( + out, "hardcoded-secret", Severity::High, "CWE-798", file, lang, lit.line, &lit.function, + "a high-entropy literal is assigned to a credential-shaped name".to_string(), + format!("{} = {}", first_secret_name(&lit.context).unwrap_or_else(|| "".into()), redact(v)), + "move it to a secret store or an environment variable, and rotate it - it is in git history", + ); +} + +fn first_secret_name(context: &str) -> Option { + let lower = context.to_ascii_lowercase(); + let hit = SECRET_NAMES + .iter() + .filter_map(|n| lower.find(n).map(|i| (i, *n))) + .min_by_key(|(i, _)| *i)?; + Some(hit.1.to_string()) +} + +fn redact(v: &str) -> String { + let head: String = v.chars().take(4).collect(); + format!("{head}... ({} chars, redacted)", v.chars().count()) +} + +// shannon entropy per char, which is what separates a key from a word +fn entropy(s: &str) -> f64 { + if s.is_empty() { + return 0.0; + } + let mut counts = [0usize; 256]; + let mut total = 0usize; + for b in s.bytes() { + counts[b as usize] += 1; + total += 1; + } + let total = total as f64; + counts + .iter() + .filter(|&&c| c > 0) + .map(|&c| { + let p = c as f64 / total; + -p * p.log2() + }) + .sum() +} + +fn tls_rule(file: &str, lang: Language, call: &Call, out: &mut Vec) { + let t = norm(&call.text); + const OFF: &[(&str, &str)] = &[ + ("verify=false", "certificate verification is disabled"), + ("rejectunauthorized:false", "certificate verification is disabled"), + ("insecureskipverify:true", "certificate verification is disabled"), + ("danger_accept_invalid_certs(true)", "invalid certificates are accepted"), + ("curlopt_ssl_verifypeer,0", "peer verification is disabled"), + ("curlopt_ssl_verifyhost,0", "hostname verification is disabled"), + ("servercertificatevalidationcallback", "certificate validation is overridden"), + ("checkservertrusted", "the trust manager is overridden"), + ]; + for (needle, what) in OFF { + if t.contains(needle) { + push( + out, "tls-verification-disabled", Severity::High, "CWE-295", file, lang, call.line, + &call.function, + format!("{what} on this call"), + call.text.clone(), + "leave verification on; for a private CA add it to the trust store instead", + ); + return; + } + } +} + +fn shell_rule(file: &str, lang: Language, call: &Call, out: &mut Vec) { + let t = norm(&call.text); + let callee = call.callee.as_str(); + + // python's subprocess with a shell is the classic injection surface, but the + // keyword only means anything on a call that actually starts a process + const SPAWNERS: &[&str] = &[ + "run", "call", "check_call", "check_output", "popen", "spawn", "exec", "execsync", + "spawnsync", "system", + ]; + let spawns = SPAWNERS.contains(&callee.to_ascii_lowercase().as_str()) + || norm(&call.callee_full).contains("subprocess") + || norm(&call.callee_full).contains("child_process"); + if t.contains("shell=true") && spawns { + push( + out, "shell-injection", Severity::High, "CWE-78", file, lang, call.line, &call.function, + "a subprocess is run through a shell".to_string(), + call.text.clone(), + "pass the command as a list and drop shell=True, so arguments cannot be reinterpreted", + ); + return; + } + // `sh -c` reintroduces a shell whatever the api + if (t.contains("\"sh\"") || t.contains("\"bash\"") || t.contains("'sh'") || t.contains("'bash'")) + && (t.contains("\"-c\"") || t.contains("'-c'")) + { + push( + out, "shell-injection", Severity::High, "CWE-78", file, lang, call.line, &call.function, + "a command is handed to `sh -c`".to_string(), + call.text.clone(), + "invoke the binary directly with an argument list instead of going through a shell", + ); + return; + } + // an interpreter given something built at runtime + const EVAL: &[&str] = &["eval", "exec", "system", "popen", "execsync", "spawnsync"]; + // `RE.exec(line)` is a regex match, not a process - a bare call or a process + // receiver is what separates the sink from the common false positive + let receiver = call + .callee_full + .rsplit_once('.') + .map(|(r, _)| norm(r)) + .unwrap_or_default(); + const PROCESS_RECEIVERS: &[&str] = &[ + "os", "subprocess", "child_process", "cp", "shell", "runtime", "sys", "process", "sh", + ]; + let process_context = receiver.is_empty() + || PROCESS_RECEIVERS.iter().any(|r| receiver.ends_with(r)) + || norm(&call.context).contains("subprocess") + || norm(&call.context).contains("child_process"); + if EVAL.contains(&callee.to_ascii_lowercase().as_str()) + && process_context + && !only_literal_args(&call.text) + { + push( + out, "shell-injection", Severity::High, "CWE-78", file, lang, call.line, &call.function, + format!("`{callee}` is called with an argument built at runtime"), + call.text.clone(), + "avoid interpreting data as code; use an argument list or a parser for the value instead", + ); + } +} + +fn sql_rule(file: &str, lang: Language, call: &Call, out: &mut Vec) { + const SINKS: &[&str] = &["execute", "executemany", "query", "exec", "raw", "prepare"]; + if !SINKS.contains(&call.callee.to_ascii_lowercase().as_str()) { + return; + } + let upper = call.text.to_ascii_uppercase(); + const VERBS: &[&str] = &["SELECT ", "INSERT ", "UPDATE ", "DELETE ", "DROP ", "UNION "]; + if !VERBS.iter().any(|v| upper.contains(v)) { + return; + } + // a fully literal statement is a constant query, which is the safe case + if only_literal_args(&call.text) { + return; + } + let t = &call.text; + let concatenated = t.contains(" + ") + || t.contains("+\"") + || t.contains("${") + || t.contains("' %") + || t.contains("\" %") + || t.contains(".format(") + || t.contains("f\"") + || t.contains("f'") + || t.contains("||"); + if !concatenated { + return; + } + push( + out, "sql-injection", Severity::High, "CWE-89", file, lang, call.line, &call.function, + "an SQL statement is assembled from a value rather than parameterised".to_string(), + call.text.clone(), + "pass the value as a bound parameter (`?`, `$1`, `:name`) instead of interpolating it", + ); +} + +fn hash_rule(file: &str, lang: Language, call: &Call, out: &mut Vec) { + let t = norm(&call.text); + let callee = norm(&call.callee); + let weak = ["md5", "sha1", "md4", "sha-1"]; + let named = weak.iter().any(|w| callee.contains(w)); + let argued = weak + .iter() + .any(|w| t.contains(&format!("\"{w}\"")) || t.contains(&format!("'{w}'"))); + if !named && !argued { + return; + } + // a checksum is a legitimate use; only flag where the name suggests security + let security_context = ["password", "token", "secret", "sign", "auth", "hmac", "cert", "key"] + .iter() + .any(|w| t.contains(w) || norm(&call.function).contains(w)); + let severity = if security_context { + Severity::High + } else { + Severity::Low + }; + let message = if security_context { + "a broken hash is used in what looks like a security context".to_string() + } else { + "a broken hash algorithm is used; harmless as a checksum, not for security".to_string() + }; + push( + out, "weak-hash", severity, "CWE-327", file, lang, call.line, &call.function, + message, call.text.clone(), + "use SHA-256 or better; for passwords use argon2, scrypt or bcrypt rather than a raw hash", + ); +} + +fn random_rule(file: &str, lang: Language, call: &Call, out: &mut Vec) { + let callee = call.callee.as_str(); + let full = norm(&call.text); + let weak = matches!(callee, "random" | "rand" | "rand_r" | "srand" | "nextInt" | "randint") + || full.starts_with("math.random") + || full.contains("math.random("); + if !weak { + return; + } + // predictable randomness only matters when the value is meant to be unguessable + let around = format!("{} {}", norm(&call.context), norm(&call.function)); + let sensitive = ["token", "secret", "key", "nonce", "salt", "password", "session", "otp", "iv"] + .iter() + .any(|w| around.contains(w) || full.contains(w)); + if !sensitive { + return; + } + push( + out, "insecure-random", Severity::Medium, "CWE-338", file, lang, call.line, &call.function, + "a predictable random source is used for a value that must be unguessable".to_string(), + call.text.clone(), + "use a cryptographic generator: secrets/os.urandom, crypto.randomBytes, OsRng", + ); +} + +// true when every argument is a plain literal, which makes a sink constant +fn only_literal_args(text: &str) -> bool { + let Some(open) = text.find('(') else { + return false; + }; + let args = &text[open + 1..text.rfind(')').unwrap_or(text.len().saturating_sub(1)).max(open + 1)]; + let trimmed = args.trim(); + if trimmed.is_empty() { + return true; + } + // an identifier, a call or an operator means something is computed here + let mut in_string = false; + let mut quote = '\0'; + let mut outside = String::new(); + for ch in trimmed.chars() { + if in_string { + if ch == quote { + in_string = false; + } + continue; + } + if ch == '"' || ch == '\'' || ch == '`' { + in_string = true; + quote = ch; + continue; + } + outside.push(ch); + } + // only separators and whitespace should remain once literals are removed + outside + .chars() + .all(|c| c.is_whitespace() || c == ',' || c == '[' || c == ']' || c == 'r') +} + +pub fn rule_catalogue() -> Vec<(&'static str, &'static str, &'static str)> { + vec![ + ("hardcoded-secret", "CWE-798", "credentials written into source"), + ("tls-verification-disabled", "CWE-295", "certificate checks turned off"), + ("shell-injection", "CWE-78", "a command line built at runtime"), + ("sql-injection", "CWE-89", "a statement assembled instead of parameterised"), + ("weak-hash", "CWE-327", "a broken hash algorithm"), + ("insecure-random", "CWE-338", "predictable randomness for a secret"), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + fn run(lang: Language, src: &str) -> Vec { + let c = collect(lang, src).expect("the source should parse"); + apply_rules("t", lang, &c, true) + } + + fn rules_of(f: &[Finding]) -> BTreeSet<&str> { + f.iter().map(|x| x.rule).collect() + } + + #[test] + fn a_shaped_token_is_found_without_any_surrounding_context() { + let f = run( + Language::Python, + "KEY = \"AKIAIOSFODNN7EXAMPLE\"\n", + ); + assert_eq!(f.len(), 1, "{f:?}"); + assert_eq!(f[0].rule, "hardcoded-secret"); + assert_eq!(f[0].severity, Severity::High); + // the value never appears in full in the report + assert!(!f[0].evidence.contains("IOSFODNN7EXAMPLE"), "{}", f[0].evidence); + assert!(f[0].evidence.contains("redacted")); + } + + #[test] + fn a_credential_name_plus_entropy_is_a_secret_but_a_placeholder_is_not() { + let hit = run( + Language::Python, + "password = \"S7dK9vQxR2mLpZ4w\"\n", + ); + assert_eq!(rules_of(&hit), ["hardcoded-secret"].into_iter().collect()); + + // placeholders, env lookups and short values are all the fix rather than the bug + for src in [ + "password = \"changeme\"\n", + "password = \"${DB_PASSWORD}\"\n", + "password = \"short\"\n", + "greeting = \"S7dK9vQxR2mLpZ4w\"\n", + ] { + assert!(run(Language::Python, src).is_empty(), "should not fire: {src}"); + } + } + + #[test] + fn a_password_in_a_comment_or_prose_never_fires() { + // the whole point of parsing rather than grepping + let f = run( + Language::Python, + "# password = \"S7dK9vQxR2mLpZ4w\" is what we used to do\nx = 1\n", + ); + assert!(f.is_empty(), "{f:?}"); + } + + #[test] + fn shell_true_and_sh_dash_c_are_both_injection_surfaces() { + let f = run( + Language::Python, + "subprocess.run(cmd, shell=True)\n", + ); + assert!(rules_of(&f).contains("shell-injection"), "{f:?}"); + + let f = run( + Language::Rust, + "fn go(){ Command::new(\"sh\").arg(\"-c\").arg(user).spawn(); }\n", + ); + assert!(rules_of(&f).contains("shell-injection"), "{f:?}"); + } + + #[test] + fn a_constant_query_is_safe_and_a_built_one_is_not() { + let safe = run( + Language::Python, + "cur.execute(\"SELECT 1 FROM users\")\n", + ); + assert!(!rules_of(&safe).contains("sql-injection"), "{safe:?}"); + + let unsafe_ = run( + Language::Python, + "cur.execute(\"SELECT * FROM users WHERE id = \" + user_id)\n", + ); + assert!(rules_of(&unsafe_).contains("sql-injection"), "{unsafe_:?}"); + } + + #[test] + fn tls_verification_off_is_high_whatever_the_ecosystem_calls_it() { + for (lang, src) in [ + (Language::Python, "requests.get(url, verify=False)\n"), + (Language::JavaScript, "https.request({rejectUnauthorized: false});\n"), + (Language::Go, "func f(){ tls.Config{InsecureSkipVerify: true} }\n"), + ] { + let f = run(lang, src); + assert!( + f.iter().any(|x| x.rule == "tls-verification-disabled" && x.severity == Severity::High), + "{lang:?} did not fire: {f:?}" + ); + } + } + + #[test] + fn a_weak_hash_is_only_serious_in_a_security_context() { + let checksum = run(Language::Python, "h = hashlib.md5(chunk)\n"); + let f = checksum.iter().find(|f| f.rule == "weak-hash").expect("should fire"); + // a checksum is a legitimate use, so it is reported quietly + assert_eq!(f.severity, Severity::Low); + + let auth = run(Language::Python, "h = hashlib.md5(password)\n"); + let f = auth.iter().find(|f| f.rule == "weak-hash").expect("should fire"); + assert_eq!(f.severity, Severity::High); + } + + #[test] + fn predictable_randomness_only_matters_for_unguessable_values() { + let f = run(Language::JavaScript, "const token = Math.random();\n"); + assert!(rules_of(&f).contains("insecure-random"), "{f:?}"); + // a jitter or an animation frame is not a security decision + let f = run(Language::JavaScript, "const jitter = Math.random();\n"); + assert!(!rules_of(&f).contains("insecure-random"), "{f:?}"); + } + + #[test] + fn findings_carry_their_enclosing_function() { + let f = run( + Language::Python, + "def connect():\n password = \"S7dK9vQxR2mLpZ4w\"\n", + ); + assert_eq!(f[0].function, "connect"); + assert_eq!(f[0].line, 2); + } + + #[test] + fn a_regex_exec_is_not_a_process_call() { + // the common false positive: RegExp.prototype.exec looks exactly like child_process.exec + let f = run(Language::JavaScript, "const m = LISTENING.exec(line.trim());\n"); + assert!(!rules_of(&f).contains("shell-injection"), "{f:?}"); + + // the real sink still fires + let f = run(Language::JavaScript, "child_process.exec(userInput);\n"); + assert!(rules_of(&f).contains("shell-injection"), "{f:?}"); + // as does python's bare builtin, which has no receiver at all + let f = run(Language::Python, "exec(payload)\n"); + assert!(rules_of(&f).contains("shell-injection"), "{f:?}"); + } + + #[test] + fn shell_true_only_counts_on_a_call_that_starts_a_process() { + // matching the text of the rule itself is not running a subprocess + let f = run(Language::Rust, "fn r(){ t.contains(\"shell=true\"); }\n"); + assert!(!rules_of(&f).contains("shell-injection"), "{f:?}"); + } + + #[test] + fn a_pem_label_is_not_a_pem_key() { + // a short mention, as a rule table or a message would carry + let f = run(Language::Rust, "fn m(){ let s = \"-----BEGIN ... PRIVATE KEY----- (redacted)\"; }\n"); + assert!(f.is_empty(), "{f:?}"); + } + + #[test] + fn test_scopes_are_skipped_unless_asked_for() { + // an inline `mod tests` is a test scope even though the file is not a test path + let src = "mod tests {\n fn t(){ let password = \"S7dK9vQxR2mLpZ4w\"; }\n}\n"; + let c = collect(Language::Rust, src).unwrap(); + assert!(apply_rules("t", Language::Rust, &c, false).is_empty()); + assert!(!apply_rules("t", Language::Rust, &c, true).is_empty()); + } + + #[test] + fn entropy_separates_a_key_from_a_word() { + assert!(entropy("S7dK9vQxR2mLpZ4w") > 3.0); + assert!(entropy("passwordpassword") < 3.0); + } +} diff --git a/src/serve.rs b/src/serve.rs index 2dac9c2..362dfab 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -6,7 +6,7 @@ //! parsed map in whenever source changes - `/refresh` forces it immediately. use crate::model::{FileCache, Counts}; -use crate::{insights, render, scan}; +use crate::{audit, deps, insights, render, sast, scan}; use anyhow::Result; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -50,8 +50,16 @@ struct MapState { html: bool, origin: String, analysis: Mutex>, + // the advisory answer for this map generation; asking osv is a network round trip + audit: Mutex>, + // security findings for this generation, keyed also by whether tests were included + sast: Mutex>, + // the dependency delta per base ref for this generation + deps: Mutex, } +type DepsCache = (String, BTreeMap)>); + struct Analysis { // the map generation this was computed from ts: String, @@ -83,9 +91,61 @@ impl MapState { format!("http://{}:{}", d.addr, d.port) }, analysis: Mutex::new(None), + audit: Mutex::new(None), + sast: Mutex::new(None), + deps: Mutex::new((String::new(), BTreeMap::new())), }) } + // resolving lockfiles is cheap, asking osv is not - hold the answer for this generation + fn audit_report(&self, offline: bool) -> audit::AuditReport { + let mut slot = self.audit.lock().expect("audit lock poisoned"); + if let Some((ts, cached)) = slot.as_ref() { + // a cached resolution still serves an offline caller even if osv was unreachable + if ts == &self.ts && (offline || cached.assessed) { + return cached.clone(); + } + } + let mut report = audit::resolve(&self.root); + if !offline { + audit::assess(&mut report); + } + audit::locate(&self.root, &mut report); + *slot = Some((self.ts.clone(), report.clone())); + report + } + + // What this branch did to the dependency tree + fn deps_report(&self, base: Option<&str>) -> Result<(String, Arc), String> { + let key = base.unwrap_or_default().to_string(); + let mut slot = self.deps.lock().unwrap_or_else(|p| p.into_inner()); + if slot.0 != self.ts { + *slot = (self.ts.clone(), BTreeMap::new()); + } + if let Some((label, hit)) = slot.1.get(&key) { + return Ok((label.clone(), Arc::clone(hit))); + } + // the server watches a working tree, so uncommitted edits count + let (label, report) = crate::changes::deps_report(&self.root, base, true) + .map_err(|e| format!("{e:#}"))?; + let report = Arc::new(report); + slot.1.insert(key, (label.clone(), Arc::clone(&report))); + Ok((label, report)) + } + + // re-parsing every file is not free, so hold the answer for this generation + fn sast_report(&self, include_tests: bool) -> sast::SastReport { + let mut slot = self.sast.lock().expect("sast lock poisoned"); + if let Some((ts, tests, cached)) = slot.as_ref() { + if ts == &self.ts && *tests == include_tests { + return cached.clone(); + } + } + let report = sast::analyse(&self.root, include_tests); + *slot = Some((self.ts.clone(), include_tests, report.clone())); + report + } + fn rescan(&mut self) -> Result<(usize, usize)> { let before = self.caches.len(); let files = scan::collect_files(&self.root)?; @@ -1439,6 +1499,153 @@ fn q_notes(map: &MapState, marker: Option<&str>) -> Value { json!({"count": notes.len(), "marker": marker, "notes": notes}) } +// resolved dependency set plus whatever the advisory database had to say about it +fn md_security(r: &sast::SastReport, floor: sast::Severity, rule: Option<&str>) -> String { + use std::fmt::Write; + let shown: Vec<&sast::Finding> = r + .findings + .iter() + .filter(|f| f.severity <= floor) + .filter(|f| rule.is_none_or(|want| f.rule == want)) + .collect(); + + let mut out = String::new(); + let _ = writeln!( + out, + "# security - {} finding(s) across {} file(s)", + shown.len(), + r.files_scanned + ); + let _ = writeln!( + out, + "high {}, medium {}, low {} (test scopes excluded unless include_tests)", + r.by_severity(sast::Severity::High), + r.by_severity(sast::Severity::Medium), + r.by_severity(sast::Severity::Low) + ); + + if shown.is_empty() { + out.push_str( + "\nnothing matched. These are syntax-level rules with no data flow behind them, \ + so this is not a proof of safety.\n", + ); + } + + for f in &shown { + let _ = write!( + out, + "\n[{}] {} - {}:{} in {}\n {}\n evidence: {}\n {} - {}\n", + f.severity.as_str(), + f.rule, + f.file, + f.line, + f.function, + f.message, + f.evidence, + f.cwe, + f.hint, + ); + } + + out.push_str("\n## rules\n"); + for (name, cwe, what) in sast::rule_catalogue() { + let n = r.findings.iter().filter(|f| f.rule == name).count(); + let _ = writeln!(out, "- {name} ({cwe}) - {what}: {n} finding(s)"); + } + out.push_str( + "\nEvery finding is a syntax match with no type or data-flow information behind it - \ + read the evidence and confirm in the source before acting on one.\n", + ); + out +} + +fn md_vulnerabilities(r: &audit::AuditReport, offline: bool, with_dev: bool) -> String { + use std::fmt::Write; + let shown: Vec<&audit::Finding> = if with_dev { + r.findings.iter().collect() + } else { + r.runtime_findings() + }; + let mut out = String::new(); + + if r.packages.is_empty() { + return "# vulnerabilities\n\nno lockfile found under this root, so no version could be \ + resolved. A manifest range like `1` or `^2.0` names nothing an advisory can be \ + matched against - run the package manager to produce a lockfile." + .to_string(); + } + + let _ = writeln!( + out, + "# vulnerabilities - {} finding(s) across {} package(s)", + shown.len(), + r.packages.len() + ); + let _ = writeln!( + out, + "{} resolved from {} lockfile(s), {} direct and {} transitive", + r.packages.len(), + r.lockfiles.len(), + r.direct_count(), + r.packages.len() - r.direct_count() + ); + out.push('\n'); + for lock in &r.lockfiles { + let n = r.packages.iter().filter(|p| &p.lockfile == lock).count(); + let _ = writeln!(out, "- {lock} - {n} package(s)"); + } + + if !r.unresolved.is_empty() { + // a coverage gap is not a clean result, so it is never left implicit + let _ = write!(out, "\n## not resolved ({})\n", r.unresolved.len()); + for u in &r.unresolved { + let _ = writeln!(out, "- {} - {}", u.manifest, u.reason); + } + } + if offline { + out.push_str("\nthe advisory database was not consulted (offline)\n"); + return out; + } + if let Some(err) = &r.error { + // the resolution above still stands, only the assessment is missing + let _ = write!(out, "\nnot assessed - {err}\n"); + return out; + } + if shown.is_empty() { + let scope = if with_dev { "" } else { " that reach production" }; + let _ = write!(out, "\nno known advisories against these packages{scope}\n"); + return out; + } + + let runtime = shown.iter().filter(|f| !f.package.dev).count(); + let _ = write!( + out, + "\n## findings ({runtime} runtime, {} dev-only)\n", + shown.len() - runtime + ); + for f in shown { + let p = &f.package; + let _ = write!( + out, + "\n[{}] {} {} - {}, {}, {}\n {}\n {}\n {} {}\n", + f.advisory.severity, + p.name, + p.version, + p.ecosystem.label(), + if p.direct { "direct" } else { "transitive" }, + if p.dev { "dev-only" } else { "runtime" }, + f.advisory.summary, + match &f.advisory.fixed { + Some(v) => format!("fixed in {v}"), + None => "no fixed version published".to_string(), + }, + f.advisory.id, + f.advisory.url, + ); + } + out +} + fn mcp_tools() -> Value { let tool = |name: &str, desc: &str, props: Value, required: &[&str]| { json!({ @@ -1451,7 +1658,7 @@ fn mcp_tools() -> Value { }, }) }; - json!({ "tools": [ + let tools = vec![ tool( "index", "START HERE in an unfamiliar project: what it contains, which directories carry the weight, where to look next. Use instead of `ls -R`, `find . -name '*.rs'` or `tree`. Every file the map holds is named, in path order, one row each - nothing is ever collapsed into a directory summary, so a row always tells you where something actually is. Most projects come back whole; the answer is capped at about a thousand lines, and past that the rest waits behind `offset` (or narrow with `path` instead of paging). The header totals always describe the whole filtered set, not the page. Where a project has module roots the rows carry two more columns: `mods`, the submodules a file declares, and `exp`, the names it re-exports - so a Rust `lib.rs` or `mod.rs`, which defines nothing and reads as zero in every other column, is visible as the module graph and public surface it is. Then drill: `path` for the files under one directory, `offset` for the next page of rows.", @@ -1483,6 +1690,33 @@ fn mcp_tools() -> Value { json!({"file": {"type": "string", "description": "relative path (optional)"}}), &[], ), + tool( + "vulnerabilities", + "ANSWERS whether anything this project depends on has a known vulnerability, and which of them actually reach production. Use instead of reading a manifest and guessing. Resolves the exact transitive closure from lockfiles (Cargo.lock, package-lock.json, go.sum, pinned requirements.txt) rather than the loose ranges a manifest declares - `anyhow = \"1\"` names no version an advisory range can be matched against, and most advisories land on packages nothing declared. The resolved set is checked against the OSV advisory database, which covers crates.io, npm, Go and PyPI. Each finding carries the severity, the first fixed version, and whether the package is `direct` (a manifest names it) or `transitive`, `runtime` or `dev` - a dev-only advisory never ships. Findings are ordered runtime first, then worst first. The advisory database is consulted over the network; when it cannot be reached the resolved dependency set is still returned and the reason is reported, never fatal. Pass offline=true to resolve dependencies without any network call.", + json!({ + "offline": {"type": "boolean", "description": "resolve dependencies only; never consult the advisory database (default false)"}, + "dev": {"type": "boolean", "description": "include dev/build-only findings (default true; set false for only what ships)"}, + }), + &[], + ), + tool( + "deps", + "ANSWERS what this branch did to the dependency tree: which packages entered it, which left, which moved version, and whether that introduced an advisory that was not there before. Use instead of reading a lockfile diff, which is thousands of lines of hashes with the answer buried in it. It does NOT answer what this project depends on or what is currently vulnerable - that is `vulnerabilities`, which never looks at git. Both sides of the branch are resolved out of committed trees through the same lockfile parsers, so the answer does not change with whatever is on disk. Each change carries the versions on both sides, whether the package is `direct` (a manifest names it) or transitive, whether it ships or is dev-only, and the manifest lines it can be edited at - a transitive bump points at the direct dependency whose line a person can actually change. `promoted`/`demoted` mean a manifest started or stopped naming it at the same version; `now-ships`/`no-longer-ships` mean it crossed between the dev and runtime closures, which is when a dev-only advisory starts to matter. Version direction is reported only where the ecosystem orders versions unambiguously; a Go pseudo-version or an npm prerelease tag reads as `versions-changed` rather than a guessed direction. Only the versions that exist on exactly one side are checked against the OSV advisory database; when it cannot be reached the change set is still complete and the reason is reported, never fatal. A branch that touched no manifest or lockfile answers in one line without reading anything.", + json!({ + "base": {"type": "string", "description": "git ref to diff against (default: merge-base with origin/main, main, origin/master or master - first that exists)"}, + }), + &[], + ), + tool( + "security", + "ANSWERS what in this project's own code is a security risk - credentials committed to source, certificate checks turned off, a command line or an SQL statement built from a value, a broken hash, predictable randomness. Use instead of grepping for `password` or `eval`. Rules match real literal and call nodes in the same tree-sitter parse the map is built from, so a finding cannot come from a comment, from prose, or from the word `password` inside a sentence - which is exactly what a text search gets wrong. Each finding carries the rule, a CWE, the severity, the enclosing function, and the text it actually matched; secret values are redacted to a prefix and a length. Test scopes are skipped by default because a credential in a fixture is usually a fixture - an inline `mod tests` counts, not just a test path. These are syntax-level rules with no data flow and no type information behind them: read the evidence and confirm in the source before acting on one, and treat a clean result as nothing matched rather than as proof of safety.", + json!({ + "min_severity": {"type": "string", "enum": ["high", "medium", "low"], "description": "only findings at or above this severity (default low, i.e. everything)"}, + "rule": {"type": "string", "description": "only findings from this rule (see the rules section of any result)"}, + "include_tests": {"type": "boolean", "description": "also scan test scopes, where a credential is usually a fixture (default false)"}, + }), + &[], + ), tool( "file", "ANSWERS what is in this file - the submodules it declares, its imports (`pub` marks a re-export), every constant and function with its return type and doc summary, plus notes - for a fraction of the tokens reading it would cost. Use it to decide whether a file is worth opening at all, and on a module root (lib.rs, mod.rs, __init__.py) to read the module graph and published API of everything around it. NOT for editing: this is a map, not the code, so open the real source before you change a line. Pass structured=true for definition spans and the intra-file call graph instead of the rendered markdown.", @@ -1512,6 +1746,16 @@ fn mcp_tools() -> Value { }), &[], ), + tool( + "prompts", + "ANSWERS why a change was made, not just what changed: which request sent to claude or copilot produced it. Use when a hunk needs explaining - before reverting something that looks stray, when reviewing work another session did, or to recover the intent behind code you are about to change. Reads the records those agents already keep on this machine: claude's session transcripts, which link a prompt to the exact `Edit`/`Write` it produced, and copilot's chat log, which carries the prompt and its timing. Every attribution names its evidence: `content-match` means the text that request wrote is still in the file inside a changed hunk, `tool-edit` means the request named the file, `temporal` means only its timing places it there - the rung that covers edits made through the shell. A changed file no request explains is listed as unattributed rather than credited to whichever prompt was nearest, so `unattributed` is the honest answer for hand-written code.", + json!({ + "base": {"type": "string", "description": "git ref to diff against (default: merge-base with origin/main, main, origin/master or master - first that exists)"}, + "limit": {"type": "integer", "description": "attributed files per page (default 25, max 500)"}, + "offset": {"type": "integer", "description": "attributed files to skip (default 0)"}, + }), + &[], + ), tool( "test_triggers", "CALL BEFORE RUNNING ANY TEST SUITE, and again after editing: it answers which tests your changes actually put at risk, so you run those instead of everything, and which changes no test covers at all. Tests are matched to changed functions through the call graph, so a change deep in the stack still surfaces the tests above it; `distance` is how many call hops away each sits. Returns a runnable command per language.", @@ -1570,7 +1814,8 @@ fn mcp_tools() -> Value { json!({}), &[], ), - ]}) + ]; + json!({ "tools": tools }) } fn mcp_initialize(params: &Value) -> Value { @@ -1618,10 +1863,16 @@ fn mcp_initialize(params: &Value) -> Value { of an exact name - check it before changing a signature, `dependencies` \ file-level edges and declared packages, `file` one file's full map including \ the submodules it declares, `notes` TODO/FIXME markers, `refresh` force a \ - rescan. Analysis: `changes` what this branch touched, `test_triggers` the \ + rescan. Analysis: `changes` what this branch touched, `prompts` which \ + request sent to claude or copilot produced each of those changes - and \ + which of them nothing explains, `test_triggers` the \ tests those changes make necessary, `test_targets` where a missing test \ would cost most, `lints` syntax-level findings, `hot` call-graph shape, \ - `services` the service map and the calls crossing it. For a person rather \ + `services` the service map and the calls crossing it, `vulnerabilities` known advisories against the dependencies the lockfiles actually \ + resolve to, runtime ones first, `deps` what this branch did to that dependency tree - \ + what entered it, what left, what moved version and which advisories that introduced. \ + `security` syntax-level security findings in this \ + project's own code - secrets, disabled TLS, injection surfaces. For a person rather \ than an agent: `insights` opens the UI over that same analysis in their \ browser - call it when they ask to *see* the code, not to read about it. \ The analysis tools are \ @@ -1630,7 +1881,7 @@ fn mcp_initialize(params: &Value) -> Value { acting. They page rather than truncate: on `showing 1-40 of 152`, pass \ `offset` for the rest.\n\n\ Results are markdown; the same data is JSON over HTTP (/index, /find, \ - /references, /dependencies, /file, /notes, /insights.json).", + /references, /dependencies, /file, /notes, /insights.json, /deps.json).", }) } @@ -2274,6 +2525,85 @@ fn md_unavailable(what: &str, v: &Value) -> Option { )) } +fn md_prompts(v: &Value, page: Page) -> String { + if let Some(why) = md_unavailable("prompts", v) { + return why; + } + let turns = jarr(v, "turns"); + let files = jarr(v, "changed_files"); + let attributed: Vec<&Value> = files + .iter() + .filter(|f| !jarr(f, "prompted_by").is_empty()) + .collect(); + let unattributed = jarr(v, "unattributed"); + + let mut out = format!( + "# prompts\n{} request(s) against this branch, {} changed file(s) explained, {} not\n", + turns.len(), + attributed.len(), + unattributed.len(), + ); + if turns.is_empty() { + out.push_str( + "no local claude or copilot records name this project. \ + The agent may not have run here, or its history has rotated.\n", + ); + return out; + } + + // requests once, numbered, so the rows below cite rather than repeat them + let slot: BTreeMap = turns + .iter() + .enumerate() + .map(|(i, t)| (jstr(t, "id"), i + 1)) + .collect(); + let body: String = turns + .iter() + .enumerate() + .map(|(i, t)| { + let n = jarr(t, "edits").len(); + format!( + "#{} {} {} ({} edit(s)) {}\n", + i + 1, + jstr(t, "agent"), + jstr(t, "ts"), + n, + jstr(t, "prompt"), + ) + }) + .collect(); + md_section(&mut out, "requests", &body); + + let (window, note) = page.window(&attributed); + let rows: String = window + .iter() + .map(|f| { + let cited: Vec = jarr(f, "prompted_by") + .iter() + .map(|p| { + let span = match &jarr(p, "lines")[..] { + [a, b] => format!("L{a}-{b} "), + _ => String::new(), + }; + let n = slot.get(&jstr(p, "turn")).copied().unwrap_or(0); + format!("{span}[{}] #{n}", jstr(p, "evidence")) + }) + .collect(); + format!("{}: {}\n", jstr(f, "path"), cited.join(", ")) + }) + .collect(); + md_section(&mut out, &format!("attributed changes {note}"), &rows); + + if !unattributed.is_empty() { + let body: String = unattributed + .iter() + .map(|p| format!("{}\n", p.as_str().unwrap_or_default())) + .collect(); + md_section(&mut out, "no request explains", &body); + } + out +} + fn md_changes(v: &Value, page: Page) -> String { if let Some(why) = md_unavailable("changes", v) { return why; @@ -2367,6 +2697,32 @@ fn md_changes(v: &Value, page: Page) -> String { }) .collect(); md_section(&mut out, "unresolved calls", &unresolved); + + // What the branch did to the metric names + let telemetry = v.get("telemetry").cloned().unwrap_or(json!({})); + let metrics: String = jarr(&telemetry, "changes") + .iter() + .map(|m| { + let was = jstr(m, "was"); + let mut line = format!("{} {}", jstr(m, "kind"), jstr(m, "name")); + if !was.is_empty() { + line.push_str(&format!(" (was {was})")); + } + if jbool(m, "breaking") { + line.push_str(" BREAKING"); + } + let detail: Vec = jarr(m, "detail") + .into_iter() + .filter_map(|d| d.as_str().map(str::to_string)) + .collect(); + if !detail.is_empty() { + line.push_str(&format!(" - {}", detail.join(", "))); + } + line.push('\n'); + line + }) + .collect(); + md_section(&mut out, "telemetry", &metrics); out } @@ -2905,6 +3261,33 @@ fn mcp_tool_call(state: &RwLock, params: &Value) -> Result = match name { + "security" => { + let include_tests = args + .get("include_tests") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let floor = match arg("min_severity").as_deref().unwrap_or("low") { + "high" => sast::Severity::High, + "medium" | "moderate" => sast::Severity::Medium, + _ => sast::Severity::Low, + }; + Ok(md_security( + &map.sast_report(include_tests), + floor, + arg("rule").as_deref(), + )) + } + "vulnerabilities" => { + let offline = args + .get("offline") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let with_dev = args.get("dev").and_then(|v| v.as_bool()).unwrap_or(true); + Ok(md_vulnerabilities(&map.audit_report(offline), offline, with_dev)) + } + "deps" => map + .deps_report(arg("base").as_deref()) + .map(|(label, r)| deps::markdown(&r, &label)), "index" => Ok(md_index( &q_index(&map, arg("path").as_deref()), &Page::from(&args, INDEX_DEFAULT_ROWS), @@ -2929,6 +3312,10 @@ fn mcp_tool_call(state: &RwLock, params: &Value) -> Result { + let a = map.analysis(arg("base").as_deref()); + Ok(md_prompts(&a["changes"], Page::from(&args, 25))) + } "test_triggers" => { let a = map.analysis(arg("base").as_deref()); Ok(md_triggers( @@ -3301,9 +3688,11 @@ const ENDPOINTS: &[&str] = &[ "GET /find?q=[&kind=func|const|note]", "GET /references?symbol=", "GET /dependencies[?file=]", + "GET /deps.json[?base=] (what this branch did to the dependency tree)", "GET /file?path=", "GET /notes[?marker=TODO]", "GET /health", + "GET /prompts[?base=] (which claude/copilot request produced each change)", "GET /insights.json[?base=] (the whole analysis payload)", "GET /insights (human UI over the same data; needs --html)", "POST /refresh", @@ -3373,6 +3762,39 @@ fn route(state: &RwLock, method: &str, url: &str, body: &[u8]) -> Repl let map = state.read().expect("map lock poisoned"); ok(q_notes(&map, get("marker"))) } + // the attribution alone, without the rest of the change set + ("GET", "/prompts") => { + let map = state.read().expect("map lock poisoned"); + let a = map.analysis(get("base")); + let c = &a["changes"]; + ok(json!({ + "schema": crate::prompts::SCHEMA, + "root": map.root_label, + "base": c["base"], + "turns": c["turns"], + "changed_files": c["changed_files"] + .as_array() + .map(|fs| fs.iter().filter(|f| { + f["prompted_by"].as_array().is_some_and(|p| !p.is_empty()) + }).cloned().collect::>()) + .unwrap_or_default(), + "unattributed": c["unattributed"], + })) + } + // dependency advisories + ("GET", "/vulnerabilities.json") => { + let map = state.read().expect("map lock poisoned"); + let offline = get("offline").is_some_and(|v| v == "1" || v == "true"); + ok(serde_json::to_value(map.audit_report(offline)).unwrap_or_else(|_| json!({}))) + } + // what this branch did to the dependency tree + ("GET", "/deps.json") => { + let map = state.read().expect("map lock poisoned"); + match map.deps_report(get("base")) { + Ok((_, r)) => ok(serde_json::to_value(&*r).unwrap_or_else(|_| json!({}))), + Err(e) => bad(400, e), + } + } ("GET", "/insights.json") => { let map = state.read().expect("map lock poisoned"); ok((*map.analysis(get("base"))).clone()) @@ -3501,6 +3923,7 @@ pub fn serve(root: &Path, opts: &ServeOptions) -> Result<()> { if opts.html { println!("insights UI: http://{addr}/insights"); } + println!("dependency delta: http://{addr}/deps.json[?base=]"); match opts.watch { Some(interval) => { println!("watching for changes every {}s", interval.as_secs().max(1)); @@ -4697,10 +5120,14 @@ mod tests { "find", "references", "dependencies", + "vulnerabilities", + "deps", + "security", "file", "notes", "refresh", "changes", + "prompts", "test_triggers", "test_targets", "lints", @@ -4940,6 +5367,22 @@ mod tests { assert!(!Arc::ptr_eq(&first, &map.analysis(None))); } + #[test] + fn the_dependency_delta_needs_no_flag() { + let state = RwLock::new(fixture()); + let names = |v: &Value| -> Vec { + v["tools"] + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap().to_string()) + .collect() + }; + assert!(names(&mcp_tools()).contains(&"deps".to_string())); + assert!(names(&mcp_tools()).contains(&"dependencies".to_string())); + assert_ne!(route(&state, "GET", "/deps.json", b"").status, 404); + } + #[test] fn insights_ui_is_opt_in() { let state = RwLock::new(fixture()); diff --git a/src/telemetry.rs b/src/telemetry.rs new file mode 100644 index 0000000..d58a04d --- /dev/null +++ b/src/telemetry.rs @@ -0,0 +1,1663 @@ +//! `ccc changes` telemetry - what this branch did to the OpenTelemetry metrics +//! the source defines. +//! +//! A metric name is a contract with everything downstream of the process: a +//! dashboard, an alert, a recording rule, an SLO. None of that lives in this +//! repository, and none of it moves when the instrument feeding it is renamed, +//! re-based into another unit, or deleted - the panel just goes quiet, and the +//! alert that was watching it never fires again. So the question here is not +//! "did an instrumentation file change", which the change set already answers +//! and which is mostly noise. It is: which metric names entered the tree, which +//! left, and which of the ones that stayed changed the shape of what they emit. +//! +//! Both sides are collected by the same rule, out of a committed tree rather +//! than off disk unless `--worktree` asks otherwise, so the committed view a CI +//! run wants cannot be contaminated by a dirty working copy. A file is parsed +//! only when it references OpenTelemetry, which is both what keeps the pass +//! cheap on a project that has none and what keeps a project's own unrelated +//! `createCounter` out of the report. +//! +//! What this does not do is follow an instrument to the sites that record on +//! it, so the attribute keys a metric carries are not part of the comparison. +//! That needs binding-to-call-site dataflow this pass deliberately does not +//! carry - the same line `sast` draws. A metric whose attributes changed and +//! whose name, instrument, unit and type did not reads here as unchanged. + +use crate::changes::{is_test_fn_name, is_test_path}; +use crate::languages::Language; +use crate::sast::{is_string_kind, rightmost, unquote}; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::Path; +use std::process::Command; +use tree_sitter::{Node, Parser}; + +pub const SCHEMA: &str = "ccc-telemetry/1"; + +// A file earns a parse by naming the API it would be instrumenting with. This +// is the whole cost control of the pass - and it is a correctness rule too, +// because `createCounter` is not a reserved word and a project is entitled to +// its own. +const MARKERS: &[&str] = &["opentelemetry", "System.Diagnostics.Metrics"]; + +// directory names whose contents describe somebody else's instrumentation +const VENDORED: &[&str] = &[ + "node_modules", + "vendor", + "third_party", + "target", + "dist", + "build", + ".git", +]; + +// a fluent chain is the statement, and a statement is not a whole function +const MAX_STATEMENT: usize = 600; +const STATEMENT_DEPTH: usize = 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Instrument { + Counter, + UpDownCounter, + Histogram, + Gauge, + ObservableCounter, + ObservableUpDownCounter, + ObservableGauge, +} + +impl Instrument { + pub fn label(&self) -> &'static str { + match self { + Instrument::Counter => "counter", + Instrument::UpDownCounter => "up-down-counter", + Instrument::Histogram => "histogram", + Instrument::Gauge => "gauge", + Instrument::ObservableCounter => "observable-counter", + Instrument::ObservableUpDownCounter => "observable-up-down-counter", + Instrument::ObservableGauge => "observable-gauge", + } + } +} + +// where an instrument is created +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct Site { + pub file: String, + pub line: usize, + pub function: String, + pub language: String, +} + +// one metric, as one side of the branch defines it +#[derive(Debug, Clone, Serialize)] +pub struct Metric { + pub name: String, + pub instrument: Instrument, + // as the API spells it - `u64`, `Int64`, the C# generic argument - and + // empty where the binding does not encode a type at all + pub value_type: String, + pub unit: String, + pub description: String, + // every place this name is created, lowest file and line first + pub sites: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum MetricChangeKind { + Added, + Removed, + Renamed, + InstrumentChanged, + TypeChanged, + UnitChanged, + DescriptionChanged, +} + +impl MetricChangeKind { + pub fn label(&self) -> &'static str { + match self { + MetricChangeKind::Added => "added", + MetricChangeKind::Removed => "removed", + MetricChangeKind::Renamed => "renamed", + MetricChangeKind::InstrumentChanged => "instrument-changed", + MetricChangeKind::TypeChanged => "type-changed", + MetricChangeKind::UnitChanged => "unit-changed", + MetricChangeKind::DescriptionChanged => "description-changed", + } + } + + // the marker the text report draws it with + fn marker(&self) -> char { + match self { + MetricChangeKind::Added => '+', + MetricChangeKind::Removed => '-', + _ => '~', + } + } + + // Whether a query written against the base still returns the same series. + // A new metric breaks nothing; every other kind here either takes a name + // away or silently changes what the numbers under it mean. A reworded + // description is the one property nothing downstream is keyed on. + fn breaking(&self) -> bool { + !matches!( + self, + MetricChangeKind::Added | MetricChangeKind::DescriptionChanged + ) + } + + // report order: what a reader acts on first + fn rank(&self) -> u8 { + match self { + MetricChangeKind::Removed => 0, + MetricChangeKind::Renamed => 1, + MetricChangeKind::InstrumentChanged => 2, + MetricChangeKind::TypeChanged => 3, + MetricChangeKind::UnitChanged => 4, + MetricChangeKind::Added => 5, + MetricChangeKind::DescriptionChanged => 6, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct MetricChange { + // The most severe property that moved. A metric can change its unit and its + // description in one commit; `detail` carries every clause, and this names + // the one worth reacting to. + pub kind: MetricChangeKind, + pub name: String, + // the name at the base, set only on a rename + #[serde(skip_serializing_if = "String::is_empty")] + pub was: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub from: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option, + // one clause per property that moved, in the report's own words + pub detail: Vec, + // a query written against the base does not survive this change + pub breaking: bool, +} + +#[derive(Debug, Clone, Copy, Default, Serialize)] +pub struct TelemetryCounts { + // metrics the head side defines, which is the surface being emitted now + pub metrics: usize, + pub added: usize, + pub removed: usize, + pub renamed: usize, + // metrics that kept their name and changed a property + pub modified: usize, + pub breaking: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TelemetryReport { + pub schema: &'static str, + pub base_sha: String, + pub head_sha: String, + // neither side creates an instrument, so everything below is empty and the + // project is simply not instrumented with OpenTelemetry + pub instrumented: bool, + pub changes: Vec, + // the whole head-side surface, not just what moved + pub metrics: Vec, + // set when a side could not be read; the rest of the report is then empty + // rather than wrong, because half a collection reads as deletions + pub error: Option, + pub counts: TelemetryCounts, +} + +impl TelemetryReport { + fn empty(base_sha: &str, head_sha: &str, error: Option) -> TelemetryReport { + TelemetryReport { + schema: SCHEMA, + base_sha: base_sha.to_string(), + head_sha: head_sha.to_string(), + instrumented: false, + changes: Vec::new(), + metrics: Vec::new(), + error, + counts: TelemetryCounts::default(), + } + } + + // whether a gate should fail: a query written against the base broke, or a + // side could not be read - "we could not look" is not "nothing moved" + pub fn gates(&self) -> bool { + self.counts.breaking > 0 || self.error.is_some() + } +} + +pub struct TelemetryOptions<'a> { + pub base_sha: &'a str, + pub head_sha: &'a str, + // the head side is the working tree rather than the committed head + pub worktree: bool, +} + +// --------------------------------------------------------------------------- +// the instruments +// --------------------------------------------------------------------------- + +// The instrument constructors the OpenTelemetry metric APIs expose, keyed on +// the method name exactly as each binding spells it, with the value type that +// name encodes. Matched whole rather than by prefix or suffix: `Int64Counter` +// is a substring of `Int64UpDownCounter` at one end and of nothing at the +// other, and a loose rule folds the two together. +const INSTRUMENTS: &[(&str, Instrument, &str)] = &[ + // rust - opentelemetry::metrics::Meter + ("u64_counter", Instrument::Counter, "u64"), + ("f64_counter", Instrument::Counter, "f64"), + ("u64_observable_counter", Instrument::ObservableCounter, "u64"), + ("f64_observable_counter", Instrument::ObservableCounter, "f64"), + ("i64_up_down_counter", Instrument::UpDownCounter, "i64"), + ("f64_up_down_counter", Instrument::UpDownCounter, "f64"), + ( + "i64_observable_up_down_counter", + Instrument::ObservableUpDownCounter, + "i64", + ), + ( + "f64_observable_up_down_counter", + Instrument::ObservableUpDownCounter, + "f64", + ), + ("u64_histogram", Instrument::Histogram, "u64"), + ("f64_histogram", Instrument::Histogram, "f64"), + ("u64_gauge", Instrument::Gauge, "u64"), + ("i64_gauge", Instrument::Gauge, "i64"), + ("f64_gauge", Instrument::Gauge, "f64"), + ("u64_observable_gauge", Instrument::ObservableGauge, "u64"), + ("i64_observable_gauge", Instrument::ObservableGauge, "i64"), + ("f64_observable_gauge", Instrument::ObservableGauge, "f64"), + // go - go.opentelemetry.io/otel/metric + ("Int64Counter", Instrument::Counter, "int64"), + ("Float64Counter", Instrument::Counter, "float64"), + ("Int64UpDownCounter", Instrument::UpDownCounter, "int64"), + ("Float64UpDownCounter", Instrument::UpDownCounter, "float64"), + ("Int64Histogram", Instrument::Histogram, "int64"), + ("Float64Histogram", Instrument::Histogram, "float64"), + ("Int64Gauge", Instrument::Gauge, "int64"), + ("Float64Gauge", Instrument::Gauge, "float64"), + ("Int64ObservableCounter", Instrument::ObservableCounter, "int64"), + ( + "Float64ObservableCounter", + Instrument::ObservableCounter, + "float64", + ), + ( + "Int64ObservableUpDownCounter", + Instrument::ObservableUpDownCounter, + "int64", + ), + ( + "Float64ObservableUpDownCounter", + Instrument::ObservableUpDownCounter, + "float64", + ), + ("Int64ObservableGauge", Instrument::ObservableGauge, "int64"), + ("Float64ObservableGauge", Instrument::ObservableGauge, "float64"), + // python - opentelemetry.metrics.Meter + ("create_counter", Instrument::Counter, ""), + ("create_up_down_counter", Instrument::UpDownCounter, ""), + ("create_histogram", Instrument::Histogram, ""), + ("create_gauge", Instrument::Gauge, ""), + ("create_observable_counter", Instrument::ObservableCounter, ""), + ( + "create_observable_up_down_counter", + Instrument::ObservableUpDownCounter, + "", + ), + ("create_observable_gauge", Instrument::ObservableGauge, ""), + // javascript / typescript - @opentelemetry/api + ("createCounter", Instrument::Counter, ""), + ("createUpDownCounter", Instrument::UpDownCounter, ""), + ("createHistogram", Instrument::Histogram, ""), + ("createGauge", Instrument::Gauge, ""), + ("createObservableCounter", Instrument::ObservableCounter, ""), + ( + "createObservableUpDownCounter", + Instrument::ObservableUpDownCounter, + "", + ), + ("createObservableGauge", Instrument::ObservableGauge, ""), + // c# - System.Diagnostics.Metrics.Meter, which is what OpenTelemetry .NET + // collects from. The value type is a generic argument, read separately + ("CreateCounter", Instrument::Counter, ""), + ("CreateUpDownCounter", Instrument::UpDownCounter, ""), + ("CreateHistogram", Instrument::Histogram, ""), + ("CreateGauge", Instrument::Gauge, ""), + ("CreateObservableCounter", Instrument::ObservableCounter, ""), + ( + "CreateObservableUpDownCounter", + Instrument::ObservableUpDownCounter, + "", + ), + ("CreateObservableGauge", Instrument::ObservableGauge, ""), + // c++ - opentelemetry::metrics::Meter, where the type is in the name + ("CreateUInt64Counter", Instrument::Counter, "uint64"), + ("CreateDoubleCounter", Instrument::Counter, "double"), + ("CreateInt64UpDownCounter", Instrument::UpDownCounter, "int64"), + ("CreateDoubleUpDownCounter", Instrument::UpDownCounter, "double"), + ("CreateUInt64Histogram", Instrument::Histogram, "uint64"), + ("CreateDoubleHistogram", Instrument::Histogram, "double"), + ("CreateInt64Gauge", Instrument::Gauge, "int64"), + ("CreateDoubleGauge", Instrument::Gauge, "double"), + ( + "CreateInt64ObservableCounter", + Instrument::ObservableCounter, + "int64", + ), + ( + "CreateDoubleObservableCounter", + Instrument::ObservableCounter, + "double", + ), + ( + "CreateInt64ObservableUpDownCounter", + Instrument::ObservableUpDownCounter, + "int64", + ), + ( + "CreateDoubleObservableUpDownCounter", + Instrument::ObservableUpDownCounter, + "double", + ), + ( + "CreateInt64ObservableGauge", + Instrument::ObservableGauge, + "int64", + ), + ( + "CreateDoubleObservableGauge", + Instrument::ObservableGauge, + "double", + ), +]; + +fn instrument_of(method: &str) -> Option<(Instrument, &'static str)> { + INSTRUMENTS + .iter() + .find(|(m, _, _)| *m == method) + .map(|(_, i, t)| (*i, *t)) +} + +// The two bindings that take the unit and the description by position, and +// disagree about the order. Everywhere else they are named, so nothing is read +// out of position there. +fn positional(lang: Language) -> Option<(usize, usize)> { + match lang { + // CreateCounter(name, unit, description) + Language::CSharp => Some((1, 2)), + // CreateUInt64Counter(name, description, unit) + Language::Cpp | Language::C => Some((2, 1)), + _ => None, + } +} + +pub fn analyse(root: &Path, opts: &TelemetryOptions) -> TelemetryReport { + let prefix = git_prefix(root); + let base = match collect(root, Side::Commit(opts.base_sha), &prefix) { + Ok(m) => m, + Err(e) => return TelemetryReport::empty(opts.base_sha, opts.head_sha, Some(e)), + }; + let head_side = if opts.worktree { + Side::Worktree + } else { + Side::Commit(opts.head_sha) + }; + let head = match collect(root, head_side, &prefix) { + Ok(m) => m, + Err(e) => return TelemetryReport::empty(opts.base_sha, opts.head_sha, Some(e)), + }; + + let changes = diff(&base, &head); + let mut counts = TelemetryCounts { + metrics: head.len(), + ..TelemetryCounts::default() + }; + for c in &changes { + match c.kind { + MetricChangeKind::Added => counts.added += 1, + MetricChangeKind::Removed => counts.removed += 1, + MetricChangeKind::Renamed => counts.renamed += 1, + _ => counts.modified += 1, + } + if c.breaking { + counts.breaking += 1; + } + } + + TelemetryReport { + schema: SCHEMA, + base_sha: opts.base_sha.to_string(), + head_sha: opts.head_sha.to_string(), + instrumented: !base.is_empty() || !head.is_empty(), + changes, + metrics: head.into_values().collect(), + error: None, + counts, + } +} + +// one side of the branch, and where it reads its source from +#[derive(Clone, Copy)] +enum Side<'a> { + // the working tree, uncommitted edits and untracked files included + Worktree, + // a committed tree, read with `git show :` + Commit(&'a str), +} + +// Every metric one side defines, keyed by name. Both sides go through this same +// function - a difference in how the two are collected would show up as a +// change the branch never made. +fn collect(root: &Path, side: Side, prefix: &str) -> Result, String> { + let mut args: Vec<&str> = vec!["grep", "-l", "-z", "-I", "-i", "--fixed-strings"]; + if matches!(side, Side::Worktree) { + args.push("--untracked"); + } + for m in MARKERS { + args.push("-e"); + args.push(m); + } + if let Side::Commit(sha) = side { + args.push(sha); + } + args.push("--"); + + // searching a tree prints `:`; searching the working tree prints + // the path alone. Either way the path is relative to `root`, because git + // resolves it against the directory it was run in + let strip = match side { + Side::Commit(sha) => format!("{sha}:"), + Side::Worktree => String::new(), + }; + let mut candidates: Vec = git_grep(root, &args)? + .into_iter() + .map(|line| line.strip_prefix(&strip).unwrap_or(&line).to_string()) + .filter(|rel| !rel.split('/').any(|seg| VENDORED.contains(&seg))) + // a metric defined by a test is a fixture, not something a dashboard reads + .filter(|rel| !is_test_path(rel)) + .collect(); + candidates.sort(); + candidates.dedup(); + + let mut out: BTreeMap = BTreeMap::new(); + for rel in &candidates { + let Some(lang) = Language::from_path(Path::new(rel)) else { + continue; + }; + let src = match side { + Side::Worktree => std::fs::read_to_string(root.join(rel)).ok(), + Side::Commit(sha) => crate::audit::git_out(root, &["show", &format!("{sha}:{prefix}{rel}")]), + }; + let Some(src) = src else { + continue; + }; + for m in collect_file(lang, rel, &src) { + merge(&mut out, m); + } + } + Ok(out) +} + +// The same name created in two places is one metric with two definition sites, +// not two metrics. The facts come from the site that sorts first, so the two +// sides of a branch cannot disagree about which one spoke. +fn merge(out: &mut BTreeMap, m: Metric) { + match out.get_mut(&m.name) { + Some(existing) => { + existing.sites.extend(m.sites); + existing.sites.sort(); + existing.sites.dedup(); + } + None => { + out.insert(m.name.clone(), m); + } + } +} + +fn collect_file(lang: Language, rel: &str, src: &str) -> Vec { + let mut parser = Parser::new(); + if parser.set_language(&lang.ts_language()).is_err() { + return Vec::new(); + } + let Some(tree) = parser.parse(src, None) else { + return Vec::new(); + }; + let mut out = Vec::new(); + walk(tree.root_node(), src, lang, rel, "", false, &mut out); + out +} + +fn walk( + node: Node, + src: &str, + lang: Language, + rel: &str, + function: &str, + in_test: bool, + out: &mut Vec, +) { + let kind = node.kind(); + + // entering a function renames the scope every site below it reports + let mut scope = function.to_string(); + let mut test_scope = in_test; + if lang.func_kinds().contains(&kind) { + if let Some(name) = node.child_by_field_name("name").and_then(|n| text_of(n, src)) { + test_scope = test_scope || is_test_fn_name(&name); + scope = name; + } + } + if lang.module_kinds().contains(&kind) { + if let Some(name) = node.child_by_field_name("name").and_then(|n| text_of(n, src)) { + let n = name.to_ascii_lowercase(); + test_scope = test_scope || n == "tests" || n == "test"; + } + } + + if !test_scope && lang.call_kinds().contains(&kind) { + if let Some(m) = creation(node, src, lang, rel, &scope) { + out.push(m); + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk(child, src, lang, rel, &scope, test_scope, out); + } +} + +// One call node, if it creates an instrument. The metric's name is its first +// literal argument: every binding takes it there, and a name assembled at +// runtime is not a name this pass can report, so it is skipped rather than +// guessed at. +fn creation(node: Node, src: &str, lang: Language, rel: &str, scope: &str) -> Option { + let callee = node + .child_by_field_name("function") + .and_then(|n| text_of(n, src)) + .or_else(|| node.child(0).and_then(|n| text_of(n, src))) + .unwrap_or_default(); + let method = rightmost(&callee); + // `CreateCounter` is the method plus the value type it was asked for + let (method, generic) = match method.split_once('<') { + Some((m, g)) => (m, g.trim_end_matches('>').trim().to_string()), + None => (method, String::new()), + }; + let (instrument, typed) = instrument_of(method)?; + + let args = string_args(node, src); + let name = args.iter().flatten().next()?.clone(); + if name.is_empty() { + return None; + } + + let stmt = statement_of(node, src, lang); + let mut unit = scrape(&stmt, UNIT_KEYS); + let mut description = scrape(&stmt, DESCRIPTION_KEYS); + if let Some((u, d)) = positional(lang) { + if unit.is_empty() { + unit = args.get(u).cloned().flatten().unwrap_or_default(); + } + if description.is_empty() { + description = args.get(d).cloned().flatten().unwrap_or_default(); + } + } + + Some(Metric { + name, + instrument, + value_type: if typed.is_empty() { + generic + } else { + typed.to_string() + }, + unit, + description, + sites: vec![Site { + file: rel.to_string(), + line: node.start_position().row + 1, + function: scope.to_string(), + language: lang.as_str().to_string(), + }], + }) +} + +// The call's arguments in order, each one the literal it is or None for +// anything else, so a position can be read without losing count of it. +fn string_args(node: Node, src: &str) -> Vec> { + let mut find = node.walk(); + let list = node.child_by_field_name("arguments").or_else(|| { + node.children(&mut find) + .find(|c| c.kind().contains("argument")) + }); + let Some(list) = list else { + return Vec::new(); + }; + let mut cursor = list.walk(); + list.named_children(&mut cursor).map(|c| literal_of(c, src)).collect() +} + +// The literal an argument is, if it is one. C# wraps every argument in an +// `argument` node and other grammars parenthesise, so a wrapper carrying one +// child is stepped through - but only a wrapper. A node with two children is a +// named argument or a call, and descending into it would read a value out of a +// position it was never in. +fn literal_of(node: Node, src: &str) -> Option { + let mut cur = node; + for _ in 0..2 { + if is_string_kind(cur.kind()) { + return Some(unquote(&text_of(cur, src)?)); + } + if cur.named_child_count() != 1 { + return None; + } + cur = cur.named_child(0)?; + } + None +} + +// The statement a creation sits in, which is where a fluent API leaves the unit +// and the description: `meter.f64_histogram("x").with_unit("ms").build()`. Walk +// up through the expression nodes the chain is made of, and stop before the +// block or the function that would swallow the whole body. +fn statement_of(node: Node, src: &str, lang: Language) -> String { + let mut best = node; + let mut cur = node; + for _ in 0..STATEMENT_DEPTH { + let Some(parent) = cur.parent() else { break }; + let k = parent.kind(); + if is_body_kind(k) || lang.func_kinds().contains(&k) { + break; + } + best = parent; + cur = parent; + if k.ends_with("_statement") || k.ends_with("_declaration") { + break; + } + } + truncate(&text_of(best, src).unwrap_or_default(), MAX_STATEMENT) +} + +fn is_body_kind(kind: &str) -> bool { + matches!( + kind, + "block" + | "statement_block" + | "compound_statement" + | "declaration_list" + | "field_declaration_list" + | "source_file" + | "program" + | "module" + | "translation_unit" + ) +} + +// Every binding puts the unit and the description behind a different spelling - +// a fluent `.with_unit("ms")`, a Go option `metric.WithUnit("ms")`, a Python +// keyword `unit="ms"`, a JS object key. All of them put the value in a literal +// immediately after the word, so this finds the word and reads the next +// literal, rather than teaching the pass six argument grammars. +const UNIT_KEYS: &[&str] = &[ + "with_unit(", + "withunit(", + "setunit(", + "unit=", + "unit =", + "unit:", + "unit :", + "\"unit\"", + "'unit'", +]; + +const DESCRIPTION_KEYS: &[&str] = &[ + "with_description(", + "withdescription(", + "setdescription(", + "description=", + "description =", + "description:", + "description :", + "\"description\"", + "'description'", +]; + +fn scrape(stmt: &str, keys: &[&str]) -> String { + let low = stmt.to_ascii_lowercase(); + let at = keys + .iter() + .filter_map(|k| find_word(&low, k).map(|i| i + k.len())) + .min(); + let Some(at) = at else { + return String::new(); + }; + // The value has to be the very next token. A unit passed as a constant - + // `.with_unit(MILLIS)` - leaves the next literal in the chain belonging to + // something else entirely, and reporting that as the unit is worse than + // reporting no unit at all. + let mut rest = stmt[at..].trim_start(); + while let Some(t) = rest.strip_prefix([':', '=']) { + rest = t.trim_start(); + } + let mut chars = rest.chars(); + let Some(quote) = chars.next().filter(|c| matches!(c, '"' | '\'' | '`')) else { + return String::new(); + }; + let body = chars.as_str(); + match body.find(quote) { + Some(end) => body[..end].to_string(), + None => String::new(), + } +} + +// `unit=` must not match inside `runit=`, and `description:` must not be found +// by way of a longer word ending in it +fn find_word(hay: &str, needle: &str) -> Option { + let mut from = 0; + while let Some(i) = hay[from..].find(needle) { + let at = from + i; + let before = hay[..at].chars().next_back(); + if !before.is_some_and(|c| c.is_alphanumeric() || c == '_') { + return Some(at); + } + from = at + 1; + } + None +} + +fn text_of(node: Node, src: &str) -> Option { + src.get(node.byte_range()).map(|s| s.to_string()) +} + +fn truncate(s: &str, max: usize) -> String { + let one_line = s.replace(['\n', '\r'], " "); + if one_line.len() <= max { + return one_line; + } + one_line.chars().take(max).collect() +} + +fn diff(base: &BTreeMap, head: &BTreeMap) -> Vec { + let mut changes = Vec::new(); + let mut added: Vec<&Metric> = Vec::new(); + let mut removed: Vec<&Metric> = Vec::new(); + + for (name, h) in head { + match base.get(name) { + None => added.push(h), + Some(b) => { + let moved = compare(b, h); + if moved.is_empty() { + continue; + } + let kind = moved + .iter() + .map(|(k, _)| *k) + .min_by_key(|k| k.rank()) + .unwrap_or(MetricChangeKind::DescriptionChanged); + changes.push(MetricChange { + kind, + name: name.clone(), + was: String::new(), + from: Some(b.clone()), + to: Some(h.clone()), + detail: moved.into_iter().map(|(_, d)| d).collect(), + breaking: kind.breaking(), + }); + } + } + } + for (name, b) in base { + if !head.contains_key(name) { + removed.push(b); + } + } + + changes.extend(pair_renames(&mut added, &mut removed)); + for h in added { + changes.push(MetricChange { + kind: MetricChangeKind::Added, + name: h.name.clone(), + was: String::new(), + from: None, + to: Some(h.clone()), + detail: Vec::new(), + breaking: false, + }); + } + for b in removed { + changes.push(MetricChange { + kind: MetricChangeKind::Removed, + name: b.name.clone(), + was: String::new(), + from: Some(b.clone()), + to: None, + detail: Vec::new(), + breaking: true, + }); + } + + changes.sort_by(|a, b| { + b.breaking + .cmp(&a.breaking) + .then_with(|| a.kind.rank().cmp(&b.kind.rank())) + .then_with(|| a.name.cmp(&b.name)) + }); + changes +} + +// What moved between two definitions of the same name, one clause per property. +fn compare(b: &Metric, h: &Metric) -> Vec<(MetricChangeKind, String)> { + let mut out = Vec::new(); + if b.instrument != h.instrument { + out.push(( + MetricChangeKind::InstrumentChanged, + format!( + "instrument {} -> {}", + b.instrument.label(), + h.instrument.label() + ), + )); + } + if b.value_type != h.value_type { + out.push(( + MetricChangeKind::TypeChanged, + format!("type {} -> {}", shown(&b.value_type), shown(&h.value_type)), + )); + } + if b.unit != h.unit { + out.push(( + MetricChangeKind::UnitChanged, + format!("unit {} -> {}", shown(&b.unit), shown(&h.unit)), + )); + } + if b.description != h.description { + out.push(( + MetricChangeKind::DescriptionChanged, + "description reworded".to_string(), + )); + } + out +} + +fn shown(s: &str) -> &str { + if s.is_empty() { + "(none)" + } else { + s + } +} + +// A rename is a removal and an addition that are the same instrument created in +// the same function +fn pair_renames<'a>( + added: &mut Vec<&'a Metric>, + removed: &mut Vec<&'a Metric>, +) -> Vec { + type Key = (String, String, Instrument, String); + fn key(m: &Metric) -> Option { + let site = m.sites.first()?; + Some(( + site.file.clone(), + site.function.clone(), + m.instrument, + m.value_type.clone(), + )) + } + + let mut groups: BTreeMap, Vec<&Metric>)> = BTreeMap::new(); + for m in added.iter() { + if let Some(k) = key(m) { + groups.entry(k).or_default().0.push(m); + } + } + for m in removed.iter() { + if let Some(k) = key(m) { + groups.entry(k).or_default().1.push(m); + } + } + + let mut out = Vec::new(); + let mut paired: Vec<(String, String)> = Vec::new(); + for (ins, del) in groups.into_values() { + let ([h], [b]) = (&ins[..], &del[..]) else { + continue; + }; + let mut detail = vec![format!("name {} -> {}", b.name, h.name)]; + detail.extend(compare(b, h).into_iter().map(|(_, d)| d)); + out.push(MetricChange { + kind: MetricChangeKind::Renamed, + name: h.name.clone(), + was: b.name.clone(), + from: Some((*b).clone()), + to: Some((*h).clone()), + detail, + breaking: true, + }); + paired.push((h.name.clone(), b.name.clone())); + } + added.retain(|m| !paired.iter().any(|(a, _)| a == &m.name)); + removed.retain(|m| !paired.iter().any(|(_, r)| r == &m.name)); + out +} + +fn git_prefix(root: &Path) -> String { + crate::audit::git_out(root, &["rev-parse", "--show-prefix"]) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +// `git grep` says "nothing matched" with exit 1, which is not a failure +fn git_grep(root: &Path, args: &[&str]) -> Result, String> { + let out = Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .output() + .map_err(|e| format!("running git grep: {e}"))?; + if !matches!(out.status.code(), Some(0) | Some(1)) { + let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); + return Err(if err.is_empty() { + "git grep failed".to_string() + } else { + err + }); + } + Ok(String::from_utf8_lossy(&out.stdout) + .split('\0') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect()) +} + +fn headline(r: &TelemetryReport, base: &str) -> String { + if !r.instrumented { + return "telemetry: no OpenTelemetry instrument is created in this project".to_string(); + } + if r.changes.is_empty() { + return format!( + "telemetry: {} metric(s), unchanged against {base}", + r.counts.metrics + ); + } + let mut parts = Vec::new(); + for (n, label) in [ + (r.counts.added, "added"), + (r.counts.removed, "removed"), + (r.counts.renamed, "renamed"), + (r.counts.modified, "modified"), + ] { + if n > 0 { + parts.push(format!("{n} {label}")); + } + } + format!( + "telemetry: {} metric(s), {} changed ({}) against {base}", + r.counts.metrics, + r.changes.len(), + parts.join(", ") + ) +} + +// where a change happened, head side first because that is where the fix goes +fn site_of(c: &MetricChange) -> String { + let m = c.to.as_ref().or(c.from.as_ref()); + match m.and_then(|m| m.sites.first()) { + Some(s) => format!("{}:{}", s.file, s.line), + None => String::new(), + } +} + +fn instrument_of_change(c: &MetricChange) -> &'static str { + match c.to.as_ref().or(c.from.as_ref()) { + Some(m) => m.instrument.label(), + None => "", + } +} + +// the `changes` text report's voice, appended to it +pub fn text(r: &TelemetryReport, base: &str) -> String { + use std::fmt::Write; + let mut out = String::new(); + let _ = writeln!(out, "{}", headline(r, base)); + if let Some(err) = &r.error { + // an unread side would read as a wholesale deletion, so nothing is + // reported rather than something wrong + let _ = writeln!(out, " not collected - {err}"); + return out; + } + if r.changes.is_empty() { + return out; + } + let width = r + .changes + .iter() + .map(|c| c.name.chars().count()) + .max() + .unwrap_or(0) + .min(48); + let iw = r + .changes + .iter() + .map(|c| instrument_of_change(c).len()) + .max() + .unwrap_or(0); + for c in &r.changes { + let detail = if c.detail.is_empty() { + String::new() + } else { + format!(" {}", c.detail.join(", ")) + }; + let _ = writeln!( + out, + " {} {:width$} {:iw$} {}{detail}", + c.kind.marker(), + c.name, + instrument_of_change(c), + site_of(c) + ); + } + if r.counts.breaking > 0 { + let _ = writeln!( + out, + "\n{} change(s) break a query written against {base} - a dashboard or an alert on \ + those names goes quiet rather than failing", + r.counts.breaking + ); + } + out +} + +// the same answer for an agent, in the tone `deps --markdown` uses +pub fn markdown(r: &TelemetryReport, base: &str) -> String { + use std::fmt::Write; + let mut out = String::new(); + let _ = writeln!(out, "# {}", headline(r, base)); + let _ = writeln!( + out, + "\nbase {} -> head {}", + short(&r.base_sha), + short(&r.head_sha) + ); + if let Some(err) = &r.error { + let _ = writeln!(out, "\nnot collected - {err}\n"); + return out; + } + if !r.instrumented { + out.push_str( + "\nNo file in this project creates an OpenTelemetry instrument, so there is no metric \ + surface to compare. A file is only read when it names the API, so an instrumentation \ + layer this pass cannot see is one that does not mention OpenTelemetry.\n", + ); + return out; + } + if r.changes.is_empty() { + out.push_str( + "\nEvery metric this project defines carries the same name, instrument, type and unit \ + it did at the base. Attribute keys are not part of that comparison.\n", + ); + return out; + } + + let _ = write!(out, "\n## changes ({})\n", r.changes.len()); + for c in &r.changes { + let detail = if c.detail.is_empty() { + String::new() + } else { + format!(" - {}", c.detail.join(", ")) + }; + let _ = writeln!( + out, + "- {} `{}` ({}){detail} - {}", + c.kind.label(), + c.name, + instrument_of_change(c), + site_of(c) + ); + } + + if r.counts.breaking == 0 { + out.push_str("\nNothing here breaks a query written against the base.\n"); + } else { + let _ = write!( + out, + "\n## breaking ({})\n\nA dashboard, alert or recording rule written against the base \ + will go quiet on these rather than fail loudly:\n", + r.counts.breaking + ); + for c in r.changes.iter().filter(|c| c.breaking) { + let was = if c.was.is_empty() { + String::new() + } else { + format!(" (was `{}`)", c.was) + }; + let _ = writeln!(out, "- `{}`{was} - {}", c.name, c.kind.label()); + } + } + out.push_str( + "\nThis compares metric names, instruments, value types, units and descriptions. It does \ + not follow an instrument to the sites that record on it, so a change to the attribute \ + keys a metric carries is not reported here.\n", + ); + out +} + +fn short(sha: &str) -> &str { + &sha[..sha.len().min(9)] +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + use std::fs; + + fn one(lang: Language, rel: &str, src: &str) -> Metric { + let got = collect_file(lang, rel, src); + assert_eq!(got.len(), 1, "{rel}: expected one metric, got {got:?}"); + got.into_iter().next().expect("checked above") + } + + fn facts(m: &Metric) -> (String, &'static str, String, String, String) { + ( + m.name.clone(), + m.instrument.label(), + m.value_type.clone(), + m.unit.clone(), + m.description.clone(), + ) + } + + // The point of the pass: one metric expressed in six bindings is one metric, + // and a monorepo that instruments a Go service and a TypeScript front end + // gets one comparable answer rather than two shapes. + #[test] + fn every_binding_reads_the_same_metric_the_same_way() { + let rust = one( + Language::Rust, + "src/m.rs", + "fn go() {\n let c = meter.u64_counter(\"http.server.requests\")\n\ + .with_unit(\"1\").with_description(\"requests served\").build();\n}\n", + ); + assert_eq!( + facts(&rust), + ( + "http.server.requests".into(), + "counter", + "u64".into(), + "1".into(), + "requests served".into() + ) + ); + + let go = one( + Language::Go, + "src/m.go", + "package m\nfunc go() {\n c, _ := meter.Int64Counter(\"http.server.requests\", \ + metric.WithUnit(\"1\"), metric.WithDescription(\"requests served\"))\n}\n", + ); + assert_eq!( + facts(&go), + ( + "http.server.requests".into(), + "counter", + "int64".into(), + "1".into(), + "requests served".into() + ) + ); + + let py = one( + Language::Python, + "src/m.py", + "c = meter.create_counter(\"http.server.requests\", unit=\"1\", \ + description=\"requests served\")\n", + ); + assert_eq!( + facts(&py), + ( + "http.server.requests".into(), + "counter", + // python names no type, and a type this pass cannot see is not + // one it invents + String::new(), + "1".into(), + "requests served".into() + ) + ); + + let ts = one( + Language::TypeScript, + "src/m.ts", + "const c = meter.createCounter('http.server.requests', \ + { description: 'requests served', unit: '1' });\n", + ); + assert_eq!( + facts(&ts), + ( + "http.server.requests".into(), + "counter", + String::new(), + "1".into(), + "requests served".into() + ) + ); + } + + // The two bindings that take these by position disagree about the order, so + // reading one with the other's rule silently swaps a unit for a sentence. + #[test] + fn the_positional_bindings_disagree_about_order_and_both_are_honoured() { + let cs = one( + Language::CSharp, + "src/M.cs", + "class M { static readonly Counter C = \ + Meter.CreateCounter(\"http.server.requests\", \"1\", \"requests served\"); }\n", + ); + assert_eq!( + facts(&cs), + ( + "http.server.requests".into(), + "counter", + // the generic argument is the value type C# does not put in the name + "long".into(), + "1".into(), + "requests served".into() + ) + ); + + let cpp = one( + Language::Cpp, + "src/m.cc", + "void go() { auto c = meter->CreateUInt64Counter(\"http.server.requests\", \ + \"requests served\", \"1\"); }\n", + ); + assert_eq!( + facts(&cpp), + ( + "http.server.requests".into(), + "counter", + "uint64".into(), + "1".into(), + "requests served".into() + ) + ); + } + + #[test] + fn an_instrument_is_matched_whole_rather_than_by_the_word_it_ends_in() { + let m = one( + Language::Go, + "src/m.go", + "package m\nfunc go() { q, _ := meter.Int64UpDownCounter(\"queue.depth\") }\n", + ); + assert_eq!(m.instrument, Instrument::UpDownCounter); + assert_eq!(m.value_type, "int64"); + + // and a method that merely reads like one is not an instrument + assert!(collect_file( + Language::Rust, + "src/m.rs", + "fn go() { let c = meter.counter(\"nope\"); }\n", + ) + .is_empty()); + } + + #[test] + fn a_name_assembled_at_runtime_is_skipped_rather_than_guessed_at() { + let got = collect_file( + Language::Rust, + "src/m.rs", + "fn go() {\n let a = meter.u64_counter(name).build();\n\ + \x20 let b = meter.u64_counter(format!(\"{prefix}.hits\")).build();\n}\n", + ); + assert!(got.is_empty(), "got {got:?}"); + } + + // A unit given as a constant leaves the next literal in the chain belonging + // to something else entirely, and reporting that as the unit is worse than + // reporting no unit at all. + #[test] + fn a_unit_that_is_not_a_literal_is_left_empty() { + let m = one( + Language::Rust, + "src/m.rs", + "fn go() { let c = meter.f64_histogram(\"latency\").with_unit(MILLIS)\ + .with_description(\"how long\").build(); }\n", + ); + assert_eq!(m.unit, ""); + assert_eq!(m.description, "how long"); + } + + #[test] + fn a_metric_a_test_creates_is_a_fixture_not_a_surface() { + assert!(collect_file( + Language::Rust, + "src/m.rs", + "#[test]\nfn test_charges() { let c = meter.u64_counter(\"fixture.hits\").build(); }\n", + ) + .is_empty()); + assert!(collect_file( + Language::Rust, + "src/m.rs", + "mod tests {\n fn helper() { let c = meter.u64_counter(\"fixture.hits\").build(); }\n}\n", + ) + .is_empty()); + } + + fn metric(name: &str, ins: Instrument, ty: &str, unit: &str, file: &str, func: &str) -> Metric { + Metric { + name: name.into(), + instrument: ins, + value_type: ty.into(), + unit: unit.into(), + description: String::new(), + sites: vec![Site { + file: file.into(), + line: 1, + function: func.into(), + language: "rust".into(), + }], + } + } + + fn side(ms: &[Metric]) -> BTreeMap { + ms.iter().map(|m| (m.name.clone(), m.clone())).collect() + } + + fn kinds(base: &[Metric], head: &[Metric]) -> BTreeMap { + diff(&side(base), &side(head)) + .into_iter() + .map(|c| (c.name.clone(), c.kind)) + .collect() + } + + #[test] + fn every_row_of_the_change_table() { + use Instrument::{Counter, Histogram}; + let base = vec![ + metric("gone", Counter, "u64", "1", "a.rs", "a"), + metric("reshaped", Counter, "u64", "1", "b.rs", "b"), + metric("retyped", Counter, "u64", "1", "c.rs", "c"), + metric("rescaled", Histogram, "f64", "ms", "d.rs", "d"), + metric("still", Counter, "u64", "1", "e.rs", "e"), + ]; + let mut described = metric("described", Counter, "u64", "1", "f.rs", "f"); + described.description = "before".into(); + let head = vec![ + metric("arrived", Counter, "u64", "1", "z.rs", "z"), + metric("reshaped", Histogram, "u64", "1", "b.rs", "b"), + metric("retyped", Counter, "f64", "1", "c.rs", "c"), + metric("rescaled", Histogram, "f64", "s", "d.rs", "d"), + metric("still", Counter, "u64", "1", "e.rs", "e"), + ]; + let mut base = base; + let mut head = head; + base.push(described.clone()); + described.description = "after".into(); + head.push(described); + + let got = kinds(&base, &head); + assert_eq!(got.get("arrived"), Some(&MetricChangeKind::Added)); + assert_eq!(got.get("gone"), Some(&MetricChangeKind::Removed)); + assert_eq!( + got.get("reshaped"), + Some(&MetricChangeKind::InstrumentChanged) + ); + assert_eq!(got.get("retyped"), Some(&MetricChangeKind::TypeChanged)); + assert_eq!(got.get("rescaled"), Some(&MetricChangeKind::UnitChanged)); + assert_eq!( + got.get("described"), + Some(&MetricChangeKind::DescriptionChanged) + ); + // an untouched metric is not a change, and must not be reported as one + assert!(!got.contains_key("still")); + + // and only the ones a query cannot survive are breaking + let breaking: BTreeSet = diff(&side(&base), &side(&head)) + .into_iter() + .filter(|c| c.breaking) + .map(|c| c.name) + .collect(); + assert_eq!( + breaking, + ["gone", "rescaled", "reshaped", "retyped"] + .iter() + .map(|s| s.to_string()) + .collect::>() + ); + } + + #[test] + fn a_metric_that_only_moved_file_is_not_a_change() { + let base = [metric("hits", Instrument::Counter, "u64", "1", "old.rs", "f")]; + let head = [metric("hits", Instrument::Counter, "u64", "1", "new.rs", "g")]; + assert!(kinds(&base, &head).is_empty()); + } + + #[test] + fn a_rename_is_paired_only_when_the_pairing_is_unambiguous() { + use Instrument::Counter; + // one out, one in, same instrument in the same function: a rename + let base = [metric("hits", Counter, "u64", "1", "a.rs", "install")]; + let head = [metric("cache.hits", Counter, "u64", "1", "a.rs", "install")]; + let got = diff(&side(&base), &side(&head)); + assert_eq!(got.len(), 1); + assert_eq!(got[0].kind, MetricChangeKind::Renamed); + assert_eq!(got[0].was, "hits"); + assert_eq!(got[0].name, "cache.hits"); + assert!(got[0].breaking); + + // two out and two in under the same key: which became which is a guess, + // and a wrong guess hides a real deletion + let base = [ + metric("a", Counter, "u64", "1", "a.rs", "install"), + metric("b", Counter, "u64", "1", "a.rs", "install"), + ]; + let head = [ + metric("c", Counter, "u64", "1", "a.rs", "install"), + metric("d", Counter, "u64", "1", "a.rs", "install"), + ]; + let got = kinds(&base, &head); + assert_eq!(got.get("a"), Some(&MetricChangeKind::Removed)); + assert_eq!(got.get("b"), Some(&MetricChangeKind::Removed)); + assert_eq!(got.get("c"), Some(&MetricChangeKind::Added)); + assert_eq!(got.get("d"), Some(&MetricChangeKind::Added)); + + // a different instrument is a different metric, not a renamed one + let base = [metric("hits", Counter, "u64", "1", "a.rs", "install")]; + let head = [metric( + "duration", + Instrument::Histogram, + "u64", + "1", + "a.rs", + "install", + )]; + let got = kinds(&base, &head); + assert_eq!(got.get("hits"), Some(&MetricChangeKind::Removed)); + assert_eq!(got.get("duration"), Some(&MetricChangeKind::Added)); + } + + #[test] + fn one_name_created_twice_is_one_metric_with_two_sites() { + let mut out = BTreeMap::new(); + merge( + &mut out, + metric("hits", Instrument::Counter, "u64", "1", "b.rs", "f"), + ); + merge( + &mut out, + metric("hits", Instrument::Counter, "u64", "1", "a.rs", "g"), + ); + assert_eq!(out.len(), 1); + let m = &out["hits"]; + assert_eq!(m.sites.len(), 2); + // sorted, so the two sides of a branch cannot disagree about which site + // the facts came from + assert_eq!(m.sites[0].file, "a.rs"); + } + + fn run(dir: &Path, cmd: &[&str]) { + let out = Command::new(cmd[0]) + .args(&cmd[1..]) + .current_dir(dir) + .output() + .unwrap_or_else(|e| panic!("running {cmd:?}: {e}")); + assert!( + out.status.success(), + "{cmd:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + + fn commit_all(dir: &Path, msg: &str) { + run(dir, &["git", "add", "-A"]); + run( + dir, + &[ + "git", + "-c", + "user.name=telemetry-test", + "-c", + "user.email=telemetry@test", + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "-m", + msg, + ], + ); + } + + fn rev_head(dir: &Path) -> String { + let out = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(dir) + .output() + .expect("git rev-parse"); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + + #[test] + fn telemetry_end_to_end_git() { + let dir = std::env::temp_dir().join(format!("ccc-telemetry-e2e-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("src")).expect("mkdir"); + run(&dir, &["git", "init", "-q"]); + + fs::write( + dir.join("src/metrics.rs"), + "use opentelemetry::global;\n\ + pub fn install() {\n\ + \x20 let c = meter.u64_counter(\"billing.charges\").with_unit(\"1\").build();\n\ + \x20 let h = meter.f64_histogram(\"billing.latency\").with_unit(\"ms\").build();\n\ + }\n", + ) + .expect("write"); + // a file that never names the API is never read, so its `createCounter` + // is its own business + fs::write( + dir.join("src/ui.ts"), + "const meter = ours();\nexport const c = meter.createCounter('ui.clicks');\n", + ) + .expect("write"); + commit_all(&dir, "base"); + let base_sha = rev_head(&dir); + + fs::write( + dir.join("src/metrics.rs"), + "use opentelemetry::global;\n\ + pub fn install() {\n\ + \x20 let c = meter.u64_counter(\"billing.charges.total\").with_unit(\"1\").build();\n\ + \x20 let h = meter.f64_histogram(\"billing.latency\").with_unit(\"s\").build();\n\ + }\n", + ) + .expect("write"); + commit_all(&dir, "branch"); + let head_sha = rev_head(&dir); + + let r = analyse( + &dir, + &TelemetryOptions { + base_sha: &base_sha, + head_sha: &head_sha, + worktree: false, + }, + ); + assert!(r.error.is_none(), "{:?}", r.error); + assert!(r.instrumented); + // `ui.clicks` is not in the surface: the file never names OpenTelemetry + assert_eq!(r.counts.metrics, 2, "{:?}", r.metrics); + let by: BTreeMap<&str, &MetricChange> = + r.changes.iter().map(|c| (c.name.as_str(), c)).collect(); + assert_eq!( + by["billing.charges.total"].kind, + MetricChangeKind::Renamed + ); + assert_eq!(by["billing.charges.total"].was, "billing.charges"); + assert_eq!(by["billing.latency"].kind, MetricChangeKind::UnitChanged); + assert_eq!(by["billing.latency"].detail, vec!["unit ms -> s"]); + assert_eq!(r.counts.breaking, 2); + + // an uncommitted edit is invisible to the committed view a CI run wants, + // and is the whole point of the other one + fs::write( + dir.join("src/edge.py"), + "from opentelemetry import metrics\nq = meter.create_counter(\"edge.queued\")\n", + ) + .expect("write"); + let committed = analyse( + &dir, + &TelemetryOptions { + base_sha: &base_sha, + head_sha: &head_sha, + worktree: false, + }, + ); + assert_eq!(committed.counts.metrics, 2); + let live = analyse( + &dir, + &TelemetryOptions { + base_sha: &base_sha, + head_sha: &head_sha, + worktree: true, + }, + ); + assert_eq!(live.counts.metrics, 3); + assert_eq!(live.counts.added, 1); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn a_side_that_could_not_be_read_reports_nothing_rather_than_deletions() { + let dir = std::env::temp_dir().join(format!("ccc-telemetry-nogit-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("mkdir"); + // no repository, so neither side can be read at all + let r = analyse( + &dir, + &TelemetryOptions { + base_sha: "deadbeef", + head_sha: "deadbeef", + worktree: false, + }, + ); + assert!(r.error.is_some()); + assert!(r.changes.is_empty()); + assert!(!r.instrumented); + // "we could not look" is not "nothing moved" + assert!(r.gates()); + let _ = fs::remove_dir_all(&dir); + } +}