testguard-cli 0.5.0 → 0.7.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 (65) hide show
  1. package/CHANGELOG.md +628 -0
  2. package/README.md +536 -42
  3. package/package.json +2 -2
  4. package/spec/GATE-SEMANTICS.md +166 -2
  5. package/spec/README.md +29 -10
  6. package/spec/lib/validate.mjs +140 -8
  7. package/spec/schemas/baseline.schema.json +13 -5
  8. package/spec/schemas/brief.schema.json +12 -12
  9. package/spec/schemas/calibration.schema.json +5 -5
  10. package/spec/schemas/claims.schema.json +122 -25
  11. package/spec/schemas/common.schema.json +131 -29
  12. package/spec/schemas/evidence.schema.json +476 -69
  13. package/spec/schemas/gate.schema.json +12 -12
  14. package/spec/schemas/ignore.schema.json +4 -4
  15. package/spec/schemas/replay.schema.json +85 -0
  16. package/spec/schemas/status.schema.json +258 -56
  17. package/src/admit/admit.mjs +17 -0
  18. package/src/baseline/baseline.mjs +31 -2
  19. package/src/brief/brief.mjs +71 -7
  20. package/src/claims/removed.mjs +161 -0
  21. package/src/cli.mjs +68 -10
  22. package/src/commands/admit.mjs +116 -0
  23. package/src/commands/baseline.mjs +17 -3
  24. package/src/commands/brief.mjs +13 -7
  25. package/src/commands/claims.mjs +55 -6
  26. package/src/commands/init.mjs +19 -6
  27. package/src/commands/mcp.mjs +12 -0
  28. package/src/commands/probe.mjs +35 -7
  29. package/src/commands/replay.mjs +63 -0
  30. package/src/commands/status.mjs +3 -1
  31. package/src/gate/changed.mjs +9 -2
  32. package/src/git.mjs +5 -0
  33. package/src/init/init.mjs +182 -29
  34. package/src/init/templates/SKILL.md +3 -3
  35. package/src/mcp/server.mjs +104 -0
  36. package/src/mcp/tools.mjs +114 -0
  37. package/src/probe/attribution.mjs +123 -0
  38. package/src/probe/classify.mjs +18 -2
  39. package/src/probe/contention.mjs +60 -0
  40. package/src/probe/cost.mjs +145 -0
  41. package/src/probe/discover.mjs +20 -3
  42. package/src/probe/independence.mjs +59 -0
  43. package/src/probe/inject.mjs +41 -9
  44. package/src/probe/mocks.mjs +122 -0
  45. package/src/probe/probe.mjs +331 -40
  46. package/src/probe/progress.mjs +87 -0
  47. package/src/probe/pyimports.mjs +287 -0
  48. package/src/probe/rank.mjs +75 -15
  49. package/src/probe/runners/index.mjs +79 -8
  50. package/src/probe/runners/jest.mjs +15 -3
  51. package/src/probe/runners/playwright.mjs +142 -0
  52. package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
  53. package/src/probe/runners/python/_testguard_report.py +141 -0
  54. package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
  55. package/src/probe/runners/python.mjs +316 -0
  56. package/src/probe/runners/shared.mjs +80 -14
  57. package/src/probe/runners/vitest.mjs +16 -3
  58. package/src/render.mjs +2 -2
  59. package/src/replay/label.mjs +64 -0
  60. package/src/replay/replay.mjs +310 -0
  61. package/src/scaffold/producers.mjs +217 -6
  62. package/src/scaffold/producers.python.mjs +209 -0
  63. package/src/scaffold/scaffold.mjs +90 -30
  64. package/src/status/status.mjs +47 -6
  65. package/src/util/glob.mjs +7 -1
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # TestGuard
2
2
 
