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.
- package/CHANGELOG.md +255 -0
- package/README.md +275 -31
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +58 -1
- package/spec/README.md +1 -0
- package/spec/lib/validate.mjs +57 -1
- package/spec/schemas/baseline.schema.json +8 -0
- package/spec/schemas/common.schema.json +5 -1
- package/spec/schemas/evidence.schema.json +374 -63
- package/spec/schemas/replay.schema.json +85 -0
- package/spec/schemas/status.schema.json +256 -54
- package/src/admit/admit.mjs +17 -0
- package/src/baseline/baseline.mjs +31 -2
- package/src/brief/brief.mjs +71 -7
- package/src/claims/removed.mjs +161 -0
- package/src/cli.mjs +53 -10
- package/src/commands/admit.mjs +116 -0
- package/src/commands/baseline.mjs +17 -3
- package/src/commands/brief.mjs +13 -7
- package/src/commands/claims.mjs +35 -6
- package/src/commands/init.mjs +19 -6
- package/src/commands/mcp.mjs +12 -0
- package/src/commands/probe.mjs +11 -3
- package/src/commands/replay.mjs +63 -0
- package/src/commands/status.mjs +3 -1
- package/src/git.mjs +5 -0
- package/src/init/init.mjs +182 -29
- package/src/init/templates/SKILL.md +3 -3
- package/src/mcp/server.mjs +104 -0
- package/src/mcp/tools.mjs +114 -0
- package/src/probe/contention.mjs +60 -0
- package/src/probe/discover.mjs +12 -3
- package/src/probe/independence.mjs +59 -0
- package/src/probe/mocks.mjs +120 -0
- package/src/probe/probe.mjs +126 -18
- package/src/probe/rank.mjs +73 -15
- package/src/probe/runners/index.mjs +49 -5
- package/src/probe/runners/jest.mjs +3 -3
- package/src/probe/runners/playwright.mjs +133 -0
- package/src/probe/runners/shared.mjs +70 -13
- package/src/probe/runners/vitest.mjs +4 -3
- package/src/render.mjs +2 -2
- package/src/replay/label.mjs +64 -0
- package/src/replay/replay.mjs +309 -0
- package/src/scaffold/producers.mjs +217 -6
- package/src/scaffold/scaffold.mjs +9 -3
- 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.
|
|
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
|
|
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
|
|
106
|
-
tests would be silently absent and the
|
|
107
|
-
with no hint why. `--include-dirty`
|
|
108
|
-
edits and new files) into a throwaway
|
|
109
|
-
HEAD and index are never touched.
|
|
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
|
-
`
|
|
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).
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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": "
|
|
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
|
-
|
|
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.
|
|
321
|
+
- uses: raccioly/testguard@v0.6.0
|
|
189
322
|
with: { command: gate }
|
|
190
323
|
|
|
191
|
-
# GitLab CI —
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
279
|
-
vitest and jest, AST-aware producers, and
|
|
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.
|
|
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",
|
package/spec/GATE-SEMANTICS.md
CHANGED
|
@@ -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
|
package/spec/lib/validate.mjs
CHANGED
|
@@ -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}$" },
|