testguard-cli 0.6.0 → 0.8.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 (45) hide show
  1. package/CHANGELOG.md +489 -0
  2. package/README.md +287 -34
  3. package/package.json +2 -2
  4. package/spec/GATE-SEMANTICS.md +210 -8
  5. package/spec/README.md +32 -11
  6. package/spec/lib/validate.mjs +125 -14
  7. package/spec/lib/wilson.mjs +112 -0
  8. package/spec/schemas/baseline.schema.json +7 -7
  9. package/spec/schemas/brief.schema.json +12 -12
  10. package/spec/schemas/calibration.schema.json +96 -26
  11. package/spec/schemas/claims.schema.json +129 -25
  12. package/spec/schemas/common.schema.json +127 -29
  13. package/spec/schemas/evidence.schema.json +175 -73
  14. package/spec/schemas/gate.schema.json +12 -12
  15. package/spec/schemas/ignore.schema.json +4 -4
  16. package/spec/schemas/replay.schema.json +10 -10
  17. package/spec/schemas/status.schema.json +25 -25
  18. package/src/cli.mjs +20 -5
  19. package/src/commands/claims.mjs +22 -2
  20. package/src/commands/probe.mjs +24 -4
  21. package/src/commands/replay.mjs +1 -1
  22. package/src/gate/changed.mjs +9 -2
  23. package/src/probe/attribution.mjs +123 -0
  24. package/src/probe/classify.mjs +18 -2
  25. package/src/probe/cost.mjs +205 -0
  26. package/src/probe/discover.mjs +14 -6
  27. package/src/probe/inject.mjs +41 -9
  28. package/src/probe/mocks.mjs +2 -0
  29. package/src/probe/probe.mjs +235 -52
  30. package/src/probe/progress.mjs +87 -0
  31. package/src/probe/pyimports.mjs +287 -0
  32. package/src/probe/rank.mjs +2 -0
  33. package/src/probe/runners/index.mjs +38 -11
  34. package/src/probe/runners/jest.mjs +13 -1
  35. package/src/probe/runners/playwright.mjs +9 -0
  36. package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
  37. package/src/probe/runners/python/_testguard_report.py +141 -0
  38. package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
  39. package/src/probe/runners/python.mjs +316 -0
  40. package/src/probe/runners/shared.mjs +10 -1
  41. package/src/probe/runners/vitest.mjs +13 -1
  42. package/src/replay/replay.mjs +50 -22
  43. package/src/scaffold/producers.python.mjs +209 -0
  44. package/src/scaffold/scaffold.mjs +86 -32
  45. package/src/util/glob.mjs +7 -1
