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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "testguard-cli",
3
- "version": "0.5.0",
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.",
3
+ "version": "0.7.0",
4
+ "description": "Breaks your code on purpose and reports every promise your tests did not notice breaking. Mutation testing bound to the claims a project makes — vitest, jest, Playwright, pytest and unittest.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "engines": {
@@ -12,7 +12,7 @@ The verdict set is closed. Only one value is a pass.
12
12
  | `killed` | Fault applied; every one of N probe runs failed with a genuine assertion failure, on a defender set that was green N/N unmodified. | no |
13
13
  | `survived` | Fault applied; every one of N probe runs passed. The claim is **unproven**. | **yes** |
14
14
  | `nocover` | No defending test exists: the declared globs resolve to nothing, or no test imports the subject. Worse than `survived` — nothing was even tried. | **yes** |
15
- | `unverifiable` | The claim could not be probed: the fault's anchor is missing or ambiguous, or its defenders failed to load (`defenders-failed-to-load`). Carries a `reason`. A loud, gating verdict — a claim that cannot be probed is not "skipped", it is undefended until someone fixes the fault or the defenders. Never confused with `flaky-defender`, which requires tests that *ran*. | **yes** |
15
+ | `unverifiable` | The claim could not be probed: the fault's anchor is missing or ambiguous, its defenders failed to load (`defenders-failed-to-load`), probing it threw (`probe-error`), or the defenders do not execute the subject at all (`subject-not-executed`). Carries a `reason`. A loud, gating verdict — a claim that cannot be probed is not "skipped", it is undefended until someone fixes the fault or the defenders. Never confused with `flaky-defender`, which requires tests that *ran*. | **yes** |
16
16
  | `timeout` | Probe run exceeded its budget. Not counted as a kill; the pessimistic reading is the safe one because flakiness biases the metric optimistically. | **yes** |
17
17
  | `fault-invalid` | The replacement does not load or compile. A bad fault, not a detection. | **yes** |
18
18
  | `flaky-defender` | Defenders were not green N/N on unmodified source (`defenders-not-green`), or the N probe runs disagreed with each other (`inconsistent-probe`). Either way no verdict about the fault can be trusted; fix the defenders first. | **yes** |
@@ -32,7 +32,20 @@ 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. Python is read the same way: an `AssertionError` and any other
40
+ exception raised by the test body both count, because both are the suite
41
+ rejecting the behaviour; a collection error — a module that will not
42
+ import, which is what a replacement that does not parse looks like from
43
+ Python — is a load failure and never a kill; and a test the suite expected
44
+ to fail that passed (`xpass`) makes the run non-green without ever being an
45
+ assertion failure, exactly as Playwright's `flaky` does. When one claim's
46
+ defenders run under several runners, their runs merge pessimistically —
47
+ any load error is an error, any timeout is a timeout, any failure is a
48
+ failure — and the evidence names which file ran under which runner.
36
49
  4. **A mixed result is not a kill.** If some of the N probe runs fail and
37
50
  others pass, the defender's response to the fault is nondeterministic.
38
51
  Reporting `killed` would be the optimistic bias flakiness introduces;
@@ -43,6 +56,11 @@ Rules that follow from the table:
43
56
  and `detail.undeclaredKillers` names the tests, so the author can fix
44
57
  `defendedBy`. The claim's stated evidence chain is broken even though the
45
58
  suite is not blind. It gates, and ranks below a true survivor.
59
+ Attribution runs **up to N times**, and stops as soon as no test has failed
60
+ in every run so far: from there no further run can name a killer. The
61
+ evidence records the early stop (`detail.escalationStoppedEarly`). Stopping
62
+ can only fail to *name* a killer, never upgrade a verdict, which is the
63
+ pessimistic side.
46
64
  6. **A verdict names the commit it is about.** Evidence records `repo.head`;
47
65
  when the working tree was probed instead, `repo.snapshot` holds the
48
66
  throwaway commit that captured it. A tool must refuse to probe a commit
@@ -56,6 +74,136 @@ Rules that follow from the table:
56
74
  content hash; the status document lists every fault whose content
57
75
  changed since it was probed, with its previous verdict. The change is
58
76
  allowed; it is never invisible.
