testguard-cli 0.5.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 (47) hide show
  1. package/CHANGELOG.md +255 -0
  2. package/README.md +275 -31
  3. package/package.json +1 -1
  4. package/spec/GATE-SEMANTICS.md +58 -1
  5. package/spec/README.md +1 -0
  6. package/spec/lib/validate.mjs +57 -1
  7. package/spec/schemas/baseline.schema.json +8 -0
  8. package/spec/schemas/common.schema.json +5 -1
  9. package/spec/schemas/evidence.schema.json +374 -63
  10. package/spec/schemas/replay.schema.json +85 -0
  11. package/spec/schemas/status.schema.json +256 -54
  12. package/src/admit/admit.mjs +17 -0
  13. package/src/baseline/baseline.mjs +31 -2
  14. package/src/brief/brief.mjs +71 -7
  15. package/src/claims/removed.mjs +161 -0
  16. package/src/cli.mjs +53 -10
  17. package/src/commands/admit.mjs +116 -0
  18. package/src/commands/baseline.mjs +17 -3
  19. package/src/commands/brief.mjs +13 -7
  20. package/src/commands/claims.mjs +35 -6
  21. package/src/commands/init.mjs +19 -6
  22. package/src/commands/mcp.mjs +12 -0
  23. package/src/commands/probe.mjs +11 -3
  24. package/src/commands/replay.mjs +63 -0
  25. package/src/commands/status.mjs +3 -1
  26. package/src/git.mjs +5 -0
  27. package/src/init/init.mjs +182 -29
  28. package/src/init/templates/SKILL.md +3 -3
  29. package/src/mcp/server.mjs +104 -0
  30. package/src/mcp/tools.mjs +114 -0
  31. package/src/probe/contention.mjs +60 -0
  32. package/src/probe/discover.mjs +12 -3
  33. package/src/probe/independence.mjs +59 -0
  34. package/src/probe/mocks.mjs +120 -0
  35. package/src/probe/probe.mjs +126 -18
  36. package/src/probe/rank.mjs +73 -15
  37. package/src/probe/runners/index.mjs +49 -5
  38. package/src/probe/runners/jest.mjs +3 -3
  39. package/src/probe/runners/playwright.mjs +133 -0
  40. package/src/probe/runners/shared.mjs +70 -13
  41. package/src/probe/runners/vitest.mjs +4 -3
  42. package/src/render.mjs +2 -2
  43. package/src/replay/label.mjs +64 -0
  44. package/src/replay/replay.mjs +309 -0
  45. package/src/scaffold/producers.mjs +217 -6
  46. package/src/scaffold/scaffold.mjs +9 -3
  47. package/src/status/status.mjs +47 -6
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.5.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
@@ -70,6 +84,7 @@ npx testguard-cli baseline # freeze today's unproven findings; from now on on
70
84
  npx testguard-cli brief # tell the agent where the suite is blind, before it writes
71
85
  npx testguard-cli gate --changed origin/main # fail when a changed source file carries no claim at all
72
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
73
88
  ```
74
89
 
75
90
  1. **Claims** live in `testguard.claims.json` (editors validate it against
@@ -83,7 +98,12 @@ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to
83
98
  2. **Probe** confirms the defenders are green N times unmodified, applies
84
99
  each fault in a scratch git worktree (your tree is never touched), runs
85
100
  the defenders N times, re-runs survivors against the whole suite with
86
- 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:
87
107
 
88
108
  | Verdict | Meaning |
89
109
  |---|---|
@@ -93,7 +113,7 @@ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to
93
113
  | `UNVERIFIABLE` | the fault's anchor is missing or ambiguous — loud, never a skip |
94
114
  | `TIMEOUT` | the defenders hung; a hang is not a detection |
95
115
  | `FAULT-INVALID` | the replacement does not load — a bad fault, not a finding |
96
- | `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) |
97
117
 
98
118
  Never a single score. Findings are ranked by severity, claim provenance