@@ -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** |
@@ -36,10 +36,16 @@ Rules that follow from the table:
36
36
  that failed and then passed on retry (Playwright's `flaky`) is a
37
37
  **non-green run** even when the runner exits 0, and a test the runner
38
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.
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.
43
49
  4. **A mixed result is not a kill.** If some of the N probe runs fail and
44
50
  others pass, the defender's response to the fault is nondeterministic.
45
51
  Reporting `killed` would be the optimistic bias flakiness introduces;
@@ -68,6 +74,115 @@ Rules that follow from the table:
68
74
  content hash; the status document lists every fault whose content
69
75
  changed since it was probed, with its previous verdict. The change is
70
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
+ A reserved method carries its own data in `methodDetail`, on the probe and
171
+ on the record: an object the spec deliberately does not constrain, so a tool
172
+ can say which rule it checked and where. Without it, "reserved" would mean a
173
+ probe that can declare it exists and nothing about it, which is not a
174
+ reservation but a dead end. It is **forbidden under `fault-injection`**,
175
+ whose shape is specified and must not acquire a junk drawer, and it is a
176
+ staging area rather than a permanent home: what the first consumer puts
177
+ there is the evidence for what the specified shape should become.
178
+ `assertion` and `scan` are **reserved**. Their required fields are
179
+ deliberately unspecified and will be defined by their first real consumer,
180
+ with its own conformance examples written from the shape that tool actually
181
+ has. Designing them for an absent tool is precisely the mistake that made
182
+ this rule necessary: the spec was drafted against a single consumer and
183
+ hard-coded its assumptions as requirements.
184
+ The verdict set stays closed and shared. What each method may legitimately
185
+ report is part of defining it.
71
186
 
72
187
  ## Replay reports; it never gates
73
188
 
@@ -94,9 +209,96 @@ Two rules that follow:
94
209
  2. **The fix's own test is removed before the run.** It proves nothing about
95
210
  what the suite knew before the fix existed.
96
211
 
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.
212
+ `caught`, `blind` and `nocover` are measurements and enter the calibration
213
+ ratio: `caught` as a hit, the other two as misses. `nocover` counts as a miss
214
+ because it is one — the worst kind. Leaving it out would let a project with
215
+ **no tests at all** for a subsystem score better than one with weak tests,
216
+ since its worst outcomes would leave the denominator before the ratio is
217
+ taken; Stryker and PIT count no-coverage mutants against the headline score
218
+ for the same reason. `flaky` and `unverifiable` are failed measurements and
219
+ enter neither side.
220
+
221
+ ### Calibration arithmetic is reproducible
222
+
223
+ A calibration cell declares `n` and `positives`, and with them has declared
224
+ `p`; a document declares `method: wilson` at `confidence`, and with them has
225
+ declared every `ci`. The validator recomputes both with `lib/wilson.mjs` —
226
+ the single implementation, as `lib/fingerprint.mjs` is for fingerprints — and
227
+ a cell that does not reproduce does not conform. Before this rule the
228
+ validator checked only that `p` lay inside `ci`, which a document with every
229
+ number invented satisfies, and the spec's own example shipped a truncated
230
+ lower bound (6/30 → `0.09`, where Wilson gives 0.0950 → `0.10`) that nothing
231
+ could notice.
232
+
233
+ - **Precision is the document's own**: the most decimal places any `p` or
234
+ `ci` value shows, capped at 6. Values are compared after rounding to that
235
+ precision, so a producer at 3 dp and one at 4 dp both reproduce and a
236
+ truncated bound does not.
237
+ - **z may be the exact quantile or the textbook rounding** (1.96, 2.576,
238
+ 1.645). The two differ by up to 9.2e-6 in the interval — enough to flip a
239
+ rounding boundary at 4 dp — so either reproduces, and a document never fails
240
+ for having used the number in the textbook.
241
+ - **`p` is `positives / n`.** A smoothed or shrunk point estimate is a
242
+ different method and must say so; under `wilson` the point estimate is the
243
+ observed proportion.
244
+
245
+ ### Calibration provenance, backoff and merging
246
+
247
+ Everything beyond a document's primary buckets is optional and additive, in
248
+ the way `method` is on claims and evidence: absence has a defined meaning, and
249
+ no document written before a field existed becomes invalid. The meaning of
250
+ absence is **unattributed** — readable, auditable, and not a source of
251
+ numbers. A tool that emits calibrations always emits these fields; a
252
+ self-claim guards that it does.
253
+
254
+ - **`measures`** says what `p` is the probability of. It is the one field
255
+ that lets two calibrations be told apart: testguard's `escape-missed` is
256
+ P(a real escaped bug of this class was not caught), and high is bad;
257
+ websec-validator's `finding-real` is P(a reported finding of this class is a
258
+ real vulnerability), and high is good. A document without it cannot be
259
+ quoted and cannot be merged. Registry: `escape-missed`, `finding-real`. A
260
+ new value is added here by the tool that first emits it.
261
+ - **`source`** carries provenance. `corpus` names external ground-truth
262
+ sources (a bug-replay's is the repository's own history, which `ref` already
263
+ names); `caveat` is the one sentence a consumer shows beside any quoted
264
+ number; `limitation` is the longer story; `evidenceStatus` says whether the
265
+ labels were reviewed; `kind: tool-oracle` is a tool confirming its own
266
+ findings, as websec-validator's dynamic probes do. `detail` is the tool's
267
+ own provenance, unconstrained, on the `methodDetail` pattern. A number
268
+ quoted without its `caveat` is a misquote.
269
+ - **`minN`** is the producer's floor. A cell below it is not quoted on its
270
+ own; the consumer backs off a tier. A consumer may raise it — a gate that
271
+ blocks a merge wants more certainty than a report — and may never lower
272
+ it: lowering it resurrects a cell the producer suppressed. Absent, the
273
+ producer sets no floor and the consumer applies its own. A spec-level
274
+ constant would be wrong in both directions: websec-validator's 5 would
275
+ suppress every cell of a corpus the size of DocGuard's benchmark.
276
+ - **`backoff`** is the ordered list of coarser tiers consulted below `minN`.
277
+ Each tier is held to the same arithmetic as the primary. Keys may be
278
+ compound (`attackClass|confidence` backs off to `confidence`), and every key
279
+ in a tier has as many `|`-parts as its `bucketBy` — a key with fewer was
280
+ translated from another table and lost a dimension on the way.
281
+ - **`fallback`** is what remains when no tier has a cell above `minN`: a
282
+ labelled guess, not a measurement, and anything quoted from it carries
283
+ `n = 0`, `ci = [0, 1]` and its `basis`. It lives in the document so that
284
+ "what did it assume when it had no data?" has one auditable answer instead
285
+ of one per consumer, each unlabelled.
286
+ - **`breakdown`** splits a cell's `n` by outcome when the producer can say
287
+ (testguard: `caught` / `blind` / `nocover`), and sums to `n`. It lets a
288
+ consumer recover a narrower rate — misses among covered code, say —
289
+ without the producer publishing a second, misleading headline.
290
+
291
+ **Merging.** Two calibrations merge by summing `n` and `positives` per cell
292
+ and recomputing `p` and `ci` from the sums — never by averaging `p` or `ci`,
293
+ which have no meaning combined. They merge only when `measures`, `bucketBy`
294
+ and `confidence` agree, and the merged document's `source.kind` is `mixed`.
295
+ This is how websec-validator folds an operator's own confirmed samples over
296
+ its shipped table, and it works only because cells carry raw counts.
297
+
298
+ **The second producer is a conformance example.** websec-validator's shipped
299
+ table, translated field for field, is `conformance/examples/calibration-websec.json`
300
+ — corpus, caveat, limitation, floor, backoff tier and labelled fallback all
301
+ present. That file, not a sentence in a README, is what "adoptable" means.
100
302
 