77
+ The same hash governs **reuse**: a tool that carries a prior verdict
78
+ forward to save a run may only do so for the fault that produced it. A
79
+ verdict is an answer about one exact `find`/`replace`, so once those
80
+ change the old answer is not an answer to the new question — a weakened
81
+ fault would keep the verdict it earned before, and a repaired anchor would
82
+ keep `unverifiable`. Where the prior record has no content hash to compare,
83
+ the fault is probed again: the cost of re-measuring is a run, the cost of
84
+ the other direction is a verdict nobody measured.
85
+
86
+ 9. **The fault must be the code that ran.** A green baseline proves the
87
+ harness is not reporting everything as broken. It proves nothing about the
88
+ opposite direction: a fault the interpreter never executes leaves the
89
+ baseline green, every verdict `survived`, and a report that reads as a
90
+ devastating finding while being entirely false. A tool that can tell which
91
+ file was actually loaded must check it while the fault is applied, and must
92
+ **refuse the run** when the subject was loaded from outside the tree being
93
+ probed — in Python an editable install (PEP 660) registers an import hook
94
+ consulted ahead of every path entry, and a plain install leaves a copy in
95
+ `site-packages`, so this is the normal case, not an exotic one. When no
96
+ defender imported the subject at all, the run continues: an import inside a
97
+ branch the fault does not reach is legitimate. It is recorded as
98
+ `detail.targetNotImported`, because a `survived` there is a statement about
99
+ the defenders' reach and not about their assertions, and the two must not
100
+ be read as the same finding.
101
+ 10. **A signal describes a file, and says which kind of blindness it is.**
102
+ Replacing a whole module (`vi.mock`, `jest.mock`, a Python `patch` of the
103
+ module itself) removes a file from the defenders: it cannot detect anything
104
+ in what it replaced. Replacing **one attribute** of a module —
105
+ `patch("pkg.mod.fn")`, which is what Python code almost always does — does
106
+ not: a fault anywhere else in that module is still fully detectable, and
107
+ dropping the file would report `nocover` for a claim that is well defended.
108
+ The file stays a defender and carries `target-attribute-patched`, naming
109
+ the attributes. Signals never change a verdict.
110
+
111
+ 11. **One fault's failure costs that fault, not the run.** A fault whose probe
112
+ throws is recorded as `unverifiable` with reason `probe-error` and a
113
+ `detail.message` naming what threw; the run continues and still writes its
114
+ evidence. Losing the document would throw away every verdict already
115
+ decided and leave a caller with an exit code this document does not define,
116
+ which is strictly worse than one loud unverifiable claim. A `probe-error`
117
+ record is never reused by a later run: it says something about the run, not
118
+ about the code. The one exception is a **precondition** failure — the
119
+ interpreter loading the source from outside the probed tree, a runner that
120
+ does not resolve — which is a statement about every verdict in the run and
121
+ still refuses it outright.
122
+ Restoring the source is held to the same reading. The restore is
123
+ belt-and-braces in worktree mode, where the mutation only ever existed
124
+ inside a scratch worktree that is discarded anyway, so a target that has
125
+ vanished is already restored and is recorded as
126
+ `detail.restoreSkipped: "target-missing"` rather than raised — a reader can
127
+ still see that the tree moved underneath the run. Under `--in-place` the
128
+ same condition is the user's own file gone and must be raised. Any other
129
+ failure to write the original back is raised in both modes: it can mean a
130
+ mutated file left on disk.
131
+
132
+ 12. **A survivor must prove the defenders execute the subject.** A green
133
+ baseline proves the defenders can *pass*. It does not prove they can fail
134
+ *because of this file* — and if the fault is applied to code the test
135
+ process never executes, the baseline is green, every probe run is green,
136
+ and every claim is reported `survived`. That output reads as a devastating
137
+ audit finding and is entirely false, and no individual check in the run
138
+ contradicts it.
139
+ So a would-be `survived` is charged one more run: the subject is replaced
140
+ with content its loader cannot parse, and the defenders run once. Going red
141
+ proves they execute it (`detail.negativeControl: "reached"`), and the
142
+ `survived` stands. Staying green proves they do not, and the verdict is
143
+ `unverifiable` with reason `subject-not-executed` — the pessimistic reading,
144
+ and the only honest one, because nothing was measured about their
145
+ assertions.
146
+ Charged **only** on a would-be survivor, and cached per subject and defender
147
+ set. A kill already proves the defenders reached the code, so paying there
148
+ would be waste. A runner that cannot say what "cannot compile" means for its
149
+ language does not guess: no control is run, no signal is recorded, and the
150
+ verdict is unchanged. `detail.targetNotImported` (reported by runners that
151
+ can name the file the interpreter loaded) answers the same question more
152
+ precisely for one language; where both are present they must agree.
153
+
154
+ 13. **Fault injection is one method, not the definition.** A claim declares
155
+ what must be true; a probe declares how a tool would try to make it false,
156
+ and `method` says which way. `fault-injection` — mutate the source, run the
157
+ claim's defenders, confirm over N runs — is the only method specified in
158
+ v1, and it is the default when the field is absent, so every document
159
+ written before the field existed is held to exactly the rules it was
160
+ written against.
161
+ Everything in this document above this rule is `fault-injection`'s
162
+ semantics. `confirmRuns`, `mode`, `defenders`, `inputs` and the baseline
163
+ and probe runs are what *it* means by showing its work; a tool that reads
164
+ code or scans a surface has none of them, and requiring them would force it
165
+ to emit empty arrays to conform — which is lying, and the one thing this
166
+ format exists to make impossible. The schema therefore requires them only
167
+ under `fault-injection`, and the validator enforces them there in full: the
168
+ relaxation is for a tool that injects nothing, never a licence for an
169
+ injecting tool to stop showing its work.
170
+ `assertion` and `scan` are **reserved**. Their required fields are
171
+ deliberately unspecified and will be defined by their first real consumer,
172
+ with its own conformance examples written from the shape that tool actually
173
+ has. Designing them for an absent tool is precisely the mistake that made
174
+ this rule necessary: the spec was drafted against a single consumer and
175
+ hard-coded its assumptions as requirements.
176
+ The verdict set stays closed and shared. What each method may legitimately
177
+ report is part of defining it.
178
+
179
+ ## Replay reports; it never gates
180
+
181
+ A replayed bug is history. It escaped, by definition, which means the suite
182
+ missed it; a gate on that is a gate nobody can pass on a first run, and it
183
+ would only teach teams to stop looking. `replay` therefore always exits `0`
184
+ unless it could not run at all.
185
+
186
+ Its verdicts mirror the probe's, for the same reasons:
187
+
188
+ | Verdict | Meaning |
189
+ |---|---|
190
+ | `caught` | a remaining test failed **by assertion** on the reverted source, every run. The suite knew. |
191
+ | `blind` | the suite stayed green on known-broken code. |
192
+ | `nocover` | no test imports the reverted files. Worse than `blind`: nothing was even tried. |
193
+ | `unverifiable` | the revert did not apply, the suite failed to load, or it timed out. Carries a `reason`. |
194
+ | `flaky` | the runs disagreed. A flaky failure reads as "the suite caught it", so flakiness biases this metric **optimistically** — mixed runs are never `caught`. |
195
+
196
+ Two rules that follow:
197
+
198
+ 1. **One patch, one row.** De-duplicate by `git patch-id`; a dual-branch
199
+ topology carries the same fix under two or three shas, and counting it
200
+ twice corrupts the corpus a calibration is computed from.
201
+ 2. **The fix's own test is removed before the run.** It proves nothing about
202
+ what the suite knew before the fix existed.
203
+
204
+ Only `caught` and `blind` carry information. `nocover`, `flaky` and
205
+ `unverifiable` are excluded from both sides of a calibration ratio, because a
206
+ number computed over them would mean nothing.
59
207
 
