testguard-cli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +291 -0
  2. package/README.md +321 -27
  3. package/package.json +1 -1
  4. package/spec/GATE-SEMANTICS.md +109 -1
  5. package/spec/README.md +4 -2
  6. package/spec/lib/validate.mjs +75 -1
  7. package/spec/schemas/baseline.schema.json +8 -0
  8. package/spec/schemas/brief.schema.json +11 -1
  9. package/spec/schemas/common.schema.json +32 -1
  10. package/spec/schemas/evidence.schema.json +374 -63
  11. package/spec/schemas/gate.schema.json +52 -0
  12. package/spec/schemas/replay.schema.json +85 -0
  13. package/spec/schemas/status.schema.json +269 -45
  14. package/src/admit/admit.mjs +17 -0
  15. package/src/baseline/baseline.mjs +31 -2
  16. package/src/brief/brief.mjs +96 -8
  17. package/src/claims/removed.mjs +161 -0
  18. package/src/cli.mjs +69 -12
  19. package/src/commands/admit.mjs +116 -0
  20. package/src/commands/baseline.mjs +17 -3
  21. package/src/commands/brief.mjs +26 -12
  22. package/src/commands/claims.mjs +35 -6
  23. package/src/commands/gate.mjs +47 -0
  24. package/src/commands/init.mjs +19 -6
  25. package/src/commands/mcp.mjs +12 -0
  26. package/src/commands/probe.mjs +12 -3
  27. package/src/commands/replay.mjs +63 -0
  28. package/src/commands/status.mjs +4 -1
  29. package/src/gate/changed.mjs +222 -0
  30. package/src/git.mjs +5 -0
  31. package/src/init/init.mjs +182 -29
  32. package/src/init/templates/SKILL.md +21 -3
  33. package/src/mcp/server.mjs +104 -0
  34. package/src/mcp/tools.mjs +114 -0
  35. package/src/probe/contention.mjs +60 -0
  36. package/src/probe/discover.mjs +12 -3
  37. package/src/probe/independence.mjs +59 -0
  38. package/src/probe/mocks.mjs +120 -0
  39. package/src/probe/probe.mjs +126 -18
  40. package/src/probe/rank.mjs +73 -15
  41. package/src/probe/runners/index.mjs +49 -5
  42. package/src/probe/runners/jest.mjs +3 -3
  43. package/src/probe/runners/playwright.mjs +133 -0
  44. package/src/probe/runners/shared.mjs +70 -13
  45. package/src/probe/runners/vitest.mjs +4 -3
  46. package/src/render.mjs +2 -2
  47. package/src/replay/label.mjs +64 -0
  48. package/src/replay/replay.mjs +309 -0
  49. package/src/scaffold/producers.mjs +217 -6
  50. package/src/scaffold/scaffold.mjs +9 -3
  51. package/src/status/status.mjs +88 -7
package/README.md CHANGED
@@ -44,6 +44,19 @@ re-implemented the authorization logic *inside the test* and asserted
44
44
  against the copy: fifteen green tests, zero detection. After wrapper-level
45
45
  tests were written against the survivors, 39/39 were killed.
46
46
 