101
303
  ## Baseline and delta
102
304
 
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 |
@@ -19,7 +28,7 @@ The pattern all such tools run:
19
28
  | `evidence` | [`schemas/evidence.schema.json`](schemas/evidence.schema.json) | One run's findings on disk, readable without the tool. Every verdict carries every run that produced it. |
20
29
  | `baseline` | [`schemas/baseline.schema.json`](schemas/baseline.schema.json) | Frozen fingerprints of existing debt. Gate only what is new. |
21
30
  | `ignore` | [`schemas/ignore.schema.json`](schemas/ignore.schema.json) | Reviewable scoping. Every entry has a reason. |
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. |
31
+ | `calibration` | [`schemas/calibration.schema.json`](schemas/calibration.schema.json) | The observed rate of a bucket's outcome — what it measures is in the document — with a Wilson interval and the sample size behind it. Every number is recomputable and the validator recomputes it; provenance, a producer floor, backoff tiers and a labelled fallback ride along so a second tool's honesty survives translation. |
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. |
25
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. |
@@ -30,7 +39,7 @@ Shared definitions (verdicts, fault classes, provenance, annotations) live in
30
39
  which verdicts turn CI red, baseline and delta rules, exit codes — is in
31
40
  [`GATE-SEMANTICS.md`](GATE-SEMANTICS.md).
32
41
 