60
208
  ## Baseline and delta
61
209
 
@@ -72,6 +220,15 @@ derived from identities and outcome, never from the fault's `find`/`replace`
72
220
  text, so repairing a rotted anchor does not churn the baseline — while a
73
221
  change of verdict on the same claim+subject does surface as new.
74
222
 
223
+ A baseline records the commit its evidence was taken at. When that evidence
224
+ came from a working-tree snapshot, the baseline says so (`snapshot`) and its
225
+ `head` is the **parent** of the commit that will carry the tests. A tool may
226
+ offer to re-stamp such a baseline onto a later commit, but only when a clean
227
+ probe of that commit reproduced exactly the same fingerprints; otherwise the
228
+ user re-probes and freezes a new baseline. A frozen contract is never
229
+ silently rewritten, and a status document notes a baseline that predates
230
+ HEAD without turning that into a state.
231
+
75
232
  Adopting tools may reconcile an existing baseline format (for example a
76
233
  `{version, fingerprints:{hash:count}}` file) by mapping it onto this shape;
77
234
  the suppress-up-to-count semantics are identical.
@@ -84,6 +241,13 @@ the suppress-up-to-count semantics are identical.
84
241
  for reasonless `path` entries.
85
242
  - An annotation is **strictly additive**. It never changes, suppresses, or
86
243
  drops a finding. Ranking may read annotations; verdicts never do.
244
+ - A **signal** is a static, annotation-grade fact about a defender, recorded
245
+ on the evidence and never a verdict. `mocked-never-asserted`: a test file
246
+ mocks the subject's module and never asserts on anything imported from it.
247
+ An author may silence it with `unasserted: <reason>` above the mock; the
248
+ tool then records `unasserted-annotated` with the reason — silenced, never
249
+ hidden. A file that mocks the subject is never a discovered defender: a
250
+ mock cannot detect a fault in what it replaced.
87
251
 
88
252
  ## Claim coverage of a change
89
253
 
package/spec/README.md CHANGED
@@ -1,16 +1,25 @@
1
- # Guard spec — the contract spine
1
+ # claimspec — the contract spine
2
2
 
