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. The same map decides when a test may be removed: --test-guard holds the proofs to the same-PR rule the specs hold behavior to (agent-review.md item 16).
- Code:
scripts/spec-boundaries.mjs,scripts/specs-check.mjs,src/docs/specCoverage.ts(pure:parseHeaderPaths,isSourcePath,covers,coveringSpecs),src/docs/testGuard.ts(pure:compareTestFile,testGuard,formatTestGuard),scripts/specs-coverage.ts(npm run specs:coverage: reads the specs and the diff, prints the lists; with--test-guardparses each changed test file at both ends of the range withcollectTestTitlesfromscripts/specs-check.mjs). - Tests:
src/decisionBoundaries.test.ts,src/docs/specCoverage.test.ts,src/docs/testGuard.test.ts,src/specsCheck.test.ts(collectTestTitles),src/specsCoverage.test.ts(working-tree blob reads and identity). - 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>(the head path set ofgit diff --name-status -M -zover 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.The test guard: a test is removed or narrowed only with its spec.
--test-guard(with--changedonly — it reads both ends of the range: the merge base and the head ofa...b,aandbofa..b,aand the working tree of a lonea) takes every test file the range touches (.test./.spec.with a.js,.ts,.cjs,.mjs,.ctsor.mtsextension — the same shapespecs:checkreads test files by;.tsx/.jsxare not read; a Git-detected rename between test paths is read as one file, old path at the base, new path at the head; a rename out of the test-file suffix is whole-file loss and a rename into it is a new test; when Git limits similarity detection it may conservatively report removal plus addition), parses each end with the static title parsespecs:checkbinds proofs with, and compares. Two classes of line, eachtest-guard: <file> — <class>: <what>. Class A,removed:— deterministic: the test file deleted (the test file); anit/test/describewhose title path is gone from the file with no new title left to pair with (test "outer > title",describe "outer"); askip/only/todomodifier, or anxit/xtest/xdescribealias, on a block the base ran (test "t" from the run — marked skip;onlyreadsevery test but "t"). A new test that arrives already skipped removes nothing; a new focused block does remove the other tests. Skip and todo modes inherit through enclosing describes, so moving or retitling a previously executed test into a skipped group is a loss. A base focused test changed to skip or todo is also removed. Class B,check:— heuristic, a person disposes of it: fewerexpect(calls in the file (expect() calls 3 → 2); a block whose body is unchanged under a new title (test "a" retitled "b", body unchanged— a describe retitled is one line, its children moved with it and are not listed, as is a block moved verbatim under another active describe); a title gone while a new title arrived (test "a" gone while "b" was added in the same file — a rename or a split?). Gone and new titles pair off in file order: N gone and M new give min(N, M) checks and N − M removals, so a rename beside a deletion in one file still reports the deletion. Allowed: a line of either class whose test file a content-changed or deleted spec covered at the requested base revision (item 2 over the base identity of the diff's changed specs). For a rename, use the old test path; removing or replacing a head header does not erase the base owner. The owning spec must change content or be deleted; a rename carries its original identity but a pure move or file-mode-only change allows nothing. Deleting a spec counts as touch only: review must require supported behavior retirement or replacement proof, not treat deletion as semantic preservation. Missing base ownership allows nothing; an unreadable revision or expected file, a package root different from the Git root, or an unresolved working-tree index, fails the command. Range endpoints are pinned once to commit hashes before diff and blob reads. Working-tree spec identity uses canonical Git blob content: clean filters for regular files and link bytes for symlinks. Snapshot reads of symlinks use the link content in every range form. Working-tree regular files are opened withO_NOFOLLOWand read through that descriptor; a symlink is read withreadlink, never through its target. Both snapshot parsing and blob hashing use this reader, and Git receives the captured bytes on stdin with path-based clean filters only for regular files. An entry swapped to a symlink before opening contributes link bytes; a path replaced after opening does not redirect the descriptor. Missing entries and failed link reads fail the command, as does a platform without no-follow file opens. An owning-spec deletion in the lone-base working-tree form refuses because untracked destinations anywhere are incomplete evidence; use a committed head range to judge deletion. A staged pure rename is still compared by blob identity and allows nothing. Head coverage determines undeleted source. A Git-deleted module instead requires an exact base owning spec retained at the head and content-changed in the same pinned diff; a stale exact deleted-file header, unchanged owner, pure owner rename or missing owner grants no deletion coverage. Only that deleted path is attributed to the revised owner, never a new or undeleted sibling. Plain--pathsand stdin retain current-header-only semantics. Title disappearance is conservatively gated even for already inactive base titles; focus markers are compared as syntax, without modelling the exact selected run. The parser reads mode modifiers from callee chains; it does not read mode flags from options arguments or collect tagged-template tables. Review those forms directly from the diff. Conditional selection and dynamic or aliased test definitions also require review and are outside this static classifier. Marker lines name the head marker or suppressed block; pairing checks retain the original title for semantic review. This is a same-PR spec-change guard, not proof of semantic preservation: review must check surviving criteria, replacement proofs and explicit behavior retirement. An allowed line prints— allowed by <spec>and counts for nothing. An unallowed class A line ends— covered by <spec>; change it in this PR or restore the test, or— no base spec covers it; restore the test and establish ownership before removing itwhen no base header claims the file; an unallowed class B line has no suffix. The command exits 1 when any class A line is unallowed (test-guard FAILED — N removal(s) without a spec change) and 0 otherwise (test-guard ok — N test file(s) changed, no verification removed without its spec), class B lines or not;--test-guardwithout--changedis a malformed invocation (item 5). The review agent runs this form and files each unallowed class A line as a finding, disposing of each class B line out loud (agent-review.md item 16).One typed effect-result declaration. An owning criterion uses
<!-- decision-boundary: id -->to require its declaration; a missing fence, an ordinary fence or malformed declaration metadata cannot silently remove enforcement. Ajson decision-boundarycode fence beside that criterion declares its typed result, validator, consumer, retry owner and closed pending/terminal cases.specs:checkstrictly validates the declaration, resolves the named source symbols under that spec's Code header, checks the result is a closed string union partitioned exactly by its outcomes, and binds an exact statically named leaf test named by the same full path and title segments in the criterion; file-wide, suite-prefix and wildcard references cannot substitute for that exact binding. Skip, focus, conditional and parameterized modifiers on the test or its ancestors refuse that proof; executed coverage remains the test job's responsibility. A lost acknowledgement response remains unconfirmed; the declaration does not claim the remote acknowledgement failed to commit. Missing/unknown fields, broad or skipped proofs and duplicate declarations fail; boundary failures never join the stale-proof baseline. The first declaration covers plane acknowledgements only. Validators, consumers and retry owners resolve to executable callable implementations, excluding interface signatures and bodyless or ambient declarations; the outstanding-effect cap resolves to a positive integer constant.--boundary-schemaprints the strict declaration schema as JSON.--candidatesprints a separate syntax census without running the proof gate: JS/TS undersrc,deploy,web/srcandscripts, excluding tests, declarations, dependencies, fixtures and generated output. Its regex, text-comparison and parser-call counts include safe uses and omit dynamic flows, Vue/templates and shell/workflows. Other specs and this census do not become enforced declarations by inference. The check does not prove validator semantics, dynamic data flow or transitive dependency changes.
Committed test snapshots larger than the default Git subprocess output buffer remain readable within a bounded 32 MiB command buffer. Buffer exhaustion still fails the command; snapshot size never licenses proof loss.
Validation criteria
| Criterion | Proof |
|---|---|
| Deleted source is attributed only to its content-revised retained base owner in an exact Git diff; unchanged, missing, stale or purely renamed owners and unrelated new source remain uncovered. Plain path input receives no historical coverage. | [unit] src/specsCoverage.test.ts::specs:coverage working-tree blobs::covers a deleted module only through its revised retained base owner, ::retains deletion ownership refusal for …, ::does not attribute unowned deleted tests or docs to separately changed specs |
| Large committed snapshots retain unchanged-owner proof-loss refusal and content-changed owner attribution. | [unit] src/specsCoverage.test.ts::specs:coverage working-tree blobs::checks large committed test snapshots without losing the proof-removal guard |
| 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 |
A test file is .test./.spec. with a .js/.ts/.cjs/.mjs/.cts/.mts extension, never .tsx/.jsx; expect( calls are counted whatever follows them (item 6) | [unit] src/docs/testGuard.test.ts::isTestFile::*, ::countExpectCalls::* |
| Class A: a deleted test file, a title gone with nothing to pair with (an it, a describe with each child), a skip/only/todo marker or x-alias on a block the base ran; added focus, active tests moved or retitled under skipped ancestors, base focus changed to skip/todo; a block arriving already skipped removes nothing (item 6) | [unit] src/docs/testGuard.test.ts::compareTestFile…::class A, removed: deterministic losses::*, src/docs/testGuard.test.ts::compareTestFile…::missing inactive %s titles still require owning-spec review |
Class B: fewer expect( calls; a retitle with the body unchanged (a describe once, its children not listed); gone and new titles pair off, min(N, M) checks and the rest removed; a block moved verbatim is neither class (item 6) | [unit] src/docs/testGuard.test.ts::compareTestFile…::class B, check: heuristic signals the reviewer disposes of::* |
| Actual nesting and selection distinguish same-title siblings, including reordered and body-edited groups; focus moved unchanged is preserved, and explicit added focus beneath skip is still reported (item 6) | [unit] src/docs/testGuard.test.ts::compareTestFile…::moving an existing focused test preserves its focus without claiming a new loss, ::an explicit focus inside a newly skipped group still reports the focus marker, ::an added skipped sibling with the same title does not suppress an active sibling, ::reordering identical same-title active and skipped siblings preserves their selections, ::same-title siblings preserve selection when their bodies also change |
| A new test file, a file that only adds, an unchanged file: nothing to report (item 6) | [unit] ::a new test file has nothing to lose, whatever it contains, ::a file that only adds tests and assertions has nothing to report, ::an unchanged file has nothing to report |
| Allowed by a supplied changed base covering-spec identity, both classes; the host establishes content change/deletion (agent proof below); a changed spec that does not cover the file allows nothing; an uncovered file carries no spec and is never allowed; deleted and renamed tests use base ownership; absent base ownership allows nothing (item 6) | [unit] src/docs/testGuard.test.ts::testGuard — a line is allowed only when a spec covering the test file changes in the same diff::* |
The printed lines and the exit: the ok line, the class A line with its covered-by / no-spec suffix and the FAILED summary counting class A only, the class B line that passes, the — allowed by suffix (item 6) | [unit] src/docs/testGuard.test.ts::formatTestGuard — the lines the command prints and whether it passes::* |
| The parse carries each block's modifier (skip/only/todo, the x-aliases), actual inherited skip ancestry independently of explicit focus and sibling titles, and its body with whitespace collapsed (item 6) | [unit] src/specsCheck.test.ts::collectTestTitles::carries the modifier…, ::carries the arguments after the title…, ::carries skip ancestry from actual nesting, keeping explicit focus and same-title siblings distinct |
The command over a real range: a branch that deletes an it( from a spec-covered test file without touching the spec prints one removed: line naming that spec and exits 1; the same branch with the spec changed prints — allowed by and exits 0; --test-guard without --changed prints the usage line and exits 1 (item 6) | [agent] On a scratch branch: delete one it( from src/docs/specCoverage.test.ts, commit, run npm run --silent specs:coverage -- --changed main...HEAD --test-guard → test-guard: src/docs/specCoverage.test.ts — removed: test "…" — covered by docs/reference/specs/specs-coverage.md; change it in this PR or restore the test, then test-guard FAILED — 1 removal(s) without a spec change, exit 1. Edit docs/reference/specs/specs-coverage.md on the branch, commit → the line ends — allowed by docs/reference/specs/specs-coverage.md, then test-guard ok — 1 test file(s) changed, …, exit 0. npm run --silent specs:coverage -- --paths x --test-guard → usage, exit 1. |
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. |
| Base/head Git wiring: whole-file loss with removed head headers uses the base owner; unchanged or purely renamed owner refuses; content-changing owner rename and narrowed test rename retain base identity; ordinary renamed-source coverage contains only the head path; both range forms and working-tree form agree; copies add no proof loss; revision names matching tracked directories remain unambiguous and resolve once to pinned commits; unreadable revisions and unresolved working-tree indexes fail; filtered regular-file content and symlink bytes match committed blob identity (item 6). | [agent] In a disposable Git repository, copy scripts/specs-coverage.ts, scripts/specs-check.mjs, scripts/spec-boundaries.mjs, src/docs/specCoverage.ts and src/docs/testGuard.ts at their original relative paths; link the verified existing node_modules and create a module package with specs:coverage calling tsx scripts/specs-coverage.ts. Run npm run --silent specs:coverage -- --changed <range> --test-guard: commit a spec owning a test and a keeper, remove the test alone → guard exit 1; replace its header/proof with the keeper → exit 0 in base..HEAD, base...HEAD and lone-base forms. Rename and narrow the test while updating its owner → exit 0 using the old path. Purely rename the owner while deleting its test → exit 1; revise the renamed contract → exit 0 using the original owner identity. Rename covered source and update its head header → --require exit 0, no obsolete path. Copy a modified-source test with diff.renames=copies → an addition with no loss because the explicit -M selects renames. A test renamed outside its suffix or replaced by a symlink → loss against its base owner; renaming into the suffix adds a test. A link whose bytes equal the original owner blob changes only type and licenses no loss. An unstaged owner move, including a staged source deletion with an untracked destination outside the specs directory, while removing its test → exit 1; staging the pure move still refuses the test loss. A real staged owner deletion refuses in lone-base form and is judged only after committing. A branch name also naming a tracked directory still resolves as a revision. Move HEAD after diff while reading a committed range → selected head remains pinned. Copy the script into a nested package under the repository → root mismatch refuses. Unknown base ref or an unresolved merge in the lone-base form → exit 1. Mode-only owner change or CRLF-normalized content → exit 1; changing a regular owner to a symlink carrying identical target text → exit 0 in both working-tree and committed forms because the link blob changed. Restore or discard the fixture after each case. This proves the touch guard, not semantic replacement. |
| Working-tree snapshot reads and owner hashes never follow a final-component symlink swapped at the read boundary; regular reads remain bound to the opened inode, vanished links fail closed, unchanged regular and link blobs allow no loss, regular clean filters retain canonical identity, and committed snapshots remain independent of link targets (item 6). Fixtures use canonical directory paths so read-boundary hooks also run through platform temporary-directory aliases. | [unit] src/specsCoverage.test.ts::specs:coverage working-tree blobs::* |
| Typed effect-result declarations bind to their owning criterion, covered source symbols, closed outcomes and exact active proofs; missing/unknown fields, exemptions, missing or malformed fences, malformed JSON, duplicates, stale or type-only callable symbols, open result types and missing/broad/dynamic/skipped proofs fail outside the stale-proof baseline. Ordinary specs remain unchanged and the plane acknowledgement retains its declaration (item 7). | [unit] src/decisionBoundaries.test.ts::decision-boundary declarations::* |