3
+ <!-- docguard:quality negation-load off — this README explains a tool defined by what must not happen; the negations are the product. -->
4
+ <!-- docguard:quality passive-voice off — verdicts and artifacts are the subjects throughout ("a fault is applied", "evidence is written"); naming an actor would misdescribe a tool nobody operates interactively. -->
5
+
3
6
  [![CI](https://github.com/raccioly/testguard/actions/workflows/ci.yml/badge.svg)](https://github.com/raccioly/testguard/actions/workflows/ci.yml)
4
7
  [![npm](https://img.shields.io/npm/v/testguard-cli.svg)](https://www.npmjs.com/package/testguard-cli)
5
8
  [![PyPI](https://img.shields.io/pypi/v/testguard-cli.svg)](https://pypi.org/project/testguard-cli/)
@@ -7,9 +10,8 @@
7
10
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
8
11
  [![deps](https://img.shields.io/badge/runtime%20deps-1%20pinned-brightgreen.svg)](./package.json)
9
12
 
10
- > Proves that a test suite actually defends the claims a project makes — by
11
- > injecting the faults those claims say cannot happen, and reporting every
12
- > fault the tests fail to detect.
13
+ > Breaks your code on purpose and reports every promise your tests did not
14
+ > notice breaking.
13
15
 
14
16
  **Not a test generator. A claim verifier.** Test generation is what happens
15
17
  after a claim turns out to be unfalsifiable.
@@ -22,6 +24,65 @@ surface ↔ code). All three run one loop:
22
24
  > declare what must be true → try mechanically to falsify it → freeze a
23
25
  > baseline → gate only the delta → brief the agent before it writes code.
24
26
 
27
+ ## In one minute
28
+
29
+ **Coverage tells you a line ran. It never tells you anyone checked the
30
+ result.** So a codebase can be fully covered and completely undefended, and
31
+ nothing in CI will say a word.
32
+
33
+ Here is a real test, from this repository's own fixture:
34
+
35
+ ```js
36
+ expect(store.writeAudit).toHaveBeenCalledWith(
37
+ expect.objectContaining({ action: 'MASK', scope: 'g1', ruleCount: 1 }),
38
+ ); // `content` is never named — so nothing checks it
39
+ ```
40
+
41
+ `objectContaining` ignores keys it does not list. Swap the redacted text for
42
+ the **raw secret** and this test still passes. Coverage of that line: 100%.
43
+ The audit log now leaks the very thing it exists to protect.
44
+
45
+ **The method: break the code deliberately, then watch what the tests do.**
46
+
47
+ - **killed** — you broke it, a test failed. Good. That behaviour is genuinely
48
+ defended.
49
+ - **SURVIVED** — you broke it, everything stayed green. A blind spot.
50
+
51
+ The word trips everyone once: it is the *fault* that survived, not the test.
52
+ **SURVIVED is the bad news.** A healthy report is full of `killed`.
53
+
54
+ But *"did the tests fail?"* is a sloppy question — a red suite is not proof of
55
+ detection. So there are seven verdicts, and each means something different:
56
+
57
+ | Verdict | What it means |
58
+ |---|---|
59
+ | `killed` | A test body ran and rejected the behaviour. The only good outcome. |
60
+ | `SURVIVED` | Everything passed. A real blind spot in the tests. |
61
+ | `NOCOVER` | No test even looks at this code. Not "weak tests" — *no* tests. |
62
+ | `UNVERIFIABLE` | The fault could not be applied: its anchor moved, or matches twice. Nothing was learned. |
63
+ | `FAULT-INVALID` | The break itself was broken — it did not compile. Our fault, not yours. |
64
+ | `TIMEOUT` | The suite hung. A hang is not a detection. |
65
+ | `FLAKY-DEFENDER` | The tests are not reliable enough on untouched code to be asked the question. |
66
+
67
+ Every ambiguity rounds toward *unproven*: three runs rather than one, a green
68
+ baseline required before any fault is injected, and a timeout, a load failure
69
+ or a mixed result is never a kill. The reason is the constraint the whole
70
+ design follows from — **a tool that reports everything as caught is worse than
71
+ no tool at all, because nobody questions good news.**
72
+
73
+ **What is not new:** breaking code to test your tests is *mutation testing*,
74
+ and it dates to the 1970s. Stryker, PIT, mutmut and Cosmic Ray all do it.
75
+
76
+ **What is different is the question.** Classic mutation testing mutates
77
+ everything mechanically and hands you *"mutation score: 73%"* — a number that
78
+ is not actionable, not auditable, and cannot tell you which promise is at
79
+ risk. TestGuard binds every fault to a **stated claim**, so the output is not
80
+ a score but a finding: *"Your project says a missing scope fails closed.
81
+ Nothing checks that."* That is the difference between a metric and an audit.
82
+
83
+ 📄 **[Read the three-page technical brief (PDF)](docs/testguard-explained.pdf)**
84
+ — the idea on page one, mechanics and prior art after it.
85
+
25
86
  ## Why
26
87
 
27
88
  Coverage cannot tell a test that pins *correct* behaviour from one that pins
@@ -44,6 +105,19 @@ re-implemented the authorization logic *inside the test* and asserted
44
105
  against the copy: fifteen green tests, zero detection. After wrapper-level
45
106
  tests were written against the survivors, 39/39 were killed.
46
107
 
108
+ The peer-reviewed picture in 2026 says the same thing from the other side.
109
+ Coverage and mutation score of LLM-generated suites track effectiveness only
110
+ when the code under test is assumed correct; once it may be buggy they "no
111
+ longer serve as reliable indicators" ([Zhao, Zhou and Cohen, ISSTA 2026](https://arxiv.org/abs/2607.22880)).
112
+ Buggy code steers a model toward tests that assert the bug, and prompting
113
+ with the specification is the mitigation that works ([arXiv 2607.22883](https://arxiv.org/abs/2607.22883)) —
114
+ which is why a claim here comes from intent, a fault is bound to the claim,
115
+ and the brief hands the agent the claim before it writes. And agents
116
+ saturate whatever tests they can see, with the gap to held-out tests
117
+ growing about 28 points per tenfold increase in code size ([SpecBench](https://arxiv.org/abs/2605.21384)).
118
+ Every generator on the market admits a test because it compiles, passes and
119
+ raises coverage. TestGuard admits it because it fails when the claim is false.
120
+
47
121
  ## Install
48
122
 
49
123
  | How | Command |
@@ -52,13 +126,34 @@ tests were written against the survivors, 39/39 were killed.
52
126
  | npm | `npm i -D testguard-cli` then `npx testguard probe` |
53
127
  | pip | `pip install testguard-cli` then `testguard probe` (needs Node ≥ 20) |
54
128
  | Homebrew | `brew tap raccioly/tap && brew install testguard` |
55
- | GitHub Action | `uses: raccioly/testguard@v0.5.0` — see [`action.yml`](./action.yml) |
129
+ | GitHub Action | `uses: raccioly/testguard@v0.7.0` — see [`action.yml`](./action.yml) |
56
130
  | pre-commit | `repo: https://github.com/raccioly/testguard`, hooks `testguard-claims`, `testguard-probe` |
131
+ | GitLab CI | `include: - remote: https://raw.githubusercontent.com/raccioly/testguard/v0.7.0/packaging/gitlab/testguard.gitlab-ci.yml` with `inputs:` — see [`packaging/gitlab/`](./packaging/gitlab/testguard.gitlab-ci.yml) |
57
132
 
58
133
  Projects that set `min-release-age` in `.npmrc` cannot see a version published
59
134
  less than that many days ago (`ENOVERSIONS`); install that one with
60
135
  `npm i -D testguard-cli --min-release-age=0`.
61
136
 
137
+ ## Usage
138
+
139
+ ```bash
140
+ npx testguard-cli init # install the agent layer at the git root
141
+ npx testguard-cli status --json # where the project is, and the ONE next action
142
+ npx testguard-cli claims # what does this project claim, and is each claim probeable?
143
+ npx testguard-cli scaffold src/auth.ts # propose faults for a file, as a draft to keep or drop
144
+ npx testguard-cli probe # try to falsify each claim; report what the tests missed
145
+ npx testguard-cli admit test/auth.test.ts --claim AUTH-ADMIN # does this test satisfy the two-gate rule?
146
+ npx testguard-cli baseline # freeze today's unproven findings; only new ones gate
147
+ npx testguard-cli gate --changed origin/main # fail when a changed source file carries no claim
148
+ npx testguard-cli brief --text # tell the agent where the suite is blind, before it writes
149
+ npx testguard-cli replay --since v1.0..HEAD # would this suite have caught the bugs that escaped?
150
+ npx testguard-cli mcp # serve the read-only loop over MCP, on stdio
151
+ ```
152
+
153
+ Exit codes are the contract: `0` nothing new to prove, `1` unproven claims or
154
+ unclaimed changes, `2` a precondition failed and nothing was probed, `3` usage.
155
+ Every command accepts `--json`.
156
+
62
157
  ## How it works
63
158
 
64
159
  ```bash
@@ -70,6 +165,7 @@ npx testguard-cli baseline # freeze today's unproven findings; from now on on
70
165
  npx testguard-cli brief # tell the agent where the suite is blind, before it writes
71
166
  npx testguard-cli gate --changed origin/main # fail when a changed source file carries no claim at all
72
167
  npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to keep or drop
168
+ 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
169
  ```
74
170
 
75
171
  1. **Claims** live in `testguard.claims.json` (editors validate it against
@@ -83,7 +179,12 @@ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to
83
179
  2. **Probe** confirms the defenders are green N times unmodified, applies
84
180
  each fault in a scratch git worktree (your tree is never touched), runs
85
181
  the defenders N times, re-runs survivors against the whole suite with
86
- N-run attribution, restores, and classifies. Verdicts are a closed set:
182
+ N-run attribution (up to N: it stops as soon as no test has failed in
183
+ every run, because from there none can be named a killer), restores, and
184
+ classifies. It warns when another test runner is already running — a
185
+ contended machine turns a slow suite into a `TIMEOUT` verdict about the
186
+ load, not the claim — records that on the evidence, and offers `--serial`
187
+ to run one test file at a time. Verdicts are a closed set:
87
188
 
88
189
  | Verdict | Meaning |
89
190
  |---|---|
@@ -93,7 +194,7 @@ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to
93
194
  | `UNVERIFIABLE` | the fault's anchor is missing or ambiguous — loud, never a skip |
94
195
  | `TIMEOUT` | the defenders hung; a hang is not a detection |
95
196
  | `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 |
197
+ | `FLAKY-DEFENDER` | the defenders are not reliably green, or disagreed across runs — `detail.flakeRate` says how often (`failures` of `runs` on unmodified source) |
97
198
 
98
199
  Never a single score. Findings are ranked by severity, claim provenance
99
200
  and blast radius (relative imports, `tsconfig` path aliases and
@@ -102,20 +203,73 @@ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to
102
203
  it is written.
103
204
 
104
205
  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.
206
+ uncommitted changes and you asked for the implicit HEAD, `probe` refuses
207
+ and says so — otherwise your new tests would be silently absent and the
208
+ same survivors would come back with no hint why. `--include-dirty`
209
+ snapshots the working tree (tracked edits and new files) into a throwaway
210
+ commit and probes that; your tree, HEAD and index are never touched. An
211
+ **explicit `--ref`** (a pre-fix commit in a post-mortem, say) is honoured
212
+ over a dirty tree, as is `--ignore-dirty`: a warning names the files and
213
+ the evidence records them as `repo.ignoredDirty`, so the run says what it
214
+ did not look at. Every summary names the commit probed.
110
215
 
111
216
  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".
114
-
115
- 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.
217
+ files that import the fault's target, by relative path or resolved alias
218
+ (tsconfig `paths`, through `extends` and `references`, vite/vitest
219
+ `resolve.alias`, package.json `imports`) — **minus the files that mock
220
+ it**. A test that `vi.mock`s / `jest.mock`s the target cannot detect any
221
+ fault in it; counting it would make `NOCOVER` under-report and waste runs.
222
+ `NOCOVER` therefore means exactly "no test file imports this source
223
+ without mocking it". `claims` prints the split (`18 import · 16 mock · 2
224
+ can detect`), the evidence lists the mocking files, and a mocking file
225
+ that never `expect(...)`s anything imported from the target carries the
226
+ static signal `mocked-never-asserted` — the cheapest blind-spot signal
227
+ there is, and the exact signature of one escaped bug in the field
228
+ reports. Silence it, visibly, with `// unasserted: <why>` above the mock.
229
+
230
+ Runners: **vitest**, **jest**, **Playwright** and **Python** (`--runner
231
+ auto` picks the first of vitest, jest, python that resolves). A JavaScript
232
+ runner is resolved from the **project's own package** first — its pinned
233
+ version, its own bin script — and only then from an executable on PATH,
234
+ which the evidence records as `runner.source: "path"`; `npx` is never
235
+ asked, because its cache answers for packages a project does not have.
236
+ Anything else goes through `--runner-cmd`. Each runner is proven against
237
+ its own copy of the known-answer fixture.
238
+
239
+ **Python** runs under `pytest` when the project's interpreter can import
240
+ it and stdlib `unittest` when it cannot, and the evidence records the
241
+ engine that actually ran — `--runner pytest` or `--runner unittest` pins
242
+ the choice and fails rather than falling back. Nothing is installed into
243
+ the project: the reporters ship inside TestGuard and reach the interpreter
244
+ through `PYTHONPATH`, so a codebase whose test dependencies are the
245
+ standard library stays that way. The interpreter is the project's
246
+ `.venv`/`venv`, the active `VIRTUAL_ENV`, or `--python <path>`. A `.py`
247
+ defender runs under Python whatever the project runner is, so one claim
248
+ can be defended by a vitest test and a pytest test at once.
249
+
250
+ Two things Python forces that JavaScript does not. **`patch("pkg.mod.fn")`
251
+ is not `vi.mock`**: it replaces one attribute, so the file is still a
252
+ defender of every other fault in that module — it keeps its place and
253
+ carries a `target-attribute-patched` signal naming the attributes. And
254
+ **the fault must be the code that ran**: a strict editable install puts an
255
+ import hook ahead of `sys.path`, so Python can load the original file while
256
+ TestGuard faults the copy — a green baseline, every claim `SURVIVED`, and a
257
+ report that reads as a devastating finding while being entirely false. With
258
+ the fault applied, TestGuard asks which file was actually imported and
259
+ refuses the run when it is outside the tree being probed. When no defender
260
+ imported the subject at all, the run continues and the record says so
261
+ (`detail.targetNotImported`), because that is a fact about the defenders'
262
+ reach and not about their assertions.
263
+
264
+ The two-gate rule, as one verb: `testguard admit <test-file> --claim <ID>`
265
+ runs the claim's faults against your uncommitted test and answers
266
+ `ADMITTED` (exit 0: the test is green on unmodified HEAD and fails on
267
+ every fault, N/N) or `NOT ADMITTED` (exit 1: the first blocking fault and
268
+ what to do about it). The test must be a declared or discovered defender
269
+ of the claim. It is sugar over `probe --claim <ID> --include-dirty`, so
270
+ it can never disagree with the gate. Every generator on the market admits
271
+ a test because it compiles, passes and raises coverage; `admit` admits it
272
+ because it fails when the claim is false.
119
273
 
120
274
  Practical loop: first pass `--no-escalate` (escalation re-runs the whole
121
275
  suite N times per survivor); iterate on one claim with `--claim <ID>` and
@@ -132,20 +286,105 @@ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to
132
286
  3. **Baseline** freezes every non-passing fingerprint. Later probes suppress
133
287
  what was already known and exit non-zero only on what is new. Claims whose
134
288
  source and defenders are unchanged reuse their prior verdict, so a probe
135
- in CI costs only what changed.
289
+ in CI costs only what changed. A baseline frozen from `--include-dirty`
290
+ evidence records the snapshot and points at the *parent* of the commit
291
+ that will carry your tests; after you commit, a clean `probe` plus
292
+ `baseline --restamp` moves it to that commit — only when the fingerprints
293
+ are identical, never otherwise. `status` notes a baseline that predates
294
+ HEAD without making it a state.
136
295
  4. **Brief** turns evidence plus baseline into a ranked, capped
137
296
  `## TEST BLINDSPOT CONTEXT` block, printed and also written to
138
- `.testguard/brief.json` (`--text` prints only). Wire it into an agent's session start
297
+ `.testguard/brief.json` (`--text` prints only; `--markdown` prints the same brief as a
298
+ merge-request note for the human reviewer, unclaimed changes first). Wire it into an agent's session start
139
299
  — for Claude Code, in `.claude/settings.json`:
140
300
 
141
301
  ```json
142
302
  { "hooks": { "SessionStart": [ { "hooks": [
143
- { "type": "command", "command": "npx testguard-cli brief --text" }
303
+ { "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
304
  ] } ] } }
145
305
  ```
146
306
 
147
- `--text` prints only, and exits 0 silently when there is no evidence yet,
148
- so the hook can never break a session.
307
+ `--text` prints only, and exits 0 silently when there is no evidence yet;
308
+ the command resolves the project's own install, then a `testguard` on
309
+ `PATH`, and ends in `true` — so the hook can never break a session. There
310
+ is no `npx` in it, in any form: `--no-install` resolves the package from
311
+ the registry before declining to install it, so it is quiet rather than
312
+ offline. The brief's first line says which install answered
313
+ (`local install` or `global`), so a stale one is visible.
314
+
315
+ ### Would this suite have caught the bugs that already escaped?
316
+
317
+ An injected fault is a fault somebody thought of. A bug that actually shipped
318
+ is ground truth: a human already confirmed it was a defect, and there is no
319
+ equivalent-mutant argument to have about it.
320
+
321
+ ```bash
322
+ npx testguard-cli replay --since HEAD~50..HEAD --max 10
323
+ ```
324
+
325
+ For each fix commit in the range — one that changes source **and** a test
326
+ together — `replay` checks it out in a scratch worktree, reverts only its
327
+ source files to the parent, **deletes the test the fix shipped** (that test
328
+ proves nothing about what the suite knew before it existed), and runs the
329
+ tests that import the reverted code:
330
+
331
+ | Verdict | Meaning |
332
+ |---|---|
333
+ | `caught` | a remaining test failed by assertion, every run. The suite knew. |
334
+ | `blind` | the suite stayed green on known-broken code. |
335
+ | `nocover` | no test imports the reverted files — worse than blind. |
336
+ | `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`. |
337
+ | `unverifiable` | the revert did not apply, or the suite could not load. |
338
+
339
+ One patch counts once (`git patch-id`), because a dual-branch topology
340
+ carries the same fix under two or three shas. **`replay` reports and never
341
+ gates**: a bug that escaped is history, not a regression in this change.
342
+
343
+ Each replayed bug is also labelled with the injected-fault class its diff
344
+ most resembles, which is the join key that turns an uninterpretable mutation
345
+ score into a statement with a sample size:
346
+
347
+ ```
348
+ guard-removed blind 7/9 p=0.78 ci [0.45, 0.94]
349
+ field-dropped blind 4/4 p=1.00 ci [0.51, 1.00]
350
+ ```
351
+
352
+ Read as *"when a fault of this class survives, how often does that
353
+ correspond to a bug that really escaped"*. It is written as a
354
+ `calibration` document beside the replay one. Only `caught` and `blind`
355
+ carry information; the other verdicts are excluded from both sides of the
356
+ ratio.
357
+
358
+ **The open question this exists to answer.** Does a calibration learned on a
359
+ repository *with* history transfer to a greenfield one that has none? AI
360
+ authored code has no history, so replay cannot help it directly and
361
+ calibration is the only bridge. That transfer is unproven, it is the core
362
+ product bet, and this is the instrument for testing it — not the answer.
363
+
364
+ ### Read CI's evidence locally
365
+
366
+ Verdict reuse makes a probe cheap, but the evidence lives where `probe` ran
367
+ and is gitignored. On a fresh clone, or on a laptop where CI does the
368
+ probing, the session-start brief is empty and `status` says `unprobed` while
369
+ the default branch has full evidence. Point either command at CI's document:
370
+
371
+ ```bash
372
+ testguard status . --evidence .testguard/ci/ci-self-evidence.json
373
+ testguard brief . --text --evidence .testguard/ci/ci-self-evidence.json
374
+ ```
375
+
376
+ A foreign document is not trusted blindly. `status` marks it
377
+ `evidenceSource: provided`, prints the commit it describes next to the
378
+ commit in your tree, and still computes staleness from the recorded input
379
+ hashes — so a file you have edited since CI probed it goes `evidence-stale`
380
+ for exactly those claims.
381
+
382
+ `testguard init --ci-evidence github` (or `gitlab`) writes
383
+ `.testguard/fetch-ci-evidence.sh`, which downloads the branch-named artifact
384
+ with the platform CLI you already have and then briefs from it. **It is an
385
+ on-demand helper, not a hook**: the session-start hook never touches the
386
+ network, and the helper exits 0 with a message when the CLI or the artifact
387
+ is missing. TestGuard itself still makes no network calls.
149
388
 
150
389
  ### Every change needs a claim
151
390
 
@@ -185,16 +424,139 @@ detected base that does not resolve is a warning for `status` and `brief`
185
424
  # GitHub Actions
186
425
  - uses: actions/checkout@v4
187
426
  with: { fetch-depth: 0 }
188
- - uses: raccioly/testguard@v0.5.0
427
+ - uses: raccioly/testguard@v0.7.0
189
428
  with: { command: gate }
190
429
 
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 .]
430
+ # GitLab CI — the component-shaped template: gate + probe, brief as an artifact and, opted in, as a merge-request note
431
+ include:
432
+ - remote: 'https://raw.githubusercontent.com/raccioly/testguard/v0.7.0/packaging/gitlab/testguard.gitlab-ci.yml'
433
+ inputs: { dir: backend, post_note: true } # post_note needs TESTGUARD_GITLAB_TOKEN (api scope); one note, updated in place
196
434
  ```
197
435
 
436
+ The template carries `spec: inputs:` (`version`, `dir`, `image`, `severity`,
437
+ `confirm`, `budget`, `no_escalate`, `strict`, `post_note`, `stage`), so
438
+ mirrored into a GitLab project it is a catalog component that a compliance
439
+ framework can require on every project in a group. The CLI itself never
440
+ talks to the network; only the job posts, and only when told to.
441
+
442
+ ### Properties
443
+
444
+ - **Deterministic measurement.** No model decides a verdict. Faults are
445
+ string substitutions from a reviewed file; verdicts come from the test
446
+ runner's structured report, confirmed N times. The same inputs give the
447
+ same evidence, which is what makes it replayable for an auditor.
448
+ - **No network, no telemetry.** The CLI never opens a socket. The
449
+ session-start hook resolves an installed binary and never fetches one.
450
+ Nothing leaves the machine unless a CI job you configured posts it.
451
+ - **Artifacts are data in the repository.** Claims, evidence, baseline and
452
+ brief are JSON validated against a published schema before they are
453
+ written. Nothing is healed, regenerated or re-targeted at run time; a
454
+ claims file is code, and is reviewed like code.
455
+ - **Never optimistic.** A timeout, a load failure, a mixed N-run result, a
456
+ flaky defender or a missing anchor is reported as unproven, never rounded
457
+ toward green. The one pass is `killed`; everything else gates.
458
+ - **One exact-pinned runtime dependency** (`ajv`), Node ≥ 20, MIT.
459
+
460
+ ### What TestGuard is not
461
+
462
+ - **Not a test generator.** It judges a test the agent wrote (`admit`); the
463
+ generating half stays with the agent, where the market is putting it.
464
+ - **Not a mutation-score dashboard.** No blanket mutants, no single score, no
465
+ threshold. Faults are few and bound to stated claims; findings are ranked,
466
+ never summed.
467
+ - **Not a self-healing runner.** A defender that adapts itself to a change is
468
+ a defender that did not observe it; TestGuard reports that, it does not do
469
+ it.
470
+ - **Not a coverage tool.** A line executed says nothing about whether an
471
+ assertion would notice. `NOCOVER` here means no test even imports the
472
+ file; everything above that is measured by injecting the fault.
473
+
474
+ A claim that **disappeared** is invisible to all of the above: `probe`
475
+ verifies the claims that exist, `gate` sees the source file covered by some
476
+ other claim, and `status` says clean. Deleting a claim is therefore cheaper
477
+ than weakening its fault — and `changedFaults` exists precisely because
478
+ weakening was the cheap escape. So:
479
+
480
+ ```bash
481
+ npx testguard-cli claims --since origin/main # what existed there and does not now
482
+ ```
483
+
484
+ `removed-claim` and `removed-fault` gate; a rename that keeps the statement
485
+ verbatim is reported as `renamed-claim` and does not. Where evidence is
486
+ present, the line names the verdict the claim last had, because *"it was
487
+ SURVIVED when it was removed"* is the sentence that matters. The escape hatch
488
+ is the same one as everywhere else: a `claim` entry in
489
+ `testguard.ignore.json` with a reason, expiring if you give it an `expires`.
490
+ Removal is allowed — claims can be wrong, superseded or split — and it is
491
+ never silent.
492
+
493
+ ### Probing a Python project
494
+
495
+ Nothing is installed into your project. TestGuard carries its own reporters and
496
+ puts them on the interpreter's `PYTHONPATH`, so a codebase whose test
497
+ dependencies are the standard library keeps none.
498
+
499
+ ```bash
500
+ npx testguard-cli probe . # auto: pytest if importable, else stdlib unittest
501
+ npx testguard-cli probe . --runner unittest # pin the stdlib engine
502
+ npx testguard-cli probe . --python .venv/bin/python
503
+ ```
504
+
505
+ The evidence records the engine that actually ran, and `--runner pytest` fails
506
+ rather than quietly using `unittest` — which engine ran changes what the
507
+ evidence means.
508
+
509
+ Three things behave differently from JavaScript, and each one is a place a
510
+ naive port would have produced a confident wrong answer:
511
+
512
+ - **Defenders are matched by module name**, the way Python itself matches, so
513
+ a test that reaches your package through `sys.path`, a `conftest.py` or an
514
+ installed distribution is found either way.
515
+ - **`patch("pkg.mod.fn")` is not `vi.mock`.** It replaces one attribute, so the
516
+ file still detects a fault anywhere else in that module: it stays a defender
517
+ and carries a `target-attribute-patched` signal naming the attributes. Only a
518
+ patch of the module *itself* removes a defender.
519
+ - **The fault has to be the code that ran.** A strict editable install puts an
520
+ import hook ahead of `sys.path`, so Python can load your original file while
521
+ TestGuard faults its copy — green baseline, every claim `SURVIVED`, and a
522
+ report that reads as a catastrophe while being entirely false. With the fault
523
+ applied TestGuard asks which file was really imported and **refuses the run**
524
+ if it came from outside the tree being probed. If no defender imported the
525
+ subject at all, the run continues and the record says so
526
+ (`detail.targetNotImported`): that is a fact about the tests' reach, not
527
+ about their assertions, and the two must not read alike.
528
+
529
+ `testguard scaffold path/to/module.py` proposes Python faults the same way it
530
+ does for JavaScript.
531
+
532
+ ### Run it from any harness
533
+
534
+ The operating loop above lives in a Claude Code skill and a session-start
535
+ hook. Both vanish the moment the harness is Cursor, Codex, Devin or whatever
536
+ comes next — and the harness is exactly the layer most likely to change. So
537
+ the same documents are also served over the Model Context Protocol:
538
+
539
+ ```bash
540
+ npx testguard-cli mcp # JSON-RPC 2.0 on stdio
541
+ npx testguard-cli init --mcp # prints the config for Claude Code, Cursor and Codex
542
+ ```
543
+
544
+ Five tools, all **read-only**: `testguard_status`, `testguard_brief`,
545
+ `testguard_claims`, `testguard_evidence`, `testguard_next_command`.
546
+
547
+ **No tool runs a probe.** A probe is long-running, budgeted, and the person
548
+ should see it happen, so `next_command` hands back the exact shell line for
549
+ the agent to run in its own terminal. Nothing here writes a file either —
550
+ editing a claims file through a connector would defeat the point of recording
551
+ every fault edit. The server declares only a `tools` capability: no prompts,
552
+ no resources, no sampling.
553
+
554
+ It is hand-written against a pinned protocol version rather than built on the
555
+ official SDK, because this tool has one exact-pinned runtime dependency and
556
+ keeps it that way. The surface is three methods and five tools and will not
557
+ grow; tests speak the wire format to a real child process and check each
558
+ tool against the CLI's own `--json` output, so the two cannot drift.
559
+
198
560
  ### Built for agents to run
199
561
 
200
562
  TestGuard is meant to be driven by an AI agent, not typed by a person. Three
@@ -205,21 +567,126 @@ things make that safe:
205
567
  working tree. Every human rendering — the CLI text, the session-start
206
568
  brief, the skill — derives from it, so they cannot disagree. Every command
207
569
  accepts `--json`.
208
- - **An installable operating loop.** `testguard init` writes
570
+ - **An installable operating loop.** `testguard init [dir]` writes the
571
+ **agent layer at the git root**, where agent sessions run —
209
572
  `.claude/skills/testguard/SKILL.md` (state → action, verdict → the only
210
573
  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.
574
+ `brief --text` session-start hook, an `AGENTS.md` section — and the
575
+ **project layer** (`.gitignore` lines) beside the claims file. A second
576
+ project in the same repository adds a hook line and an `AGENTS.md`
577
+ bullet; `--here` keeps everything in the subdirectory. The hook
578
+ resolves the project's `node_modules/.bin/testguard`, then the git root's,
579
+ then a `testguard` on `PATH` via `command -v`, then does nothing — **no
580
+ `npx` in any form, so no path to the network**; a pre-0.6 `npx -y` or
581
+ `npx --no-install` hook is replaced. A written file that
582
+ `.gitignore` swallows is reported, not offered for commit. Idempotent.
583
+ - **Independence is recorded (L3).** `probe` measures *power* — would this
584
+ test notice if the code were wrong. It also records, on every kill, whether
585
+ the killing test was last touched by the same change (or the same author) as
586
+ the code it guards: `detail.independence` is `co-authored`,
587
+ `separate-change` or `unknown`. A co-authored kill is legitimate — a fix
588
+ *should* ship with its regression test — but a repository where every kill
589
+ is co-authored has no independent verification, whatever its claim
590
+ verification rate says. It is a **signal**: ranking reads it, verdicts never
591
+ do, and the brief says it in one line. Nothing else in this category
592
+ measures it, and agents saturate the tests they can see
593
+ ([SpecBench](https://arxiv.org/abs/2605.21384)).
213
594
  - **Gaming is visible.** The cheapest way to make a survivor disappear is to
214
595
  weaken its fault, not to write a test. Evidence records every fault's
215
596
  content hash; `status` lists any fault edited after it survived, with its
216
597
  previous verdict, and makes reviewing that edit the next action. Editing a
217
598
  claim is allowed — claims can be wrong — but it is never invisible.
218
599
 
600
+ ### Watching a probe that is still running
601
+
602
+ A probe runs the defenders N times clean and N times per fault, which takes
603
+ minutes. It used to print its stage line only when `stderr` was a terminal —
604
+ so CI, a redirected log and an agent harness saw **nothing at all** for the
605
+ whole run, which is indistinguishable from a hang. A gate you cannot tell
606
+ from a hang is a gate people start killing.
607
+
608
+ The rewriting is a rendering choice, not a reason to withhold the
609
+ information:
610
+
611
+ | `--progress` | What you get |
612
+ |---|---|
613
+ | `auto` (default) | `tty` at a terminal, `plain` everywhere else |
614
+ | `tty` | one line, rewritten in place — for a human watching |
615
+ | `plain` | one append-only line per stage — a log file, CI, an agent |
616
+ | `ndjson` | one JSON object per line, stages **and** verdicts as they land |
617
+ | `none` | silence |
618
+
619
+ ```bash
620
+ npx testguard-cli probe . > probe.log 2>&1 # now reports; used to be silent
621
+ npx testguard-cli probe . --progress ndjson 2>events.ndjson
622
+ ```
623
+
624
+ ```
625
+ {"event":"stage","claim":"TG-COST-NAMES-SHARED-DEFENDERS","fault":"F1","stage":"baseline","run":1,"of":3,"at":"…"}
626
+ {"event":"verdict","claim":"TG-COST-NAMES-SHARED-DEFENDERS","fault":"F1","verdict":"killed","severity":"medium","file":"src/probe/cost.mjs","at":"…"}
627
+ ```
628
+
629
+ **Progress always goes to stderr**, so `--json` leaves stdout as one document
630
+ and nothing else. `--quiet` and `--json` imply `none`, and an explicit
631
+ `--progress` overrides both: an operator who asks for a stream of events has
632
+ said what they want.
633
+
634
+ ### What the probe costs, and why a gate gets slow
635
+
636
+ A probe's wall clock is not a function of how many claims you have. Per claim
637
+ it is roughly
638
+
639
+ ```
640
+ (baseline runs + faults × --confirm) × the cost of that claim's defender SET
641
+ ```
642
+
643
+ so one slow acceptance test, named as a defender by five claims, is paid for
644
+ thirty times. The symptom is a gate that takes twenty minutes; the cause is a
645
+ single line in the claims file, and nothing in the output used to say which.
646
+
647
+ ```bash
648
+ npx testguard-cli claims . --cost # read back from the last probe's evidence
649
+ npx testguard-cli probe . --cost # what the run you just made spent
650
+ ```
651
+
652
+ ```
653
+ 63 fault records cost 24m across 378 defender runs.
654
+
655
+ most expensive claims
656
+ 4m 58s TG-FAULT-EDIT-VISIBLE 1 fault · 6 runs
657
+
658
+ defender files, by the time of the runs that included them
659
+ (a claim runs its whole defender set at once, so these overlap and do not sum to the total)
660
+ 24m 3s test/probe.fixture.test.mjs 5 claims
661
+
662
+ shared defenders — each claim pays the file's full cost again
663
+ 24m 3s test/probe.fixture.test.mjs
664
+ named by TG-ESCALATION-N-RUNS, TG-FAULT-EDIT-VISIBLE, …
665
+ ```
666
+
667
+ Every number is read back out of the `durationMs` the probe already records.
668
+ `--cost` re-measures nothing, spawns nothing, and costs the price of reading
669
+ one JSON file.
670
+
671
+ **The per-file figures overlap.** A run executes a claim's whole defender set
672
+ at once, so the runner never says how much of it belonged to which file. A
673
+ file's `ms` is the time of every run that *included* it: an upper bound on
674
+ what removing it could save, which is the decision you are making. Only
675
+ `totalMs` is additive.
676
+
677
+ **What to do about a shared defender.** Extract the decision it proves into a
678
+ pure function, unit-test that, and point the claim at the unit test. This is
679
+ not a testing trick — it is the same reason `classify()` in this codebase is
680
+ pure. A rule you can state in three lines should not need a fifty-second
681
+ acceptance test to falsify it. When the extraction is right the claim still
682
+ fails on the fault; when it is wrong the claim SURVIVES, and the probe tells
683
+ you so before you have shipped a weaker gate. Do not narrow `defendedBy` and
684
+ assume — re-probe the claim and read the verdict.
685
+
219
686
  ### Authoring faults mechanically
220
687
 
221
688
  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,
689
+ field reports found that ~80% of hand-written faults are one of nine shapes,
223
690
  so `scaffold` proposes them for you:
224
691
 
225
692
  ```bash
@@ -234,6 +701,22 @@ npx testguard-cli scaffold src/auth.ts --claim AUTH-ADMIN # every proposal und
234
701
  | `return-altered` | `return <check>;` (`===`, `.includes(`, `&&`, …) → `return true;` |
235
702
  | `literal-changed` | `httpOnly`/`secure` flipped, `sameSite` → `none`, a cost/rounds → `1`, a ttl/tolerance/window/limit ×1000 |
236
703
  | `call-removed` | a bare `verify…()` / `validate…()` / `check…()` / `authorize…()` call removed |
704
+ | `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 |
705
+ | `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)` |
706
+ | `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 |
707
+ | `handler-dropped` | an `on<Event>={…}` prop removed, whether it is its own line or inline in the tag; the control renders and does nothing |
708
+
709
+ The table above is JavaScript. `scaffold` reads Python too, and proposes the
710
+ same fault classes in Python syntax: `if <guard>:` → `if False:`,
711
+ `return <check>` → `return True`, `verify=True` → `verify=False`, a
712
+ parameter-derived argument swapped for `None`, a key dropped from a payload
713
+ `dict` or an allow-list. Two differences are deliberate. A statement is
714
+ removed by replacing it with `pass`, never by deleting the line, because a
715
+ block whose only statement is gone is an `IndentationError`; and a line that
716
+ leaves a bracket open (`COLOURS = {`) is never removed at all. Both exist
717
+ because a fault that cannot compile is a `fault-invalid` verdict — a probe run
718
+ spent saying nothing about the tests. The two JSX shapes (`element-removed`,
719
+ `handler-dropped`) have no Python meaning and are absent rather than faked.
237
720
 
238
721
  Every proposal's `find` is the exact line with `expectHits`/`occurrence`
239
722
  computed from the file, so it is verifiable by construction; provenance is
@@ -267,17 +750,28 @@ redacted text for the raw input and the test stays green. `probe` reports it
267
750
  as `SURVIVED`; the fixture's [README](fixtures/known-answer/README.md) walks
268
751
  through every verdict.
269
752
 
270
- ## Status
753
+ The same blind spot, in Python, is
754
+ [`fixtures/known-answer-python/`](fixtures/known-answer-python/README.md) —
755
+ run under **both** stdlib `unittest` and `pytest`, which have different report
756
+ shapes and different notions of failure and must still agree on all sixteen
757
+ verdicts.
271
758
 
272
- **v0.4.** Eight commands, vitest and jest runners, hand-authored faults plus
273
- a mechanical scaffold, an agent operating layer (`status`, `init`) and a
274
- change gate (`gate`). The contract
275
- spine — eight JSON Schemas shared with the other Guard tools — is under
276
- [`spec/`](spec/). One exact-pinned runtime dependency (`ajv`, for schema validation); Node ≥ 20.
759
+ ## Status
277
760
 
278
- Not yet: test generation (the two-gate acceptance loop), runners beyond
279
- vitest and jest, AST-aware producers, and calibration of fault classes
280
- against real escaped bugs. Each is designed for; none is claimed.
761
+ **v0.5.** Eleven commands (`status`, `init`, `claims`, `probe`, `admit`,
762
+ `replay`, `baseline`, `brief`, `gate`, `scaffold`, `mcp`), vitest, jest,
763
+ Playwright and Python (pytest / stdlib unittest) runners, hand-authored faults
764
+ plus a mechanical scaffold for JavaScript and Python, an agent operating layer
765
+ (`status`, `init`) and a change gate (`gate`). The contract spine — eight JSON
766
+ Schemas shared with the other Guard tools — is under [`spec/`](spec/). One
767
+ exact-pinned runtime dependency (`ajv`, for schema validation); Node ≥ 20, and
768
+ Python ≥ 3.8 only when probing Python.
769
+
770
+ Not yet: test generation (the acceptance half, `admit`, exists; the generating
771
+ half stays the agent's), runners beyond those four, AST-aware producers, and
772
+ the transfer of a calibration between repositories — `replay` measures it now;
773
+ whether it carries to a repository with no history is unproven. Each is
774
+ designed for; none is claimed.
281
775
 
282
776
  ## Licence
283
777