3
- Eight formats that tools following the Guard pattern share. They share
4
- *formats*, not code: [`docguard-cli`](https://www.npmjs.com/package/docguard-cli)
5
- is Node ESM, [`websec-validator`](https://pypi.org/project/websec-validator/)
6
- is Python, and porting one runtime into the other is not worth it. A tool
7
- conforms by emitting these shapes; it keeps its own language, CLI and UX.
3
+ Nine formats for declaring what must be true about a codebase, recording the
4
+ evidence that tried to falsify it, and gating only what is new.
8
5
 
9
- The pattern all such tools run:
6
+ They are shared as *formats*, not as code. The first tools to adopt them are
7
+ [`docguard-cli`](https://www.npmjs.com/package/docguard-cli) (Node ESM) and
8
+ [`websec-validator`](https://pypi.org/project/websec-validator/) (Python) —
9
+ porting one runtime into the other was never worth it, and never the point. A
10
+ tool conforms by emitting these shapes; it keeps its own language, CLI and UX.
11
+
12
+ The method they have in common:
10
13
 
11
14
  > declare what must be true → try mechanically to falsify it → freeze a
12
15
  > baseline → gate only the delta → brief the agent before it writes code.
13
16
 
17
+ **Falsifying by fault injection is one method, not the definition.** A tool
18
+ that verifies a claim by reading code, by scanning a surface, or by replaying
19
+ history is as much a consumer as one that mutates source. The formats that
20
+ carry *how* a claim was tested say which method produced them; the formats
21
+ that carry claims, baselines, scoping and briefs do not care.
22
+
14
23
  ## Formats
15
24
 
16
25
  | Kind | Schema | Purpose |
@@ -22,6 +31,7 @@ The pattern all such tools run:
22
31
  | `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
32
  | `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
33
  | `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. |
34
+ | `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
35
  | `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
36
 
27
37
  Shared definitions (verdicts, fault classes, provenance, annotations) live in
@@ -29,7 +39,7 @@ Shared definitions (verdicts, fault classes, provenance, annotations) live in
29
39
  which verdicts turn CI red, baseline and delta rules, exit codes — is in
30
40
  [`GATE-SEMANTICS.md`](GATE-SEMANTICS.md).
31
41
 
32
- Schemas are JSON Schema 2020-12 and identified as `urn:guard-spec:v1:<kind>`.
42
+ Schemas are JSON Schema 2020-12 and identified as `urn:claimspec:v1:<kind>`.
33
43
 
34
44
  ## Conformance
35
45
 
@@ -52,5 +62,14 @@ npm run test:spec
52
62
 
53
63
  The spec is drafted here and validated against `testguard` as its first
54
64
  consumer. It moves to its own repository only when a second tool adopts it.
55
- Adoption by an existing tool is one additive output writer — no rewrite, no
56
- behaviour change, and optional until that tool wants it.
65
+
66
+ Adoption is additive and optional: an existing tool gains an output writer, and
67
+ nothing it already detects or already writes changes. That holds today for
68
+ `baseline`, `ignore`, `brief` and `status`. It does **not** yet hold for
69
+ `claims` and `evidence`, whose required fields assume fault injection
70
+ (`find`/`replace` anchors, `confirmRuns`, `baselineRuns`/`probeRuns`,
71
+ `defenders`) — a tool that scans or reads would have to emit empty arrays to
72
+ satisfy the schema, which is conforming by lying. Making those two kinds
73
+ method-agnostic is tracked as
74
+ [#81](https://github.com/raccioly/testguard/issues/81) and blocks adoption of
75
+ either.
@@ -6,7 +6,14 @@ 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
+ /**
11
+ * How a document says it tried to falsify its claims. Absent means
12
+ * `fault-injection`, so every document written before the field existed is
13
+ * held to exactly the rules it was written against.
14
+ */
15
+ export const methodOf = (x) => x?.method ?? 'fault-injection';
16
+ const isInjection = (x) => methodOf(x) === 'fault-injection';
10
17
  export const PASSING_VERDICTS = Object.freeze(new Set(['killed']));
11
18
 
12
19
  const ajv = new Ajv2020({ strict: true, allErrors: true });
@@ -15,7 +22,7 @@ for (const f of readdirSync(schemaDir).filter((n) => n.endsWith('.schema.json'))
15
22
  }
16
23
 
17
24
  const schemaFor = (kind) => {
18
- const v = ajv.getSchema(`urn:guard-spec:v1:${kind}`);
25
+ const v = ajv.getSchema(`urn:claimspec:v1:${kind}`);
19
26
  if (!v) throw new RangeError(`unknown Guard-spec kind: ${kind}`);
20
27
  return v;
21
28
  };
@@ -33,10 +40,15 @@ const semantic = {
33
40
  const p = `/claims/${ci}/faults/${fi}`;
34
41
  if (seenFault.has(f.id)) errors.push({ path: `${p}/id`, message: `duplicate fault id "${f.id}" in claim "${c.id}"` });
35
42
  seenFault.add(f.id);
36
- const occ = f.occurrence ?? 1;
37
- const hits = f.expectHits ?? 1;
38
- if (occ > hits) errors.push({ path: `${p}/occurrence`, message: `occurrence ${occ} exceeds expectHits ${hits}` });
39
- if (f.find === f.replace) errors.push({ path: `${p}/replace`, message: 'replace is identical to find; the fault is a no-op' });
43
+ // Anchor arithmetic is a property of fault injection. A probe that does
44
+ // not substitute text has no `find` to compare, and `undefined ===
45
+ // undefined` would report every one of them as a no-op.
46
+ if (isInjection(f)) {
47
+ const occ = f.occurrence ?? 1;
48
+ const hits = f.expectHits ?? 1;
49
+ if (occ > hits) errors.push({ path: `${p}/occurrence`, message: `occurrence ${occ} exceeds expectHits ${hits}` });
50
+ if (f.find === f.replace) errors.push({ path: `${p}/replace`, message: 'replace is identical to find; the fault is a no-op' });
51
+ }
40
52
  });
41
53
  });