47
+ The peer-reviewed picture in 2026 says the same thing from the other side.
48
+ Coverage and mutation score of LLM-generated suites track effectiveness only
49
+ when the code under test is assumed correct; once it may be buggy they "no
50
+ longer serve as reliable indicators" ([Zhao, Zhou and Cohen, ISSTA 2026](https://arxiv.org/abs/2607.22880)).
51
+ Buggy code steers a model toward tests that assert the bug, and prompting
52
+ with the specification is the mitigation that works ([arXiv 2607.22883](https://arxiv.org/abs/2607.22883)) —
53
+ which is why a claim here comes from intent, a fault is bound to the claim,
54
+ and the brief hands the agent the claim before it writes. And agents
55
+ saturate whatever tests they can see, with the gap to held-out tests
56
+ growing about 28 points per tenfold increase in code size ([SpecBench](https://arxiv.org/abs/2605.21384)).
57
+ Every generator on the market admits a test because it compiles, passes and
58
+ raises coverage. TestGuard admits it because it fails when the claim is false.
59
+
47
60
  ## Install
48
61
 
49
62
  | How | Command |
@@ -52,8 +65,9 @@ tests were written against the survivors, 39/39 were killed.
52
65
  | npm | `npm i -D testguard-cli` then `npx testguard probe` |
53
66
  | pip | `pip install testguard-cli` then `testguard probe` (needs Node ≥ 20) |
54
67
  | Homebrew | `brew tap raccioly/tap && brew install testguard` |
55
- | GitHub Action | `uses: raccioly/testguard@v0.4.0` — see [`action.yml`](./action.yml) |
68
+ | GitHub Action | `uses: raccioly/testguard@v0.6.0` — see [`action.yml`](./action.yml) |
56
69
  | pre-commit | `repo: https://github.com/raccioly/testguard`, hooks `testguard-claims`, `testguard-probe` |
70
+ | GitLab CI | `include: - remote: https://raw.githubusercontent.com/raccioly/testguard/v0.5.0/packaging/gitlab/testguard.gitlab-ci.yml` with `inputs:` — see [`packaging/gitlab/`](./packaging/gitlab/testguard.gitlab-ci.yml) |
57
71
 
58
72
  Projects that set `min-release-age` in `.npmrc` cannot see a version published
59
73
  less than that many days ago (`ENOVERSIONS`); install that one with
@@ -68,7 +82,9 @@ npx testguard-cli claims # what does this project claim, and is every claim
68
82
  npx testguard-cli probe # try to falsify each claim; report what the tests missed
69
83
  npx testguard-cli baseline # freeze today's unproven findings; from now on only new ones gate
70
84
  npx testguard-cli brief # tell the agent where the suite is blind, before it writes
85
+ npx testguard-cli gate --changed origin/main # fail when a changed source file carries no claim at all
71
86
  npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to keep or drop
87
+ npx testguard-cli admit test/x.test.ts --claim X # is this test green on HEAD and does it fail on every fault of X? ADMITTED or NOT ADMITTED
72
88
  ```
73
89
 
74
90
  1. **Claims** live in `testguard.claims.json` (editors validate it against
@@ -82,7 +98,12 @@ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to
82
98
  2. **Probe** confirms the defenders are green N times unmodified, applies
83
99
  each fault in a scratch git worktree (your tree is never touched), runs
84
100
  the defenders N times, re-runs survivors against the whole suite with
85
- N-run attribution, restores, and classifies. Verdicts are a closed set:
101
+ N-run attribution (up to N: it stops as soon as no test has failed in
102
+ every run, because from there none can be named a killer), restores, and
103
+ classifies. It warns when another test runner is already running — a
104
+ contended machine turns a slow suite into a `TIMEOUT` verdict about the
105
+ load, not the claim — records that on the evidence, and offers `--serial`
106
+ to run one test file at a time. Verdicts are a closed set:
86
107
 
87
108
  | Verdict | Meaning |
88
109
  |---|---|
@@ -92,7 +113,7 @@ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to
92
113
  | `UNVERIFIABLE` | the fault's anchor is missing or ambiguous — loud, never a skip |
93
114
  | `TIMEOUT` | the defenders hung; a hang is not a detection |
94
115
  | `FAULT-INVALID` | the replacement does not load — a bad fault, not a finding |
95
- | `FLAKY-DEFENDER` | the defenders are not reliably green, or disagreed across runs |
116
+ | `FLAKY-DEFENDER` | the defenders are not reliably green, or disagreed across runs — `detail.flakeRate` says how often (`failures` of `runs` on unmodified source) |
96
117
 
97
118
  Never a single score. Findings are ranked by severity, claim provenance
98
119
  and blast radius (relative imports, `tsconfig` path aliases and
@@ -101,20 +122,48 @@ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to
101
122
  it is written.
102
123
 
103
124
  Worktree mode probes a **commit**. If a defender or target file has
104
- uncommitted changes, `probe` refuses and says so — otherwise your new
105
- tests would be silently absent and the same survivors would come back
106
- with no hint why. `--include-dirty` snapshots the working tree (tracked
107
- edits and new files) into a throwaway commit and probes that; your tree,
108
- HEAD and index are never touched. Every summary names the commit probed.
125
+ uncommitted changes and you asked for the implicit HEAD, `probe` refuses
126
+ and says so — otherwise your new tests would be silently absent and the
127
+ same survivors would come back with no hint why. `--include-dirty`
128
+ snapshots the working tree (tracked edits and new files) into a throwaway
129
+ commit and probes that; your tree, HEAD and index are never touched. An
130
+ **explicit `--ref`** (a pre-fix commit in a post-mortem, say) is honoured
131
+ over a dirty tree, as is `--ignore-dirty`: a warning names the files and
132
+ the evidence records them as `repo.ignoredDirty`, so the run says what it
133
+ did not look at. Every summary names the commit probed.
109
134
 
110
135
  A claim with no `defendedBy` has its defenders **discovered**: the test
111
- files that import the fault's target, by relative path or resolved alias.
112
- `NOCOVER` then means exactly "no test file imports this source".
136
+ files that import the fault's target, by relative path or resolved alias
137
+ (tsconfig `paths`, through `extends` and `references`, vite/vitest
138
+ `resolve.alias`, package.json `imports`) — **minus the files that mock
139
+ it**. A test that `vi.mock`s / `jest.mock`s the target cannot detect any
140
+ fault in it; counting it would make `NOCOVER` under-report and waste runs.
141
+ `NOCOVER` therefore means exactly "no test file imports this source
142
+ without mocking it". `claims` prints the split (`18 import · 16 mock · 2
143
+ can detect`), the evidence lists the mocking files, and a mocking file
144
+ that never `expect(...)`s anything imported from the target carries the
145
+ static signal `mocked-never-asserted` — the cheapest blind-spot signal
146
+ there is, and the exact signature of one escaped bug in the field
147
+ reports. Silence it, visibly, with `// unasserted: <why>` above the mock.
113
148
 
114
149
  Runners: **vitest** and **jest** (`--runner auto` picks the first that
115
- resolves; both read the same jest-compatible JSON report). Anything else
116
- goes through `--runner-cmd`. Each runner is proven against its own copy
117
- of the known-answer fixture.
150
+ resolves; both read the same jest-compatible JSON report). A runner is
151
+ resolved from the **project's own package** first — its pinned version,
152
+ its own bin script — and only then from an executable on PATH, which the
153
+ evidence records as `runner.source: "path"`; `npx` is never asked,
154
+ because its cache answers for packages a project does not have. Anything
155
+ else goes through `--runner-cmd`. Each runner is proven against its own
156
+ copy of the known-answer fixture.
157
+
158
+ The two-gate rule, as one verb: `testguard admit <test-file> --claim <ID>`
159
+ runs the claim's faults against your uncommitted test and answers
160
+ `ADMITTED` (exit 0: the test is green on unmodified HEAD and fails on
161
+ every fault, N/N) or `NOT ADMITTED` (exit 1: the first blocking fault and
162
+ what to do about it). The test must be a declared or discovered defender
163
+ of the claim. It is sugar over `probe --claim <ID> --include-dirty`, so
164
+ it can never disagree with the gate. Every generator on the market admits
165
+ a test because it compiles, passes and raises coverage; `admit` admits it
166
+ because it fails when the claim is false.
118
167
 
119
168
  Practical loop: first pass `--no-escalate` (escalation re-runs the whole
120
169
  suite N times per survivor); iterate on one claim with `--claim <ID>` and
@@ -131,20 +180,237 @@ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to
131
180
  3. **Baseline** freezes every non-passing fingerprint. Later probes suppress
132
181
  what was already known and exit non-zero only on what is new. Claims whose
133
182
  source and defenders are unchanged reuse their prior verdict, so a probe
134
- in CI costs only what changed.
183
+ in CI costs only what changed. A baseline frozen from `--include-dirty`
184
+ evidence records the snapshot and points at the *parent* of the commit
185
+ that will carry your tests; after you commit, a clean `probe` plus
186
+ `baseline --restamp` moves it to that commit — only when the fingerprints
187
+ are identical, never otherwise. `status` notes a baseline that predates
188
+ HEAD without making it a state.
135
189
  4. **Brief** turns evidence plus baseline into a ranked, capped
136
190
  `## TEST BLINDSPOT CONTEXT` block, printed and also written to
137
- `.testguard/brief.json` (`--text` prints only). Wire it into an agent's session start
191
+ `.testguard/brief.json` (`--text` prints only; `--markdown` prints the same brief as a
192
+ merge-request note for the human reviewer, unclaimed changes first). Wire it into an agent's session start
138
193
  — for Claude Code, in `.claude/settings.json`:
139
194
 
140
195
  ```json
141
196
  { "hooks": { "SessionStart": [ { "hooks": [
142
- { "type": "command", "command": "npx testguard-cli brief --text" }
197
+ { "type": "command", "command": "node_modules/.bin/testguard brief --text 2>/dev/null || { command -v testguard >/dev/null 2>&1 && testguard brief --text 2>/dev/null; } || true" }
143
198
  ] } ] } }
144
199
  ```
145
200
 
146
- `--text` prints only, and exits 0 silently when there is no evidence yet,
147
- so the hook can never break a session.
201
+ `--text` prints only, and exits 0 silently when there is no evidence yet;
202
+ the command resolves the project's own install, then a `testguard` on
203
+ `PATH`, and ends in `true` — so the hook can never break a session. There
204
+ is no `npx` in it, in any form: `--no-install` resolves the package from
205
+ the registry before declining to install it, so it is quiet rather than
206
+ offline. The brief's first line says which install answered
207
+ (`local install` or `global`), so a stale one is visible.
208
+
209
+ ### Would this suite have caught the bugs that already escaped?
210
+
211
+ An injected fault is a fault somebody thought of. A bug that actually shipped
212
+ is ground truth: a human already confirmed it was a defect, and there is no
213
+ equivalent-mutant argument to have about it.
214
+
215
+ ```bash
216
+ npx testguard-cli replay --since HEAD~50..HEAD --max 10
217
+ ```
218
+
219
+ For each fix commit in the range — one that changes source **and** a test
220
+ together — `replay` checks it out in a scratch worktree, reverts only its
221
+ source files to the parent, **deletes the test the fix shipped** (that test
222
+ proves nothing about what the suite knew before it existed), and runs the
223
+ tests that import the reverted code:
224
+
225
+ | Verdict | Meaning |
226
+ |---|---|
227
+ | `caught` | a remaining test failed by assertion, every run. The suite knew. |
228
+ | `blind` | the suite stayed green on known-broken code. |
229
+ | `nocover` | no test imports the reverted files — worse than blind. |
230
+ | `flaky` | the runs disagreed, so nothing can be concluded. A flaky failure reads as detection, which biases this metric *optimistically*; mixed runs are never `caught`. |
231
+ | `unverifiable` | the revert did not apply, or the suite could not load. |
232
+
233
+ One patch counts once (`git patch-id`), because a dual-branch topology
234
+ carries the same fix under two or three shas. **`replay` reports and never
235
+ gates**: a bug that escaped is history, not a regression in this change.
236
+
237
+ Each replayed bug is also labelled with the injected-fault class its diff
238
+ most resembles, which is the join key that turns an uninterpretable mutation
239
+ score into a statement with a sample size:
240
+
241
+ ```
242
+ guard-removed blind 7/9 p=0.78 ci [0.45, 0.94]
243
+ field-dropped blind 4/4 p=1.00 ci [0.51, 1.00]
244
+ ```
245
+
246
+ Read as *"when a fault of this class survives, how often does that
247
+ correspond to a bug that really escaped"*. It is written as a
248
+ `calibration` document beside the replay one. Only `caught` and `blind`
249
+ carry information; the other verdicts are excluded from both sides of the
250
+ ratio.
251
+
252
+ **The open question this exists to answer.** Does a calibration learned on a
253
+ repository *with* history transfer to a greenfield one that has none? AI
254
+ authored code has no history, so replay cannot help it directly and
255
+ calibration is the only bridge. That transfer is unproven, it is the core
256
+ product bet, and this is the instrument for testing it — not the answer.
257
+
258
+ ### Read CI's evidence locally
259
+
260
+ Verdict reuse makes a probe cheap, but the evidence lives where `probe` ran
261
+ and is gitignored. On a fresh clone, or on a laptop where CI does the
262
+ probing, the session-start brief is empty and `status` says `unprobed` while
263
+ the default branch has full evidence. Point either command at CI's document:
264
+
265
+ ```bash
266
+ testguard status . --evidence .testguard/ci/ci-self-evidence.json
267
+ testguard brief . --text --evidence .testguard/ci/ci-self-evidence.json
268
+ ```
269
+
270
+ A foreign document is not trusted blindly. `status` marks it
271
+ `evidenceSource: provided`, prints the commit it describes next to the
272
+ commit in your tree, and still computes staleness from the recorded input
273
+ hashes — so a file you have edited since CI probed it goes `evidence-stale`
274
+ for exactly those claims.
275
+
276
+ `testguard init --ci-evidence github` (or `gitlab`) writes
277
+ `.testguard/fetch-ci-evidence.sh`, which downloads the branch-named artifact
278
+ with the platform CLI you already have and then briefs from it. **It is an
279
+ on-demand helper, not a hook**: the session-start hook never touches the
280
+ network, and the helper exits 0 with a message when the CLI or the artifact
281
+ is missing. TestGuard itself still makes no network calls.
282
+
283
+ ### Every change needs a claim
284
+
285
+ `probe` can only verify claims that exist. Every escaped defect in the field
286
+ reports so far was a **claim gap**: the feature shipped green with zero
287
+ claims, and a verifier with no claim about a feature is silent about it by
288
+ construction. `gate` closes that hole on the delta:
289
+
290
+ ```bash
291
+ npx testguard-cli gate --changed origin/main # in a PR: the files changed since the base branch
292
+ npx testguard-cli gate --changed HEAD --include-dirty # before a commit: the working tree, staged or not
293
+ ```
294
+
295
+ Every changed source file must carry a fault, resolve as a defender of a
296
+ claim (test files), or be excused by an unexpired `path` entry in
297
+ `testguard.ignore.json` — with a reason a reviewer will accept. One
298
+ unclaimed file exits `1`; there is no percentage. Every reliance on an ignore
299
+ entry is printed, so a reviewer sees *why* the gate passed; an expired entry
300
+ excuses nothing. Non-source files and documented never-claimed patterns
301
+ (`*.d.ts`, `*.config.*`, fixtures, mocks; `--explain` lists them) are
302
+ excluded and said so; `--strict` fails a change that evaluated nothing.
303
+
304
+ With a reference known, `status --changed <ref>` reports `unclaimed-changes`
305
+ **before** any evidence state and makes the claim the next action; the brief
306
+ lists the unclaimed files first. The claim is written before more code.
307
+
308
+ **In CI the base is detected** — GitHub Actions (`GITHUB_BASE_REF`) and GitLab
309
+ merge request pipelines (`CI_MERGE_REQUEST_DIFF_BASE_SHA`, then
310
+ `CI_MERGE_REQUEST_TARGET_BRANCH_NAME`); `TESTGUARD_CHANGED_REF` overrides both.
311
+ The base must exist locally: GitHub — `actions/checkout` with `fetch-depth: 0`;
312
+ GitLab — the diff base sha needs nothing extra on a merge request pipeline,
313
+ the branch name needs `GIT_DEPTH: 0` or a `git fetch origin <target>`. A
314
+ detected base that does not resolve is a warning for `status` and `brief`
315
+ (they keep working) and an error for `gate` (its whole job is the measurement).
316
+
317
+ ```yaml
318
+ # GitHub Actions
319
+ - uses: actions/checkout@v4
320
+ with: { fetch-depth: 0 }
321
+ - uses: raccioly/testguard@v0.6.0
322
+ with: { command: gate }
323
+
324
+ # GitLab CI — the component-shaped template: gate + probe, brief as an artifact and, opted in, as a merge-request note
325
+ include:
326
+ - remote: 'https://raw.githubusercontent.com/raccioly/testguard/v0.5.0/packaging/gitlab/testguard.gitlab-ci.yml'
327
+ inputs: { dir: backend, post_note: true } # post_note needs TESTGUARD_GITLAB_TOKEN (api scope); one note, updated in place
328
+ ```
329
+
330
+ The template carries `spec: inputs:` (`version`, `dir`, `image`, `severity`,
331
+ `confirm`, `budget`, `no_escalate`, `strict`, `post_note`, `stage`), so
332
+ mirrored into a GitLab project it is a catalog component that a compliance
333
+ framework can require on every project in a group. The CLI itself never
334
+ talks to the network; only the job posts, and only when told to.
335
+
336
+ ### Properties
337
+
338
+ - **Deterministic measurement.** No model decides a verdict. Faults are
339
+ string substitutions from a reviewed file; verdicts come from the test
340
+ runner's structured report, confirmed N times. The same inputs give the
341
+ same evidence, which is what makes it replayable for an auditor.
342
+ - **No network, no telemetry.** The CLI never opens a socket. The
343
+ session-start hook resolves an installed binary and never fetches one.
344
+ Nothing leaves the machine unless a CI job you configured posts it.
345
+ - **Artifacts are data in the repository.** Claims, evidence, baseline and
346
+ brief are JSON validated against a published schema before they are
347
+ written. Nothing is healed, regenerated or re-targeted at run time; a
348
+ claims file is code, and is reviewed like code.
349
+ - **Never optimistic.** A timeout, a load failure, a mixed N-run result, a
350
+ flaky defender or a missing anchor is reported as unproven, never rounded
351
+ toward green. The one pass is `killed`; everything else gates.
352
+ - **One exact-pinned runtime dependency** (`ajv`), Node ≥ 20, MIT.
353
+
354
+ ### What TestGuard is not
355
+
356
+ - **Not a test generator.** It judges a test the agent wrote (`admit`); the
357
+ generating half stays with the agent, where the market is putting it.
358
+ - **Not a mutation-score dashboard.** No blanket mutants, no single score, no
359
+ threshold. Faults are few and bound to stated claims; findings are ranked,
360
+ never summed.
361
+ - **Not a self-healing runner.** A defender that adapts itself to a change is
362
+ a defender that did not observe it; TestGuard reports that, it does not do
363
+ it.
364
+ - **Not a coverage tool.** A line executed says nothing about whether an
365
+ assertion would notice. `NOCOVER` here means no test even imports the
366
+ file; everything above that is measured by injecting the fault.
367
+
368
+ A claim that **disappeared** is invisible to all of the above: `probe`
369
+ verifies the claims that exist, `gate` sees the source file covered by some
370
+ other claim, and `status` says clean. Deleting a claim is therefore cheaper
371
+ than weakening its fault — and `changedFaults` exists precisely because
372
+ weakening was the cheap escape. So:
373
+
374
+ ```bash
375
+ npx testguard-cli claims --since origin/main # what existed there and does not now
376
+ ```
377
+
378
+ `removed-claim` and `removed-fault` gate; a rename that keeps the statement
379
+ verbatim is reported as `renamed-claim` and does not. Where evidence is
380
+ present, the line names the verdict the claim last had, because *"it was
381
+ SURVIVED when it was removed"* is the sentence that matters. The escape hatch
382
+ is the same one as everywhere else: a `claim` entry in
383
+ `testguard.ignore.json` with a reason, expiring if you give it an `expires`.
384
+ Removal is allowed — claims can be wrong, superseded or split — and it is
385
+ never silent.
386
+
387
+ ### Run it from any harness
388
+
389
+ The operating loop above lives in a Claude Code skill and a session-start
390
+ hook. Both vanish the moment the harness is Cursor, Codex, Devin or whatever
391
+ comes next — and the harness is exactly the layer most likely to change. So
392
+ the same documents are also served over the Model Context Protocol:
393
+
394
+ ```bash
395
+ npx testguard-cli mcp # JSON-RPC 2.0 on stdio
396
+ npx testguard-cli init --mcp # prints the config for Claude Code, Cursor and Codex
397
+ ```
398
+
399
+ Five tools, all **read-only**: `testguard_status`, `testguard_brief`,
400
+ `testguard_claims`, `testguard_evidence`, `testguard_next_command`.
401
+
402
+ **No tool runs a probe.** A probe is long-running, budgeted, and the person
403
+ should see it happen, so `next_command` hands back the exact shell line for
404
+ the agent to run in its own terminal. Nothing here writes a file either —
405
+ editing a claims file through a connector would defeat the point of recording
406
+ every fault edit. The server declares only a `tools` capability: no prompts,
407
+ no resources, no sampling.
408
+
409
+ It is hand-written against a pinned protocol version rather than built on the
410
+ official SDK, because this tool has one exact-pinned runtime dependency and
411
+ keeps it that way. The surface is three methods and five tools and will not
412
+ grow; tests speak the wire format to a real child process and check each
413
+ tool against the CLI's own `--json` output, so the two cannot drift.
148
414
 
149
415
  ### Built for agents to run
150
416
 
@@ -156,11 +422,30 @@ things make that safe:
156
422
  working tree. Every human rendering — the CLI text, the session-start
157
423
  brief, the skill — derives from it, so they cannot disagree. Every command
158
424
  accepts `--json`.
159
- - **An installable operating loop.** `testguard init` writes
425
+ - **An installable operating loop.** `testguard init [dir]` writes the
426
+ **agent layer at the git root**, where agent sessions run —
160
427
  `.claude/skills/testguard/SKILL.md` (state → action, verdict → the only
161
428
  acceptable fix, the two-gate rule for any test the agent writes), the
162
- `brief --text` session-start hook, an `AGENTS.md` section and the
163
- `.gitignore` lines. Idempotent.
429
+ `brief --text` session-start hook, an `AGENTS.md` section — and the
430
+ **project layer** (`.gitignore` lines) beside the claims file. A second
431
+ project in the same repository adds a hook line and an `AGENTS.md`
432
+ bullet; `--here` keeps everything in the subdirectory. The hook
433
+ resolves the project's `node_modules/.bin/testguard`, then the git root's,
434
+ then a `testguard` on `PATH` via `command -v`, then does nothing — **no
435
+ `npx` in any form, so no path to the network**; a pre-0.6 `npx -y` or
436
+ `npx --no-install` hook is replaced. A written file that
437
+ `.gitignore` swallows is reported, not offered for commit. Idempotent.
438
+ - **Independence is recorded (L3).** `probe` measures *power* — would this
439
+ test notice if the code were wrong. It also records, on every kill, whether
440
+ the killing test was last touched by the same change (or the same author) as
441
+ the code it guards: `detail.independence` is `co-authored`,
442
+ `separate-change` or `unknown`. A co-authored kill is legitimate — a fix
443
+ *should* ship with its regression test — but a repository where every kill
444
+ is co-authored has no independent verification, whatever its claim
445
+ verification rate says. It is a **signal**: ranking reads it, verdicts never
446
+ do, and the brief says it in one line. Nothing else in this category
447
+ measures it, and agents saturate the tests they can see
448
+ ([SpecBench](https://arxiv.org/abs/2605.21384)).
164
449
  - **Gaming is visible.** The cheapest way to make a survivor disappear is to
165
450
  weaken its fault, not to write a test. Evidence records every fault's
166
451
  content hash; `status` lists any fault edited after it survived, with its
@@ -170,7 +455,7 @@ things make that safe:
170
455
  ### Authoring faults mechanically
171
456
 
172
457
  Writing faults by hand means reading the code to find exact anchors. Two
173
- field reports found that ~80% of hand-written faults are one of five shapes,
458
+ field reports found that ~80% of hand-written faults are one of nine shapes,
174
459
  so `scaffold` proposes them for you:
175
460
 
176
461
  ```bash
@@ -185,6 +470,10 @@ npx testguard-cli scaffold src/auth.ts --claim AUTH-ADMIN # every proposal und
185
470
  | `return-altered` | `return <check>;` (`===`, `.includes(`, `&&`, …) → `return true;` |
186
471
  | `literal-changed` | `httpOnly`/`secure` flipped, `sameSite` → `none`, a cost/rounds → `1`, a ttl/tolerance/window/limit ×1000 |
187
472
  | `call-removed` | a bare `verify…()` / `validate…()` / `check…()` / `authorize…()` call removed |
473
+ | `field-dropped` | a field removed from an object that is returned, built by an arrow, assigned to a payload-ish name, passed to a `save`/`update`/`send`/`write`/… call, or is a `z.object({…})`-style schema; a string entry removed from an allow-list array; a `...base,` line or inline `{ ...base, … }` merge dropped. Only a line that can go on its own (balanced, comma-terminated or followed by the closer); never inside tests, fixtures or migrations |
474
+ | `argument-swapped` | a call kept, its first argument swapped for `undefined` (and `{}` when the argument is itself a call) — only when that argument is derived from a parameter of the enclosing function or a request-like value (`req`, `ctx`, `event`, …). The seam fault: `decide(deriveFrom(input), now)` → `decide(undefined, now)` |
475
+ | `element-removed` | a one-line JSX element — self-closing (`<Toggle … />`) or paired (`<button …>Save</button>`) — removed; the UI shape behind "the toggle is invisible", killable by a browser-layer defender |
476
+ | `handler-dropped` | an `on<Event>={…}` prop removed, whether it is its own line or inline in the tag; the control renders and does nothing |
188
477
 
189
478
  Every proposal's `find` is the exact line with `expectHits`/`occurrence`
190
479
  computed from the file, so it is verifiable by construction; provenance is
@@ -220,13 +509,18 @@ through every verdict.
220
509
 
221
510
  ## Status
222
511
 
223
- **v0.4.** Seven commands, vitest and jest runners, hand-authored faults plus
224
- a mechanical scaffold, and an agent operating layer (`status`, `init`). The contract
225
- spine — six JSON Schemas shared with the other Guard tools — is under
512
+ **v0.5.** Eleven commands (`status`, `init`, `claims`, `probe`, `admit`, `replay`, `baseline`, `brief`, `gate`, `scaffold`, `mcp`), vitest and jest runners, hand-authored faults plus
513
+ **v0.5.** Nine commands (`status`, `init`, `claims`, `probe`, `admit`, `baseline`, `brief`, `gate`, `scaffold`), vitest, jest and Playwright runners, hand-authored faults plus
514
+ a mechanical scaffold, an agent operating layer (`status`, `init`) and a
515
+ change gate (`gate`). The contract
516
+ spine — eight JSON Schemas shared with the other Guard tools — is under
226
517
  [`spec/`](spec/). One exact-pinned runtime dependency (`ajv`, for schema validation); Node ≥ 20.
227
518
 
228
- Not yet: test generation (the two-gate acceptance loop), runners beyond
229
- vitest and jest, AST-aware producers, and calibration of fault classes
519
+ Not yet: test generation (the acceptance half, `admit`, exists; the generating half stays the agent's), runners beyond
520
+ vitest and jest, AST-aware producers, and the transfer of a calibration between repositories — `replay` measures it
521
+ now; whether it carries to a repository with no history is unproven. Each is
522
+ designed for; none is claimed.
523
+ vitest, jest and Playwright, AST-aware producers, and calibration of fault classes
230
524
  against real escaped bugs. Each is designed for; none is claimed.
231
525
 
232
526
  ## Licence
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testguard-cli",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Proves a test suite defends the claims a project makes: injects the faults those claims forbid and reports every one the tests fail to detect.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,7 +32,14 @@ Rules that follow from the table:
32
32
  timeout does not, and a suite that fails to load does not: neither is
33
33
  evidence that the suite defends the claim. Tools parse the runner's
34
34
  structured report, never its exit code, because the exit code cannot tell
35
- these apart.
35
+ these apart. A runner's own retry mechanism does not change this: a test
36
+ that failed and then passed on retry (Playwright's `flaky`) is a
37
+ **non-green run** even when the runner exits 0, and a test the runner
38
+ reports as timed out (`timedOut`) is a timeout, never an assertion
39
+ failure. When one claim's defenders run under several runners, their
40
+ runs merge pessimistically — any load error is an error, any timeout is
41
+ a timeout, any failure is a failure — and the evidence names which file
42
+ ran under which runner.
36
43
  4. **A mixed result is not a kill.** If some of the N probe runs fail and
37
44
  others pass, the defender's response to the fault is nondeterministic.
38
45
  Reporting `killed` would be the optimistic bias flakiness introduces;
@@ -43,6 +50,11 @@ Rules that follow from the table:
43
50
  and `detail.undeclaredKillers` names the tests, so the author can fix
44
51
  `defendedBy`. The claim's stated evidence chain is broken even though the
45
52
  suite is not blind. It gates, and ranks below a true survivor.
53
+ Attribution runs **up to N times**, and stops as soon as no test has failed
54
+ in every run so far: from there no further run can name a killer. The
55
+ evidence records the early stop (`detail.escalationStoppedEarly`). Stopping
56
+ can only fail to *name* a killer, never upgrade a verdict, which is the
57
+ pessimistic side.
46
58
  6. **A verdict names the commit it is about.** Evidence records `repo.head`;
47
59
  when the working tree was probed instead, `repo.snapshot` holds the
48
60
  throwaway commit that captured it. A tool must refuse to probe a commit
@@ -57,6 +69,35 @@ Rules that follow from the table:
57
69
  changed since it was probed, with its previous verdict. The change is
58
70
  allowed; it is never invisible.
59
71
 
72
+ ## Replay reports; it never gates
73
+
74
+ A replayed bug is history. It escaped, by definition, which means the suite
75
+ missed it; a gate on that is a gate nobody can pass on a first run, and it
76
+ would only teach teams to stop looking. `replay` therefore always exits `0`
77
+ unless it could not run at all.
78
+
79
+ Its verdicts mirror the probe's, for the same reasons:
80
+
81
+ | Verdict | Meaning |
82
+ |---|---|
83
+ | `caught` | a remaining test failed **by assertion** on the reverted source, every run. The suite knew. |
84
+ | `blind` | the suite stayed green on known-broken code. |
85
+ | `nocover` | no test imports the reverted files. Worse than `blind`: nothing was even tried. |
86
+ | `unverifiable` | the revert did not apply, the suite failed to load, or it timed out. Carries a `reason`. |
87
+ | `flaky` | the runs disagreed. A flaky failure reads as "the suite caught it", so flakiness biases this metric **optimistically** — mixed runs are never `caught`. |
88
+
89
+ Two rules that follow:
90
+
91
+ 1. **One patch, one row.** De-duplicate by `git patch-id`; a dual-branch
92
+ topology carries the same fix under two or three shas, and counting it
93
+ twice corrupts the corpus a calibration is computed from.
94
+ 2. **The fix's own test is removed before the run.** It proves nothing about
95
+ what the suite knew before the fix existed.
96
+
97
+ Only `caught` and `blind` carry information. `nocover`, `flaky` and
98
+ `unverifiable` are excluded from both sides of a calibration ratio, because a
99
+ number computed over them would mean nothing.
100
+
60
101
  ## Baseline and delta
61
102
 
62
103
  A baseline freezes the fingerprints of every non-passing finding at a point
@@ -72,6 +113,15 @@ derived from identities and outcome, never from the fault's `find`/`replace`
72
113
  text, so repairing a rotted anchor does not churn the baseline — while a
73
114
  change of verdict on the same claim+subject does surface as new.
74
115
 
116
+ A baseline records the commit its evidence was taken at. When that evidence
117
+ came from a working-tree snapshot, the baseline says so (`snapshot`) and its
118
+ `head` is the **parent** of the commit that will carry the tests. A tool may
119
+ offer to re-stamp such a baseline onto a later commit, but only when a clean
120
+ probe of that commit reproduced exactly the same fingerprints; otherwise the
121
+ user re-probes and freezes a new baseline. A frozen contract is never
122
+ silently rewritten, and a status document notes a baseline that predates
123
+ HEAD without turning that into a state.
124
+
75
125
  Adopting tools may reconcile an existing baseline format (for example a
76
126
  `{version, fingerprints:{hash:count}}` file) by mapping it onto this shape;
77
127
  the suppress-up-to-count semantics are identical.
@@ -84,6 +134,64 @@ the suppress-up-to-count semantics are identical.
84
134
  for reasonless `path` entries.
85
135
  - An annotation is **strictly additive**. It never changes, suppresses, or
86
136
  drops a finding. Ranking may read annotations; verdicts never do.
137
+ - A **signal** is a static, annotation-grade fact about a defender, recorded
138
+ on the evidence and never a verdict. `mocked-never-asserted`: a test file
139
+ mocks the subject's module and never asserts on anything imported from it.
140
+ An author may silence it with `unasserted: <reason>` above the mock; the
141
+ tool then records `unasserted-annotated` with the reason — silenced, never
142
+ hidden. A file that mocks the subject is never a discovered defender: a
143
+ mock cannot detect a fault in what it replaced.
144
+
145
+ ## Claim coverage of a change
146
+
147
+ `probe` asks whether the tests defend the claims that exist. It says nothing
148
+ about code that has no claim, by construction — and every escaped defect the
149
+ field reports share was a *claim gap*, not a defender gap: the feature shipped
150
+ green with zero claims. A repository with ten old claims and one new,
151
+ unclaimed module exits 0 under `probe` forever.
152
+
153
+ The change gate closes that hole. Given a reference, a tool measures the
154
+ **delta** — the files changed since `merge-base(ref, HEAD)`, or since that
155
+ merge-base in the working tree — file by file:
156
+
157
+ - A changed file is **excluded** when it is not source (by extension) or
158
+ matches a documented never-claimed pattern (`*.d.ts`, `*.config.*`,
159
+ fixtures, mocks, snapshots, the tool's own directory). Exclusions are
160
+ listed, never silent.
161
+ - A changed source file is **covered** when at least one fault anchors to it,
162
+ when it is a test file that resolves as a defender of some claim, or when an
163
+ **unexpired** `path` ignore entry excuses it.
164
+ - Anything else is **uncovered**. The unit is the file, on purpose: a fault
165
+ anchors to a file, so the file is the smallest unit the rest of the
166
+ contract already understands.
167
+
168
+ Rules:
169
+
170
+ 1. **One uncovered file gates.** Exit `1`. There is no threshold and no
171
+ percentage; a percentage is how the gap hid before.
172
+ 2. **Every reliance on an ignore entry is reported.** The gate may pass
173
+ *because of* an excuse; a reviewer reads which entries carried it, with
174
+ their reasons and expiry. An expired entry excuses nothing and is reported
175
+ as expired.
176
+ 3. **A test file needs a claim too.** A test that defends no claim is the
177
+ authorship trap the pattern exists for; it is uncovered until a claim
178
+ names it in `defendedBy` (or discovery resolves it as a defender).
179
+ 4. **Never a silent pass on nothing.** A non-empty change whose files were
180
+ all excluded passes with an explicit "0 evaluated" line; a tool offers a
181
+ strict mode that fails it instead. An empty change is an honest `0`.
182
+ 5. **The reference is never guessed.** An explicit flag, or a CI-provided base
183
+ branch, or an error. An upstream that already contains the change has an
184
+ empty diff and would pass trivially.
185
+ 6. **Unclaimed changes precede every evidence state.** When the status
186
+ document knows a reference and finds uncovered files, its state is
187
+ `unclaimed-changes` and its next action is to write the claim, before any
188
+ `unproven` finding is surfaced. The brief renders them first. The claim is
189
+ written before more code.
190
+
191
+ Exit codes: `0` every changed source file is claimed or excused (or nothing
192
+ changed); `1` at least one uncovered file (or strict mode over an
193
+ all-excluded change); `2` the change cannot be evaluated (unresolvable
194
+ reference, invalid claims or ignore file, no repository); `3` no reference.
87
195
 
88
196
  ## Severity floor
89
197
 
package/spec/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Guard spec — the contract spine
2
2
 
3
- Six formats that tools following the Guard pattern share. They share
3
+ Eight formats that tools following the Guard pattern share. They share
4
4
  *formats*, not code: [`docguard-cli`](https://www.npmjs.com/package/docguard-cli)
5
5
  is Node ESM, [`websec-validator`](https://pypi.org/project/websec-validator/)
6
6
  is Python, and porting one runtime into the other is not worth it. A tool
@@ -21,7 +21,9 @@ The pattern all such tools run:
21
21
  | `ignore` | [`schemas/ignore.schema.json`](schemas/ignore.schema.json) | Reviewable scoping. Every entry has a reason. |
22
22
  | `calibration` | [`schemas/calibration.schema.json`](schemas/calibration.schema.json) | P(finding is real) per bucket, with a Wilson interval and the sample size behind it. |
23
23
  | `brief` | [`schemas/brief.schema.json`](schemas/brief.schema.json) | What to tell an agent before it writes code — ranked, capped, never a single score, and carrying the one next action. |
24
- | `status` | [`schemas/status.schema.json`](schemas/status.schema.json) | Where the project is and what happens next — the single machine-readable truth every human rendering derives from. Surfaces faults whose content changed since they were probed. |
24
+ | `status` | [`schemas/status.schema.json`](schemas/status.schema.json) | Where the project is and what happens next — the single machine-readable truth every human rendering derives from. Surfaces faults whose content changed since they were probed, and changed files that carry no claim. |
25
+ | `replay` | [`schemas/replay.schema.json`](schemas/replay.schema.json) | Would this suite have caught the bugs that already escaped? Replays real fix commits: revert the source, remove the test the fix shipped, run what remains. A replayed bug is ground truth — a human already confirmed it was a defect — which is what a fault model is calibrated against. |
26
+ | `gate` | [`schemas/gate.schema.json`](schemas/gate.schema.json) | Claim coverage of one change: which changed files carry a claim, which are excused (and by which ignore entry), which are unclaimed. The delta gate for code that has no claim yet. |
25
27
 
26
28
  Shared definitions (verdicts, fault classes, provenance, annotations) live in
27
29
  [`schemas/common.schema.json`](schemas/common.schema.json). Gate behaviour —