33
- 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>`.
34
43
 
35
44
  ## Conformance
36
45
 
@@ -53,5 +62,17 @@ npm run test:spec
53
62
 
54
63
  The spec is drafted here and validated against `testguard` as its first
55
64
  consumer. It moves to its own repository only when a second tool adopts it.
56
- Adoption by an existing tool is one additive output writer — no rewrite, no
57
- 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`, `status` and `calibration` — websec-validator's
69
+ shipped calibration table is `conformance/examples/calibration-websec.json`,
70
+ translated field for field with its corpus, caveat, floor, backoff and
71
+ fallback intact. It does **not** yet hold for
72
+ `claims` and `evidence`, whose required fields assume fault injection
73
+ (`find`/`replace` anchors, `confirmRuns`, `baselineRuns`/`probeRuns`,
74
+ `defenders`) — a tool that scans or reads would have to emit empty arrays to
75
+ satisfy the schema, which is conforming by lying. Making those two kinds
76
+ method-agnostic is tracked as
77
+ [#81](https://github.com/raccioly/testguard/issues/81) and blocks adoption of
78
+ either.
@@ -3,10 +3,18 @@ import { readFileSync, readdirSync } from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fingerprint } from './fingerprint.mjs';
6
+ import { reproduces, decimals, MAX_PLACES } from './wilson.mjs';
6
7
 
7
8
  const schemaDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'schemas');
8
9
 
9
10
  export const KINDS = Object.freeze(['claims', 'evidence', 'baseline', 'ignore', 'calibration', 'brief', 'status', 'gate', 'replay']);
11
+ /**
12
+ * How a document says it tried to falsify its claims. Absent means
13
+ * `fault-injection`, so every document written before the field existed is
14
+ * held to exactly the rules it was written against.
15
+ */
16
+ export const methodOf = (x) => x?.method ?? 'fault-injection';
17
+ const isInjection = (x) => methodOf(x) === 'fault-injection';
10
18
  export const PASSING_VERDICTS = Object.freeze(new Set(['killed']));
11
19
 
12
20
  const ajv = new Ajv2020({ strict: true, allErrors: true });
@@ -15,7 +23,7 @@ for (const f of readdirSync(schemaDir).filter((n) => n.endsWith('.schema.json'))
15
23
  }
16
24
 
17
25
  const schemaFor = (kind) => {
18
- const v = ajv.getSchema(`urn:guard-spec:v1:${kind}`);
26
+ const v = ajv.getSchema(`urn:claimspec:v1:${kind}`);
19
27
  if (!v) throw new RangeError(`unknown Guard-spec kind: ${kind}`);
20
28
  return v;
21
29
  };
@@ -33,10 +41,15 @@ const semantic = {
33
41
  const p = `/claims/${ci}/faults/${fi}`;
34
42
  if (seenFault.has(f.id)) errors.push({ path: `${p}/id`, message: `duplicate fault id "${f.id}" in claim "${c.id}"` });
35
43
  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' });
44
+ // Anchor arithmetic is a property of fault injection. A probe that does
45
+ // not substitute text has no `find` to compare, and `undefined ===
46
+ // undefined` would report every one of them as a no-op.
47
+ if (isInjection(f)) {
48
+ const occ = f.occurrence ?? 1;
49
+ const hits = f.expectHits ?? 1;
50
+ if (occ > hits) errors.push({ path: `${p}/occurrence`, message: `occurrence ${occ} exceeds expectHits ${hits}` });
51
+ if (f.find === f.replace) errors.push({ path: `${p}/replace`, message: 'replace is identical to find; the fault is a no-op' });
52
+ }
40
53
  });
41
54
  });
42
55
  return errors;