42
54
  return errors;
@@ -44,14 +56,39 @@ const semantic = {
44
56
 
45
57
  evidence(doc) {
46
58
  const errors = [];
59
+ const injection = isInjection(doc.run);
47
60
  const n = doc.run.confirmRuns;
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
- if (n >= 3 && doc.run.provisional === true) errors.push({ path: '/run/provisional', message: `confirmRuns ${n} is confirmed; provisional must be absent or false` });
61
+ // N-run agreement is what fault injection means by "confirmed". A method
62
+ // that does not re-run anything has no N, and a rule about one would be a
63
+ // rule about a number that is not there.
64
+ if (injection) {
65
+ if (n < 3 && doc.run.provisional !== true) errors.push({ path: '/run/provisional', message: `confirmRuns ${n} is below 3; the run must declare provisional: true` });
66
+ if (n >= 3 && doc.run.provisional === true) errors.push({ path: '/run/provisional', message: `confirmRuns ${n} is confirmed; provisional must be absent or false` });
67
+ }
68
+ 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' });
69
+ 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
70
  doc.records.forEach((r, i) => {
51
71
  const p = `/records/${i}`;
52
72
  const expected = fingerprint({ claimId: r.claim.id, subjectId: r.subject.id, file: r.subject.file ?? '', verdict: r.verdict });
53
73
  if (r.fingerprint !== expected) errors.push({ path: `${p}/fingerprint`, message: `fingerprint does not match spec derivation (expected ${expected})` });
54
74
 
75
+ // What the schema no longer requires, the validator still does — for the
76
+ // one method that means it. Fault injection without defenders, inputs or
77
+ // runs is not a leaner document, it is a verdict with nothing behind it;
78
+ // relaxing the schema was to let a scanner conform, never to let an
79
+ // injecting tool stop showing its work.
80
+ if (injection) {
81
+ for (const k of ['defenders', 'inputs']) {
82
+ if (!r[k]) errors.push({ path: `${p}/${k}`, message: `fault-injection requires ${k} on every record` });
83
+ }
84
+ for (const k of ['baselineRuns', 'probeRuns']) {
85
+ if (!Array.isArray(r.detail[k])) errors.push({ path: `${p}/detail/${k}`, message: `fault-injection requires detail.${k} on every record` });
86
+ }
87
+ }
88
+ // Every rule below reads those fields, so a record that is already
89
+ // malformed must not also throw on the way to being reported.
90
+ if (!injection || !r.defenders || !r.inputs || !Array.isArray(r.detail.baselineRuns) || !Array.isArray(r.detail.probeRuns)) return;
91
+
55
92
  const { baselineRuns, probeRuns } = r.detail;
56
93
  if (r.verdict === 'killed' || r.verdict === 'survived') {
57
94
  if (baselineRuns.length !== n) errors.push({ path: `${p}/detail/baselineRuns`, message: `${r.verdict} requires exactly confirmRuns (${n}) baseline runs, got ${baselineRuns.length}` });
@@ -74,12 +111,75 @@ const semantic = {
74
111
  if (r.verdict === 'nocover' && !r.defenders.nocover) {
75
112
  errors.push({ path: `${p}/defenders/nocover`, message: 'nocover verdict requires defenders.nocover = true' });
76
113
  }
114
+ if (r.detail.flakeRate) {
115
+ const { runs, failures } = r.detail.flakeRate;
116
+ if (failures > runs) errors.push({ path: `${p}/detail/flakeRate`, message: `flakeRate failures (${failures}) exceed runs (${runs})` });
117
+ 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' });
118
+ }
119
+ if (r.detail.independence) {
120
+ if (r.verdict !== 'killed') errors.push({ path: `${p}/detail/independence`, message: 'independence is a property of a kill; only killed records carry it' });
121
+ const { class: cls, defenderCommit, targetCommit } = r.detail.independence;
122
+ if (cls !== 'unknown' && !(defenderCommit && targetCommit)) errors.push({ path: `${p}/detail/independence`, message: `independence class ${cls} requires both defenderCommit and targetCommit` });
123
+ }
77
124
  if (r.detail.undeclaredKillers && r.detail.reason !== 'killed-by-undeclared-tests') {
78
125
  errors.push({ path: `${p}/detail/undeclaredKillers`, message: 'undeclaredKillers is only meaningful with reason killed-by-undeclared-tests' });
79
126
  }
127
+ if (r.defenders.byRunner) {
128
+ const resolved = new Set(r.defenders.resolved);
129
+ 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` });
130
+ 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' });
131
+ }
132
+ if (r.defenders.mocking && r.defenders.discovered) {
133
+ 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` });
134
+ }
135
+ for (const s of r.defenders.signals ?? []) {
136
+ if (s.signal === 'unasserted-annotated' && !s.reason) errors.push({ path: `${p}/defenders/signals`, message: `unasserted-annotated on ${s.file} requires the annotation's reason` });
137
+ // An attribute patch (Python) leaves the file a defender, so it belongs
138
+ // to `resolved` and not to `mocking`; every other signal is about a file
139
+ // that replaced the module and must be listed as mocking it.
140
+ if (s.signal === 'target-attribute-patched') {
141
+ if (!s.reason) errors.push({ path: `${p}/defenders/signals`, message: `target-attribute-patched on ${s.file} requires the patched attributes as its reason` });
142
+ if (!r.defenders.resolved.includes(s.file)) errors.push({ path: `${p}/defenders/signals`, message: `${s.file} patches attributes of the subject but is not a resolved defender` });
143
+ } else if (!(r.defenders.mocking ?? []).includes(s.file)) {
144
+ errors.push({ path: `${p}/defenders/signals`, message: `${s.file} carries a mock signal but is not listed in defenders.mocking` });
145
+ }
146
+ }
147
+ // A module the defenders never imported cannot have been killed by them.
148
+ if (r.detail.targetNotImported && r.verdict === 'killed') {
149
+ errors.push({ path: `${p}/detail/targetNotImported`, message: 'the subject was never imported, so the defenders cannot have killed the fault' });
150
+ }
151
+ // The control is only ever charged on a would-be survivor, so a `reached`
152
+ // record must be one, and `not-reached` must have become unverifiable.
153
+ if (r.detail.negativeControl === 'reached' && r.verdict !== 'survived') {
154
+ errors.push({ path: `${p}/detail/negativeControl`, message: `negativeControl "reached" belongs to a survivor, not to ${r.verdict}` });
155
+ }
156
+ if (r.detail.negativeControl === 'not-reached' && !(r.verdict === 'unverifiable' && r.detail.reason === 'subject-not-executed')) {
157
+ errors.push({ path: `${p}/detail/negativeControl`, message: 'negativeControl "not-reached" requires verdict unverifiable with reason subject-not-executed' });
158
+ }
159
+ if (r.detail.reason === 'subject-not-executed' && r.detail.negativeControl !== 'not-reached') {
160
+ errors.push({ path: `${p}/detail/reason`, message: 'subject-not-executed requires the negativeControl that established it' });
161
+ }
162
+ // Python's import provenance and the general control answer the same
163
+ // question. A document where they disagree describes a run that cannot
164
+ // have happened.
165
+ if (r.detail.targetNotImported && r.detail.negativeControl === 'reached') {
166
+ errors.push({ path: `${p}/detail/negativeControl`, message: 'the subject was never imported, so the negative control cannot have reached it' });
167
+ }
80
168
  if (r.verdict === 'unverifiable' && !r.detail.reason) {
81
169
  errors.push({ path: `${p}/detail/reason`, message: 'unverifiable requires a reason (e.g. anchor-missing, anchor-ambiguous)' });
82
170
  }
171
+ // `probe-error` is the one reason that names nothing on its own: it says
172
+ // the run threw, not what threw. Without the message the record is a
173
+ // dead end for anyone reading the document instead of rerunning it.
174
+ if (r.detail.reason === 'probe-error' && !r.detail.message) {
175
+ errors.push({ path: `${p}/detail/message`, message: 'probe-error requires a message naming what threw' });
176
+ }
177
+ // In-place is the mode where a vanished target is the user's own file
178
+ // gone; it raises rather than records. A document claiming otherwise
179
+ // describes a run that cannot have happened.
180
+ if (r.detail.restoreSkipped && doc.run.mode === 'in-place') {
181
+ errors.push({ path: `${p}/detail/restoreSkipped`, message: 'restoreSkipped cannot occur in in-place mode, where a missing target is raised' });
182
+ }
83
183
  });
84
184
  return errors;
85
185
  },
@@ -104,10 +204,42 @@ const semantic = {
104
204
  if (doc.state === 'clean' && (doc.counts.new ?? 0) > 0) errors.push({ path: '/state', message: 'clean with new findings' });
105
205
  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
206
  if (doc.state === 'unclaimed-changes' && doc.next.action !== 'claim') errors.push({ path: '/next/action', message: 'unclaimed-changes requires next.action = claim' });
207
+ if (doc.evidenceSource && !doc.evidenceHead) errors.push({ path: '/evidenceHead', message: 'evidence was read, so the commit it describes must be recorded' });
107
208
  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
209
  return errors;
109
210
  },
110
211
 
212
+ replay(doc) {
213
+ const errors = [];
214
+ const n = doc.run.confirmRuns;
215
+ const seen = new Set();
216
+ doc.records.forEach((r, i) => {
217
+ const p = `/records/${i}`;
218
+ // One patch, one row: a dual-branch topology carries the same fix under
219
+ // two or three shas, and counting it twice corrupts the corpus the
220
+ // calibration is computed from.
221
+ if (seen.has(r.patchId)) errors.push({ path: `${p}/patchId`, message: `duplicate patch-id ${r.patchId.slice(0, 12)}: the same fix counted twice` });
222
+ seen.add(r.patchId);
223
+ if (['caught', 'blind'].includes(r.verdict) && r.runs.length !== n) {
224
+ errors.push({ path: `${p}/runs`, message: `${r.verdict} requires exactly confirmRuns (${n}) runs, got ${r.runs.length}` });
225
+ }
226
+ // Only a test body rejecting the reverted source counts as caught —
227
+ // the same rule as `killed`, for the same reason.
228
+ if (r.verdict === 'caught' && !r.runs.every((x) => x.outcome === 'fail' && (x.assertionFailures ?? 0) > 0)) {
229
+ errors.push({ path: `${p}/runs`, message: 'caught requires every run to fail with at least one assertion failure' });
230
+ }
231
+ if (r.verdict === 'blind' && !r.runs.every((x) => x.outcome === 'pass')) {
232
+ errors.push({ path: `${p}/runs`, message: 'blind requires every run to pass: the suite stayed green on known-broken code' });
233
+ }
234
+ if (r.verdict === 'flaky' && !(r.runs.some((x) => x.outcome === 'pass') && r.runs.some((x) => x.outcome === 'fail'))) {
235
+ errors.push({ path: `${p}/runs`, message: 'flaky requires runs that disagree' });
236
+ }
237
+ 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' });
238
+ 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)' });
239
+ });
240
+ return errors;
241
+ },
242
+
111
243
  gate(doc) {
112
244
  const errors = [];
113
245
  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})` });
@@ -1,20 +1,28 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "urn:guard-spec:v1:baseline",
4
- "title": "Guard spec v1 — baseline",
3
+ "$id": "urn:claimspec:v1:baseline",
4
+ "title": "claimspec v1 — baseline",
5
5
  "description": "Frozen fingerprints of findings that existed when the baseline was taken. A finding whose fingerprint appears here is suppressed up to `count` occurrences; everything else is new and gates. Only non-passing verdicts are ever fingerprinted.",
6
6
  "type": "object",
7
7
  "required": ["schemaVersion", "tool", "createdAt", "fingerprints"],
8
8
  "properties": {
9
9
  "$schema": { "type": "string" },
10
10
  "schemaVersion": { "const": 1 },
11
- "tool": { "$ref": "urn:guard-spec:v1:common#/$defs/tool" },
12
- "createdAt": { "$ref": "urn:guard-spec:v1:common#/$defs/isoDateTime" },
13
- "head": { "$ref": "urn:guard-spec:v1:common#/$defs/gitSha" },
11
+ "tool": { "$ref": "urn:claimspec:v1:common#/$defs/tool" },
12
+ "createdAt": { "$ref": "urn:claimspec:v1:common#/$defs/isoDateTime" },
13
+ "head": { "$ref": "urn:claimspec:v1:common#/$defs/gitSha" },
14
14
  "dirty": {
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:claimspec: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:claimspec:v1:common#/$defs/isoDateTime"
25
+ },
18
26
  "fingerprints": {
19
27
  "type": "object",
20
28
  "propertyNames": { "pattern": "^[a-f0-9]{64}$" },
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "urn:guard-spec:v1:brief",
4
- "title": "Guard spec v1 — agent brief",
3
+ "$id": "urn:claimspec:v1:brief",
4
+ "title": "claimspec v1 — agent brief",
5
5
  "description": "What to tell an agent before it writes code. `text` is the rendered block for an additionalContext payload; `items` is the same content structured so other tools can compose briefs. Ranked, capped, and never a single score.",
6
6
  "type": "object",
7
7
  "required": ["schemaVersion", "tool", "generatedAt", "heading", "summary", "items", "text"],
8
8
  "properties": {
9
9
  "$schema": { "type": "string" },
10
10
  "schemaVersion": { "const": 1 },
11
- "tool": { "$ref": "urn:guard-spec:v1:common#/$defs/tool" },
12
- "generatedAt": { "$ref": "urn:guard-spec:v1:common#/$defs/isoDateTime" },
13
- "head": { "$ref": "urn:guard-spec:v1:common#/$defs/gitSha" },
11
+ "tool": { "$ref": "urn:claimspec:v1:common#/$defs/tool" },
12
+ "generatedAt": { "$ref": "urn:claimspec:v1:common#/$defs/isoDateTime" },
13
+ "head": { "$ref": "urn:claimspec:v1:common#/$defs/gitSha" },
14
14
  "heading": {
15
15
  "description": "The markdown heading that opens `text`, e.g. `## TEST BLINDSPOT CONTEXT`.",