99
119
  and blast radius (relative imports, `tsconfig` path aliases and
@@ -102,20 +122,48 @@ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to
102
122
  it is written.
103
123
 
104
124
  Worktree mode probes a **commit**. If a defender or target file has
105
- uncommitted changes, `probe` refuses and says so — otherwise your new
106
- tests would be silently absent and the same survivors would come back
107
- with no hint why. `--include-dirty` snapshots the working tree (tracked
108
- edits and new files) into a throwaway commit and probes that; your tree,
109
- 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.
110
134
 
111
135
  A claim with no `defendedBy` has its defenders **discovered**: the test
112
- files that import the fault's target, by relative path or resolved alias.
113
- `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.
114
148
 
115
149
  Runners: **vitest** and **jest** (`--runner auto` picks the first that
116
- resolves; both read the same jest-compatible JSON report). Anything else
117
- goes through `--runner-cmd`. Each runner is proven against its own copy
118
- 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.
119
167
 
120
168
  Practical loop: first pass `--no-escalate` (escalation re-runs the whole
121
169
  suite N times per survivor); iterate on one claim with `--claim <ID>` and
@@ -132,20 +180,105 @@ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to
132
180
  3. **Baseline** freezes every non-passing fingerprint. Later probes suppress
133
181
  what was already known and exit non-zero only on what is new. Claims whose
134
182
  source and defenders are unchanged reuse their prior verdict, so a probe
135
- 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.
136
189
  4. **Brief** turns evidence plus baseline into a ranked, capped
137
190
  `## TEST BLINDSPOT CONTEXT` block, printed and also written to
138
- `.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
139
193
  — for Claude Code, in `.claude/settings.json`:
140
194
 
141
195
  ```json
142
196
  { "hooks": { "SessionStart": [ { "hooks": [
143
- { "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" }
144
198
  ] } ] } }
145
199
  ```
146
200
 
147
- `--text` prints only, and exits 0 silently when there is no evidence yet,
148
- 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.
149
282
 
150
283
  ### Every change needs a claim
151
284
 
@@ -185,16 +318,100 @@ detected base that does not resolve is a warning for `status` and `brief`
185
318
  # GitHub Actions
186
319
  - uses: actions/checkout@v4
187
320
  with: { fetch-depth: 0 }
188
- - uses: raccioly/testguard@v0.5.0
321
+ - uses: raccioly/testguard@v0.6.0
189
322
  with: { command: gate }
190
323
 
191
- # GitLab CI — or include: remote: the template in packaging/gitlab/
192
- testguard:gate:
193
- image: node:22
194
- rules: [{ if: $CI_PIPELINE_SOURCE == "merge_request_event" }]
195
- script: [npx -y testguard-cli gate .]
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
196
397
  ```
197
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.
414
+
198
415
  ### Built for agents to run
199
416
 
200
417
  TestGuard is meant to be driven by an AI agent, not typed by a person. Three
@@ -205,11 +422,30 @@ things make that safe:
205
422
  working tree. Every human rendering — the CLI text, the session-start
206
423
  brief, the skill — derives from it, so they cannot disagree. Every command
207
424
  accepts `--json`.
208
- - **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 —
209
427
  `.claude/skills/testguard/SKILL.md` (state → action, verdict → the only
210
428
  acceptable fix, the two-gate rule for any test the agent writes), the
211
- `brief --text` session-start hook, an `AGENTS.md` section and the
212
- `.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)).
213
449
  - **Gaming is visible.** The cheapest way to make a survivor disappear is to
214
450
  weaken its fault, not to write a test. Evidence records every fault's
215
451
  content hash; `status` lists any fault edited after it survived, with its
@@ -219,7 +455,7 @@ things make that safe:
219
455
  ### Authoring faults mechanically
220
456
 
221
457
  Writing faults by hand means reading the code to find exact anchors. Two
222
- 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,
223
459
  so `scaffold` proposes them for you:
224
460
 
225
461
  ```bash
@@ -234,6 +470,10 @@ npx testguard-cli scaffold src/auth.ts --claim AUTH-ADMIN # every proposal und
234
470
  | `return-altered` | `return <check>;` (`===`, `.includes(`, `&&`, …) → `return true;` |
235
471
  | `literal-changed` | `httpOnly`/`secure` flipped, `sameSite` → `none`, a cost/rounds → `1`, a ttl/tolerance/window/limit ×1000 |
236
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 |
237
477
 
238
478
  Every proposal's `find` is the exact line with `expectHits`/`occurrence`
239
479
  computed from the file, so it is verifiable by construction; provenance is
@@ -269,14 +509,18 @@ through every verdict.
269
509
 
270
510
  ## Status
271
511
 
272
- **v0.4.** Eight commands, vitest and jest runners, hand-authored faults plus
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
273
514
  a mechanical scaffold, an agent operating layer (`status`, `init`) and a
274
515
  change gate (`gate`). The contract
275
516
  spine — eight JSON Schemas shared with the other Guard tools — is under
276
517
  [`spec/`](spec/). One exact-pinned runtime dependency (`ajv`, for schema validation); Node ≥ 20.
277
518
 
278
- Not yet: test generation (the two-gate acceptance loop), runners beyond
279
- 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
280
524
  against real escaped bugs. Each is designed for; none is claimed.
281
525
 
282
526
  ## Licence
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testguard-cli",
3
- "version": "0.5.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,13 @@ 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.
87
144
 
88
145
  ## Claim coverage of a change
89
146
 
package/spec/README.md CHANGED
@@ -22,6 +22,7 @@ The pattern all such tools run:
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
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. |
25
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. |
26
27
 
27
28
  Shared definitions (verdicts, fault classes, provenance, annotations) live in
@@ -6,7 +6,7 @@ import { fingerprint } from './fingerprint.mjs';
6
6
 
7
7
  const schemaDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'schemas');