@@ -44,9 +57,15 @@ const semantic = {
44
57
 
45
58
  evidence(doc) {
46
59
  const errors = [];
60
+ const injection = isInjection(doc.run);
47
61
  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` });
62
+ // N-run agreement is what fault injection means by "confirmed". A method
63
+ // that does not re-run anything has no N, and a rule about one would be a
64
+ // rule about a number that is not there.
65
+ if (injection) {
66
+ if (n < 3 && doc.run.provisional !== true) errors.push({ path: '/run/provisional', message: `confirmRuns ${n} is below 3; the run must declare provisional: true` });
67
+ if (n >= 3 && doc.run.provisional === true) errors.push({ path: '/run/provisional', message: `confirmRuns ${n} is confirmed; provisional must be absent or false` });
68
+ }
50
69
  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
70
  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' });
52
71
  doc.records.forEach((r, i) => {
@@ -54,6 +73,26 @@ const semantic = {
54
73
  const expected = fingerprint({ claimId: r.claim.id, subjectId: r.subject.id, file: r.subject.file ?? '', verdict: r.verdict });
55
74
  if (r.fingerprint !== expected) errors.push({ path: `${p}/fingerprint`, message: `fingerprint does not match spec derivation (expected ${expected})` });
56
75
 
76
+ // What the schema no longer requires, the validator still does — for the
77
+ // one method that means it. Fault injection without defenders, inputs or
78
+ // runs is not a leaner document, it is a verdict with nothing behind it;
79
+ // relaxing the schema was to let a scanner conform, never to let an
80
+ // injecting tool stop showing its work.
81
+ if (injection && r.detail.methodDetail) {
82
+ errors.push({ path: `${p}/detail/methodDetail`, message: 'methodDetail is for a method the spec has not specified; fault-injection has a specified shape and must not acquire a junk drawer' });
83
+ }
84
+ if (injection) {
85
+ for (const k of ['defenders', 'inputs']) {
86
+ if (!r[k]) errors.push({ path: `${p}/${k}`, message: `fault-injection requires ${k} on every record` });
87
+ }
88
+ for (const k of ['baselineRuns', 'probeRuns']) {
89
+ if (!Array.isArray(r.detail[k])) errors.push({ path: `${p}/detail/${k}`, message: `fault-injection requires detail.${k} on every record` });
90
+ }
91
+ }
92
+ // Every rule below reads those fields, so a record that is already
93
+ // malformed must not also throw on the way to being reported.
94
+ if (!injection || !r.defenders || !r.inputs || !Array.isArray(r.detail.baselineRuns) || !Array.isArray(r.detail.probeRuns)) return;
95
+
57
96
  const { baselineRuns, probeRuns } = r.detail;
58
97
  if (r.verdict === 'killed' || r.verdict === 'survived') {
59
98
  if (baselineRuns.length !== n) errors.push({ path: `${p}/detail/baselineRuns`, message: `${r.verdict} requires exactly confirmRuns (${n}) baseline runs, got ${baselineRuns.length}` });
@@ -99,23 +138,95 @@ const semantic = {
99
138
  }
100
139
  for (const s of r.defenders.signals ?? []) {
101
140
  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` });