16
16
  "type": "string",
@@ -51,7 +51,7 @@
51
51
  "required": ["ref", "files"],
52
52
  "properties": {
53
53
  "ref": { "type": "string", "minLength": 1, "maxLength": 256 },
54
- "files": { "type": "array", "minItems": 1, "items": { "$ref": "urn:guard-spec:v1:common#/$defs/uncoveredFile" } }
54
+ "files": { "type": "array", "minItems": 1, "items": { "$ref": "urn:claimspec:v1:common#/$defs/uncoveredFile" } }
55
55
  },
56
56
  "additionalProperties": false
57
57
  },
@@ -62,13 +62,13 @@
62
62
  "type": "object",
63
63
  "required": ["fingerprint", "claimId", "statement", "verdict", "severity", "isNew"],
64
64
  "properties": {
65
- "fingerprint": { "$ref": "urn:guard-spec:v1:common#/$defs/sha256" },
66
- "claimId": { "$ref": "urn:guard-spec:v1:common#/$defs/identifier" },
67
- "subjectId": { "$ref": "urn:guard-spec:v1:common#/$defs/identifier" },
65
+ "fingerprint": { "$ref": "urn:claimspec:v1:common#/$defs/sha256" },
66
+ "claimId": { "$ref": "urn:claimspec:v1:common#/$defs/identifier" },
67
+ "subjectId": { "$ref": "urn:claimspec:v1:common#/$defs/identifier" },
68
68
  "statement": { "type": "string", "minLength": 1, "maxLength": 2000 },
69
- "verdict": { "$ref": "urn:guard-spec:v1:common#/$defs/verdict" },
70
- "severity": { "$ref": "urn:guard-spec:v1:common#/$defs/severity" },
71
- "file": { "$ref": "urn:guard-spec:v1:common#/$defs/repoPath" },
69
+ "verdict": { "$ref": "urn:claimspec:v1:common#/$defs/verdict" },
70
+ "severity": { "$ref": "urn:claimspec:v1:common#/$defs/severity" },
71
+ "file": { "$ref": "urn:claimspec:v1:common#/$defs/repoPath" },
72
72
  "rank": { "type": "number" },
73
73
  "hint": {
74
74
  "description": "One line the agent can act on, e.g. which assertion shape left the gap.",
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "urn:guard-spec:v1:calibration",
4
- "title": "Guard spec v1 — calibration",
3
+ "$id": "urn:claimspec:v1:calibration",
4
+ "title": "claimspec v1 — calibration",
5
5
  "description": "P(finding is real) per bucket, with a confidence interval and the sample size behind it. A label without n is a vibe; this is the format that stops it being one.",
6
6
  "type": "object",
7
7
  "required": ["schemaVersion", "tool", "computedAt", "method", "confidence", "bucketBy", "source", "buckets"],
8
8
  "properties": {
9
9
  "$schema": { "type": "string" },
10
10
  "schemaVersion": { "const": 1 },
11
- "tool": { "$ref": "urn:guard-spec:v1:common#/$defs/tool" },
12
- "computedAt": { "$ref": "urn:guard-spec:v1:common#/$defs/isoDateTime" },
11
+ "tool": { "$ref": "urn:claimspec:v1:common#/$defs/tool" },
12
+ "computedAt": { "$ref": "urn:claimspec:v1:common#/$defs/isoDateTime" },
13
13
  "method": {
14
14
  "description": "Interval method. `wilson` is the only method defined in v1.",
15
15
  "enum": ["wilson"]
@@ -17,7 +17,7 @@
17
17
  "confidence": { "type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1 },
18
18
  "bucketBy": {
19
19
  "description": "The field findings are bucketed on, e.g. `faultClass` or `severity`.",
20
- "$ref": "urn:guard-spec:v1:common#/$defs/identifier"
20
+ "$ref": "urn:claimspec:v1:common#/$defs/identifier"
21
21
  },
22
22
  "source": {
23
23
  "description": "Where the ground-truth labels came from.",