8
8
 
9
- export const KINDS = Object.freeze(['claims', 'evidence', 'baseline', 'ignore', 'calibration', 'brief', 'status', 'gate']);
9
+ export const KINDS = Object.freeze(['claims', 'evidence', 'baseline', 'ignore', 'calibration', 'brief', 'status', 'gate', 'replay']);
10
10
  export const PASSING_VERDICTS = Object.freeze(new Set(['killed']));
11
11
 
12
12
  const ajv = new Ajv2020({ strict: true, allErrors: true });
@@ -47,6 +47,8 @@ const semantic = {
47
47
  const n = doc.run.confirmRuns;
48
48
  if (n < 3 && doc.run.provisional !== true) errors.push({ path: '/run/provisional', message: `confirmRuns ${n} is below 3; the run must declare provisional: true` });
49
49
  if (n >= 3 && doc.run.provisional === true) errors.push({ path: '/run/provisional', message: `confirmRuns ${n} is confirmed; provisional must be absent or false` });
50
+ if (doc.run.runners && doc.run.runner && !doc.run.runners.some((x) => x.name === doc.run.runner.name)) errors.push({ path: '/run/runners', message: 'runners must include the project runner named in run.runner' });
51
+ if (doc.run.repo.ignoredDirty && doc.run.repo.snapshot) errors.push({ path: '/run/repo/ignoredDirty', message: 'a working-tree snapshot has no ignored dirty files: the tree was probed as it is' });
50
52
  doc.records.forEach((r, i) => {
51
53
  const p = `/records/${i}`;
52
54
  const expected = fingerprint({ claimId: r.claim.id, subjectId: r.subject.id, file: r.subject.file ?? '', verdict: r.verdict });
@@ -74,9 +76,31 @@ const semantic = {
74
76
  if (r.verdict === 'nocover' && !r.defenders.nocover) {
75
77
  errors.push({ path: `${p}/defenders/nocover`, message: 'nocover verdict requires defenders.nocover = true' });
76
78
  }
79
+ if (r.detail.flakeRate) {
80
+ const { runs, failures } = r.detail.flakeRate;
81
+ if (failures > runs) errors.push({ path: `${p}/detail/flakeRate`, message: `flakeRate failures (${failures}) exceed runs (${runs})` });
82
+ if (failures > 0 && r.verdict !== 'flaky-defender') errors.push({ path: `${p}/detail/flakeRate`, message: 'a defender that failed on unmodified source makes the verdict flaky-defender' });
83
+ }
84
+ if (r.detail.independence) {
85
+ if (r.verdict !== 'killed') errors.push({ path: `${p}/detail/independence`, message: 'independence is a property of a kill; only killed records carry it' });
86
+ const { class: cls, defenderCommit, targetCommit } = r.detail.independence;
87
+ if (cls !== 'unknown' && !(defenderCommit && targetCommit)) errors.push({ path: `${p}/detail/independence`, message: `independence class ${cls} requires both defenderCommit and targetCommit` });
88
+ }
77
89
  if (r.detail.undeclaredKillers && r.detail.reason !== 'killed-by-undeclared-tests') {
78
90
  errors.push({ path: `${p}/detail/undeclaredKillers`, message: 'undeclaredKillers is only meaningful with reason killed-by-undeclared-tests' });
79
91
  }
92
+ if (r.defenders.byRunner) {
93
+ const resolved = new Set(r.defenders.resolved);
94
+ for (const [runner, files] of Object.entries(r.defenders.byRunner)) for (const f of files) if (!resolved.has(f)) errors.push({ path: `${p}/defenders/byRunner/${runner}`, message: `${f} ran under ${runner} but is not a resolved defender` });
95
+ if (Object.keys(r.defenders.byRunner).length < 2) errors.push({ path: `${p}/defenders/byRunner`, message: 'byRunner is only meaningful when more than one runner ran the defenders' });
96
+ }
97
+ if (r.defenders.mocking && r.defenders.discovered) {
98
+ for (const f of r.defenders.mocking) if (r.defenders.resolved.includes(f)) errors.push({ path: `${p}/defenders/mocking`, message: `${f} mocks the subject and was discovered; it cannot also be a resolved defender` });
99
+ }
100
+ for (const s of r.defenders.signals ?? []) {
101
+ if (s.signal === 'unasserted-annotated' && !s.reason) errors.push({ path: `${p}/defenders/signals`, message: `unasserted-annotated on ${s.file} requires the annotation's reason` });
102
+ if (!(r.defenders.mocking ?? []).includes(s.file)) errors.push({ path: `${p}/defenders/signals`, message: `${s.file} carries a mock signal but is not listed in defenders.mocking` });
103
+ }
80
104
  if (r.verdict === 'unverifiable' && !r.detail.reason) {
81
105
  errors.push({ path: `${p}/detail/reason`, message: 'unverifiable requires a reason (e.g. anchor-missing, anchor-ambiguous)' });
82
106
  }
@@ -104,10 +128,42 @@ const semantic = {
104
128
  if (doc.state === 'clean' && (doc.counts.new ?? 0) > 0) errors.push({ path: '/state', message: 'clean with new findings' });
105
129
  if (doc.state === 'unclaimed-changes' && !(doc.changes && doc.changes.uncovered.length > 0)) errors.push({ path: '/changes/uncovered', message: 'unclaimed-changes requires at least one uncovered changed file' });
106
130
  if (doc.state === 'unclaimed-changes' && doc.next.action !== 'claim') errors.push({ path: '/next/action', message: 'unclaimed-changes requires next.action = claim' });
131
+ if (doc.evidenceSource && !doc.evidenceHead) errors.push({ path: '/evidenceHead', message: 'evidence was read, so the commit it describes must be recorded' });
107
132
  if (doc.changes && doc.changes.uncovered.length > 0 && !['no-claims', 'unclaimed-changes'].includes(doc.state)) errors.push({ path: '/state', message: 'uncovered changed files are hidden behind a later state; unclaimed-changes precedes every evidence state' });
108
133
  return errors;
109
134
  },
110
135
 
136
+ replay(doc) {
137
+ const errors = [];
138
+ const n = doc.run.confirmRuns;
139
+ const seen = new Set();
140
+ doc.records.forEach((r, i) => {
141
+ const p = `/records/${i}`;
142
+ // One patch, one row: a dual-branch topology carries the same fix under
143
+ // two or three shas, and counting it twice corrupts the corpus the
144
+ // calibration is computed from.
145
+ if (seen.has(r.patchId)) errors.push({ path: `${p}/patchId`, message: `duplicate patch-id ${r.patchId.slice(0, 12)}: the same fix counted twice` });
146
+ seen.add(r.patchId);
147
+ if (['caught', 'blind'].includes(r.verdict) && r.runs.length !== n) {
148
+ errors.push({ path: `${p}/runs`, message: `${r.verdict} requires exactly confirmRuns (${n}) runs, got ${r.runs.length}` });
149
+ }
150
+ // Only a test body rejecting the reverted source counts as caught —
151
+ // the same rule as `killed`, for the same reason.
152
+ if (r.verdict === 'caught' && !r.runs.every((x) => x.outcome === 'fail' && (x.assertionFailures ?? 0) > 0)) {
153
+ errors.push({ path: `${p}/runs`, message: 'caught requires every run to fail with at least one assertion failure' });
154
+ }
155
+ if (r.verdict === 'blind' && !r.runs.every((x) => x.outcome === 'pass')) {
156
+ errors.push({ path: `${p}/runs`, message: 'blind requires every run to pass: the suite stayed green on known-broken code' });
157
+ }
158
+ if (r.verdict === 'flaky' && !(r.runs.some((x) => x.outcome === 'pass') && r.runs.some((x) => x.outcome === 'fail'))) {
159
+ errors.push({ path: `${p}/runs`, message: 'flaky requires runs that disagree' });
160
+ }
161
+ if (r.verdict === 'nocover' && r.ranTests) errors.push({ path: `${p}/ranTests`, message: 'nocover means no test exercises the reverted files; it cannot have run tests' });
162
+ if (r.verdict === 'unverifiable' && !r.reason) errors.push({ path: `${p}/reason`, message: 'unverifiable requires a reason (e.g. revert-did-not-apply, suite-failed-to-load)' });
163
+ });
164
+ return errors;
165
+ },
166
+
111
167
  gate(doc) {
112
168
  const errors = [];
113
169
  if (doc.evaluated !== doc.covered.length + doc.uncovered.length) errors.push({ path: '/evaluated', message: `evaluated (${doc.evaluated}) must equal covered (${doc.covered.length}) + uncovered (${doc.uncovered.length})` });
@@ -15,6 +15,14 @@
15
15
  "description": "Whether the working tree had uncommitted changes when the evidence behind this baseline was taken.",
16
16
  "type": "boolean"
17
17
  },
18
+ "snapshot": {
19
+ "description": "When the evidence behind this baseline was a working-tree snapshot: that throwaway commit. `head` is then the parent of the commit that will carry the tests; `baseline --restamp` moves `head` once a clean probe reproduces the same fingerprints.",
20
+ "$ref": "urn:guard-spec:v1:common#/$defs/gitSha"
21
+ },
22
+ "restampedAt": {
23
+ "description": "Set when `head` was moved by `baseline --restamp` (same fingerprints, clean tree). `createdAt` keeps the original freeze time.",
24
+ "$ref": "urn:guard-spec:v1:common#/$defs/isoDateTime"
25
+ },
18
26
  "fingerprints": {
19
27
  "type": "object",
20
28
  "propertyNames": { "pattern": "^[a-f0-9]{64}$" },