141
+ // An attribute patch (Python) leaves the file a defender, so it belongs
142
+ // to `resolved` and not to `mocking`; every other signal is about a file
143
+ // that replaced the module and must be listed as mocking it.
144
+ if (s.signal === 'target-attribute-patched') {
145
+ if (!s.reason) errors.push({ path: `${p}/defenders/signals`, message: `target-attribute-patched on ${s.file} requires the patched attributes as its reason` });
146
+ 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` });
147
+ } else if (!(r.defenders.mocking ?? []).includes(s.file)) {
148
+ errors.push({ path: `${p}/defenders/signals`, message: `${s.file} carries a mock signal but is not listed in defenders.mocking` });
149
+ }
150
+ }
151
+ // A module the defenders never imported cannot have been killed by them.
152
+ if (r.detail.targetNotImported && r.verdict === 'killed') {
153
+ errors.push({ path: `${p}/detail/targetNotImported`, message: 'the subject was never imported, so the defenders cannot have killed the fault' });
154
+ }
155
+ // The control is only ever charged on a would-be survivor, so a `reached`
156
+ // record must be one, and `not-reached` must have become unverifiable.
157
+ if (r.detail.negativeControl === 'reached' && r.verdict !== 'survived') {
158
+ errors.push({ path: `${p}/detail/negativeControl`, message: `negativeControl "reached" belongs to a survivor, not to ${r.verdict}` });
159
+ }
160
+ if (r.detail.negativeControl === 'not-reached' && !(r.verdict === 'unverifiable' && r.detail.reason === 'subject-not-executed')) {
161
+ errors.push({ path: `${p}/detail/negativeControl`, message: 'negativeControl "not-reached" requires verdict unverifiable with reason subject-not-executed' });
162
+ }
163
+ if (r.detail.reason === 'subject-not-executed' && r.detail.negativeControl !== 'not-reached') {
164
+ errors.push({ path: `${p}/detail/reason`, message: 'subject-not-executed requires the negativeControl that established it' });
165
+ }
166
+ // Python's import provenance and the general control answer the same
167
+ // question. A document where they disagree describes a run that cannot
168
+ // have happened.
169
+ if (r.detail.targetNotImported && r.detail.negativeControl === 'reached') {
170
+ errors.push({ path: `${p}/detail/negativeControl`, message: 'the subject was never imported, so the negative control cannot have reached it' });
103
171
  }
104
172
  if (r.verdict === 'unverifiable' && !r.detail.reason) {
105
173
  errors.push({ path: `${p}/detail/reason`, message: 'unverifiable requires a reason (e.g. anchor-missing, anchor-ambiguous)' });
106
174
  }
175
+ // `probe-error` is the one reason that names nothing on its own: it says
176
+ // the run threw, not what threw. Without the message the record is a
177
+ // dead end for anyone reading the document instead of rerunning it.
178
+ if (r.detail.reason === 'probe-error' && !r.detail.message) {
179
+ errors.push({ path: `${p}/detail/message`, message: 'probe-error requires a message naming what threw' });
180
+ }
181
+ // In-place is the mode where a vanished target is the user's own file
182
+ // gone; it raises rather than records. A document claiming otherwise
183
+ // describes a run that cannot have happened.
184
+ if (r.detail.restoreSkipped && doc.run.mode === 'in-place') {
185
+ errors.push({ path: `${p}/detail/restoreSkipped`, message: 'restoreSkipped cannot occur in in-place mode, where a missing target is raised' });
186
+ }
107
187
  });
108
188
  return errors;
109
189
  },
110
190
 
111
191
  calibration(doc) {
112
192
  const errors = [];
113
- for (const [key, b] of Object.entries(doc.buckets)) {
114
- const p = `/buckets/${key}`;
115
- if (b.positives > b.n) errors.push({ path: `${p}/positives`, message: `positives (${b.positives}) exceed n (${b.n})` });
116
- const [lo, hi] = b.ci;
117
- if (lo > hi) errors.push({ path: `${p}/ci`, message: `ci lower bound ${lo} exceeds upper bound ${hi}` });
118
- if (b.p < lo || b.p > hi) errors.push({ path: `${p}/p`, message: `p (${b.p}) lies outside ci [${lo}, ${hi}]` });
193
+ // The primary buckets and every backoff tier are held to the same rules:
194
+ // a coarser tier a consumer falls back to is exactly where a wrong number
195
+ // would go unnoticed.
196
+ const tiers = [{ bucketBy: doc.bucketBy, buckets: doc.buckets, path: '' }, ...(doc.backoff ?? []).map((t, i) => ({ ...t, path: `/backoff/${i}` }))];
197
+ // The precision the document wrote at: the most places any value shows.
198
+ // Parsing drops trailing zeros (0.10 → 0.1), so a single value can
199
+ // under-report; the maximum cannot unless every value ended in zero.
200
+ const places = tiers.flatMap((t) => Object.values(t.buckets)).reduce((m, b) => Math.max(m, decimals(b.p ?? 0), decimals(b.ci[0]), decimals(b.ci[1])), 0);
201
+ for (const t of tiers) {
202
+ // A compound bucketBy (`attackClass|confidence`) means compound keys,
203
+ // each with the same number of parts — a key with fewer was translated
204
+ // from another tool's table and lost a dimension on the way.
205
+ const arity = t.bucketBy.split('|').length;
206
+ for (const [key, b] of Object.entries(t.buckets)) {
207
+ const p = `${t.path}/buckets/${key}`;
208
+ const parts = key.split('|').length;
209
+ if (parts !== arity) errors.push({ path: p, message: `bucket key "${key}" has ${parts} part(s); bucketBy "${t.bucketBy}" has ${arity}` });
210
+ if (b.positives > b.n) errors.push({ path: `${p}/positives`, message: `positives (${b.positives}) exceed n (${b.n})` });
211
+ const [lo, hi] = b.ci;
212
+ if (lo > hi) errors.push({ path: `${p}/ci`, message: `ci lower bound ${lo} exceeds upper bound ${hi}` });
213
+ if (b.p !== null && (b.p < lo || b.p > hi)) errors.push({ path: `${p}/p`, message: `p (${b.p}) lies outside ci [${lo}, ${hi}]` });
214
+ if (b.breakdown) {
215
+ const sum = Object.values(b.breakdown).reduce((s, v) => s + v, 0);
216
+ if (sum !== b.n) errors.push({ path: `${p}/breakdown`, message: `breakdown sums to ${sum}; n is ${b.n}` });
217
+ }
218
+ // A cell that declares n and positives has declared p; a document that
219
+ // declares `wilson` at `confidence` has declared every ci. Recompute
220
+ // both with the spec's own implementation: "conforms" means
221
+ // "reproducible", not "internally plausible". Before this the validator
222
+ // would pass a document with every number invented, and the spec's own
223
+ // example carried a truncated bound nothing could notice.
224
+ // A cell with more positives than trials has no proportion to reproduce.
225
+ if (b.positives > b.n) continue;
226
+ const r = reproduces(b, { confidence: doc.confidence, places });
227
+ if (!r.p) errors.push({ path: `${p}/p`, message: `p (${b.p}) is not positives/n: ${b.positives}/${b.n} = ${r.expected.p} at ${Math.min(places, MAX_PLACES)} dp` });
228
+ if (!r.ci) errors.push({ path: `${p}/ci`, message: `ci [${lo}, ${hi}] does not reproduce: ${doc.method} at ${doc.confidence} for ${b.positives}/${b.n} is [${r.expected.ci[0]}, ${r.expected.ci[1]}]` });
229
+ }
119
230
  }
120
231
  return errors;
121
232
  },
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The single Wilson interval implementation, as `fingerprint.mjs` is the
3
+ * single fingerprint: a producer emits with it, the validator recomputes with
4
+ * it, and "conforms" means the two agree. Before this file existed the emitter
5
+ * and the validator had no shared arithmetic at all — the validator checked
6
+ * that `p` sat inside `ci` and nothing else, and the spec's own example
7
+ * carried a truncated lower bound (6/30 → 0.09, where Wilson gives 0.0950 →
8
+ * 0.10) that nothing could notice.
9
+ */
10
+
11
+ // Inverse normal CDF: Acklam's rational approximation, |relative error| < 1.2e-9.
12
+ // Enough to reproduce an interval to 6 decimal places, which is the most any
13
+ // producer writes (websec-validator 3, testguard 4, docguard 6).
14
+ const A = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02, 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00];
15
+ const B = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02, 6.680131188771972e+01, -1.328068155288572e+01];
16
+ const C = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00, -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00];
17
+ const D = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, 3.754408661907416e+00];
18
+ const P_LOW = 0.02425;
19
+
20
+ /** The standard normal quantile: z such that Φ(z) = p. */
21
+ export function probit(p) {
22
+ if (!(p > 0 && p < 1)) throw new RangeError(`probit is defined on (0, 1), got ${p}`);
23
+ let q;
24
+ if (p < P_LOW) {
25
+ q = Math.sqrt(-2 * Math.log(p));
26
+ return (((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5]) / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1);
27
+ }
28
+ if (p <= 1 - P_LOW) {
29
+ q = p - 0.5;
30
+ const r = q * q;
31
+ return (((((A[0] * r + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]) * q / (((((B[0] * r + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1);
32
+ }
33
+ q = Math.sqrt(-2 * Math.log(1 - p));
34
+ return -(((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5]) / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1);
35
+ }
36
+
37
+ /**
38
+ * The textbook roundings of z that producers actually type. testguard and
39
+ * docguard use 1.96; websec-validator uses the exact quantile. Across n ≤ 200
40
+ * the two differ by at most 9.2e-6 in the interval, which can still flip a
41
+ * rounding boundary at 4 dp — so the validator accepts either, and a document
42
+ * never fails for having used the number in the textbook.
43
+ */
44
+ export const CONVENTIONAL_Z = Object.freeze({ 0.9: 1.645, 0.95: 1.96, 0.99: 2.576 });
45
+
46
+ /** Every z a producer may legitimately have used for a two-sided interval at `confidence`. */
47
+ export function zCandidates(confidence) {
48
+ const exact = probit(1 - (1 - confidence) / 2);
49
+ const conventional = CONVENTIONAL_Z[confidence];
50
+ return conventional === undefined ? [exact] : [exact, conventional];
51
+ }
52
+
53
+ /**
54
+ * Wilson score interval for `positives` in `n`, unrounded, clamped to [0, 1].
55
+ * Wilson rather than the normal approximation because it stays sane at small
56
+ * n and extreme p — exactly the regime a young corpus is in. n = 0 is maximal
57
+ * ignorance: [0, 1].
58
+ */
59
+ export function wilsonInterval(positives, n, z) {
60
+ if (n === 0) return [0, 1];
61
+ const p = positives / n;
62
+ const d = 1 + (z * z) / n;
63
+ const centre = (p + (z * z) / (2 * n)) / d;
64
+ const half = (z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n))) / d;
65
+ return [Math.max(0, centre - half), Math.min(1, centre + half)];
66
+ }
67
+
68
+ /** Round half away from zero to `places` decimals — what `toFixed` does on the value's binary form. */
69
+ export const roundTo = (x, places) => Number(x.toFixed(places));
70
+
71
+ /**
72
+ * How many decimal places a parsed JSON number still shows. Parsing drops
73
+ * trailing zeros (0.10 → 0.1), so a single value under-reports; the validator
74
+ * takes the maximum across a document, which is the precision the producer
75
+ * wrote at as long as at least one value did not end in zero.
76
+ */
77
+ export function decimals(x) {
78
+ if (!Number.isFinite(x)) return 0;
79
+ const s = String(x);
80
+ const e = s.indexOf('e-');
81
+ if (e !== -1) {
82
+ const mantissa = s.slice(0, e);
83
+ const dot = mantissa.indexOf('.');
84
+ return Number(s.slice(e + 2)) + (dot === -1 ? 0 : mantissa.length - dot - 1);
85
+ }
86
+ const dot = s.indexOf('.');
87
+ return dot === -1 ? 0 : s.length - dot - 1;
88
+ }
89
+
90
+ /** The most decimal places the validator will hold a document to; beyond it the quantile approximation is the noise. */
91
+ export const MAX_PLACES = 6;
92
+
93
+ const near = (a, b) => Math.abs(a - b) < 1e-9;
94
+
95
+ /**
96
+ * Does a cell's declared `p` and `ci` reproduce from its own `n` and
97
+ * `positives`? Everything is compared after rounding to `places` — the
98
+ * precision the document wrote at — so a producer at 3 dp and one at 4 dp
99
+ * both reproduce, and a truncated bound does not. A `p` of `null` is a cell
100
+ * that declines to give a point estimate; only its interval is checked.
101
+ */
102
+ export function reproduces(cell, { confidence, places }) {
103
+ const { n, positives, p, ci } = cell;
104
+ const pl = Math.min(places, MAX_PLACES);
105
+ const expectedP = n === 0 ? null : roundTo(positives / n, pl);
106
+ const candidates = zCandidates(confidence).map((z) => wilsonInterval(positives, n, z).map((v) => roundTo(v, pl)));
107
+ return {
108
+ p: p === null || n === 0 || near(expectedP, p),
109
+ ci: candidates.some(([lo, hi]) => near(lo, ci[0]) && near(hi, ci[1])),
110
+ expected: { p: expectedP, ci: candidates[0] },
111
+ };
112
+ }