Spec coverage: a change maps to the specs that cover it
Every spec under docs/reference/specs/ already says what code it covers: the paths in its - **Code**: and - **Tests**: header lines, which specs:check requires to exist. specs:coverage turns those headers into a map from a change to the specs that must still be true after it — the list the review agent reads for its spec contradiction check (agent-review.md item 14) — and names the changed source paths no spec claims, so a behavior can be shipped without a contract only in the open. There is no separate covers: field to rot: the headers a reader already follows are the coverage.
- Code:
src/docs/specCoverage.ts(pure:parseHeaderPaths,isSourcePath,covers,coveringSpecs),scripts/specs-coverage.ts(npm run specs:coverage: reads the specs and the diff, prints the lists). - Tests:
src/docs/specCoverage.test.ts. - Docs: How we work, AGENTS.md ("How a change is made").
Behavior
- A spec's coverage is its headers. The paths a spec covers are the backtick spans on its
- **Code**:and- **Tests**:lines — bare or inside a markdown link — that carry a directory and no wildcard (src/core/x.ts,deploy/cloudflare-bot/; notActor,conversations.info,/runs,src/core/trace/*.test.ts). The rule is the onespecs:checkapplies to the same lines, kept as its own copy undersrc/because nothing there may import fromscripts/(the bot image copiessrc/alone). No other line contributes: a proof reference in the criteria table names a test, not coverage. - A header path covers itself and everything beneath it, trailing slash or not.
src/core/authz/andsrc/core/authzboth coversrc/core/authz/policy.ts; a file path covers exactly itself, since a file has no descendants. Coverage is by path segment:src/core/authznever coverssrc/core/authzExtra.ts. The rule needs no look at the disk, so the same paths give the same answer in CI, in a resident and in a sandbox. - Touched specs, with the reason. For a set of changed paths, the result lists every spec at least one path falls under — in the specs directory's order, each once — with the header paths that matched, so a reader can see why a spec is on the list.
- Uncovered means a source path no spec claims. A changed path is a source path when it is a code file (
.ts,.mts,.cts,.js,.mjs,.cjs,.vue) undersrc/,web/src/ordeploy/and is not a test (.test./.spec.), a snapshot, a fixture under atesting/directory, a declaration (.d.ts), or config, docs or anything outside those roots. Only such paths appear inuncovered: a new test file, a doc or a lockfile without a spec is not a gap. - One command, three inputs, two shapes, one gate.
npm run specs:coveragetakes the changed paths from--changed <a>...<b>(git diff --name-onlyover that range — the review agent's form,origin/<base>...HEAD), from--paths p1 p2 …, or from stdin one per line. It prints the touched specs one per line (<spec> ← <matching header paths>) and then the uncovered list, or the same as{ touched, uncovered }with--json. It exits 0 whatever it finds;--requireexits 1 whenuncoveredis non-empty. A malformed invocation —--changedwith no range or with a flag where the range should be, an unknown flag, or nothing on stdin at a terminal (it never blocks waiting for input) — prints the usage line and exits 1. The gate is warn-only until every source path has a covering spec, then--requirejoinscheck:consistency— warn first, then error, the way the hygiene ratchet landed.
Validation criteria
| Criterion | Proof |
|---|---|
| Header paths are read from the Code and Tests lines only, bare or linked, with line numbers; identifiers, dotted symbols, routes and globs are not paths; table rows and other headers contribute nothing (item 1) | [unit] src/docs/specCoverage.test.ts::parseHeaderPaths::* |
A source path is a code file under src/, web/src/ or deploy/; tests, snapshots, testing/ fixtures, declarations, config, docs and paths outside the roots are not (item 4) | [unit] src/docs/specCoverage.test.ts::isSourcePath::* |
| Equality touches the spec and names the matching header; a header path covers its subtree with or without a trailing slash and never a prefix-sharing sibling (items 2–3) | [unit] src/docs/specCoverage.test.ts::coveringSpecs::a changed path equal to a header path touches that spec, and says which header path matched, ::a header path covers everything beneath it, with or without its trailing slash, ::a header path never covers a sibling that merely shares its prefix |
| One path touches several specs; each spec is listed once, in order, with only the headers that matched; nothing in → nothing out (item 3) | [unit] ::one changed path can touch several specs; a spec is listed once, in spec order, with only the header paths that matched, ::no changed paths → nothing touched, nothing uncovered |
uncovered holds only source paths with no covering spec (item 4) | [unit] ::uncovered lists only source paths with no covering spec — a test, a doc or a config file without a spec is not a finding |
The command over a real range lists the touched specs and exits 0 with an uncovered path; --require exits 1 on the same input; --json is the machine shape (item 5) | [agent] npm run --silent specs:coverage -- --paths src/core/costs.ts src/nothing/here.ts → one costs.md ← src/core/costs.ts line, then 1 changed source path(s) with no covering spec naming src/nothing/here.ts, exit 0; add --require → exit 1; add --json → { "touched": [...], "uncovered": ["src/nothing/here.ts"] }. On a branch: npm run --silent specs:coverage -- --changed origin/main...HEAD lists the specs whose headers the diff falls under. |