testguard-cli 0.10.4 → 0.12.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 +85 -0
- package/README.md +130 -5
- package/package.json +1 -1
- package/spec/lib/validate.mjs +55 -1
- package/spec/schemas/sweep.schema.json +310 -0
- package/src/cli.mjs +12 -1
- package/src/commands/sweep.mjs +80 -0
- package/src/supply/persistence.mjs +173 -0
- package/src/supply/savepath.mjs +216 -0
- package/src/supply/select.mjs +168 -0
- package/src/sweep/sweep.mjs +302 -0
- package/src/util/glob.mjs +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,91 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.12.0] - 2026-09-22
|
|
11
|
+
|
|
12
|
+
Automated weekly release — everything merged since `v0.11.0`.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- feat(sweep): --save-paths, and the denominator a save report is read against (#156)
|
|
17
|
+
- docs: make the brief describe what the tool actually does, and record the prior art (#155)
|
|
18
|
+
- chore(homebrew): sha256 for v0.11.0 (#153)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `sweep --save-paths` — sweep every file that writes to storage instead of the
|
|
24
|
+
diff, and report the surface as the denominator the findings are read
|
|
25
|
+
against. A diff cannot answer how many save paths exist, and a clean report
|
|
26
|
+
over an unstated denominator is a sample of unknown size. The sweep document
|
|
27
|
+
gains `scope.mode`, `scope.writeSites` and `scope.payloadFields`, and the
|
|
28
|
+
validator refuses a save-paths sweep that omits its surface.
|
|
29
|
+
- `src/supply/savepath.mjs` — the write-site enumerator: which calls put data
|
|
30
|
+
into storage, and which payload keys sit on their own line (the ones a
|
|
31
|
+
`field-dropped` fault can anchor to). Measured on a real AI-authored
|
|
32
|
+
application: 119 writes across 37 files carrying 226 payload fields.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- `walk()` skipped `dist` and `coverage` but not `.next`, `build`, `out`,
|
|
37
|
+
`.svelte-kit`, `.turbo` and their siblings, while the claim above it already
|
|
38
|
+
promised that a build's test files cannot be picked up. A scan of a real
|
|
39
|
+
Next.js application counted 104 files instead of 32, because `.next/` carries
|
|
40
|
+
compiled copies of the application — its writes and its tests. The claim was
|
|
41
|
+
right; the list was short.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- `docs-canonical/PRIOR-ART.md` — the canonical record of what was taken from
|
|
46
|
+
Google's and Meta's mutation-testing programmes, what was deliberately not
|
|
47
|
+
taken, the measured reason no model decides a verdict, and the known gaps in
|
|
48
|
+
what was borrowed. Registered with DocGuard.
|
|
49
|
+
- README prior-art section pointing at it.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- The technical brief now describes what the tool actually does. A new
|
|
54
|
+
*Starting from zero* page carries Field report C (the persistence-path
|
|
55
|
+
measurement: 12 of 26 faults survived a green suite, all of them a dropped
|
|
56
|
+
payload field) and the claim-supply ladder; prior art names Google and Meta
|
|
57
|
+
and what each solved; the decision-model result is stated as a measurement
|
|
58
|
+
rather than a design assertion; and a *what it deliberately is not* section
|
|
59
|
+
closes it. `sweep` added to the verb table. Six pages to eight, re-rendered.
|
|
60
|
+
|
|
61
|
+
## [0.11.0] - 2026-09-21
|
|
62
|
+
|
|
63
|
+
Automated weekly release — everything merged since `v0.10.4`.
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
- feat(sweep): findings on a repository that has no claims yet (#151)
|
|
68
|
+
- chore(homebrew): sha256 for v0.10.4 (#149)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
|
|
73
|
+
- `testguard sweep [dir] --changed <ref>` — the cold start. Proposes faults for
|
|
74
|
+
the changed source files that carry no claim, probes a bounded selection, and
|
|
75
|
+
reports what a green suite did not notice. Never writes
|
|
76
|
+
`testguard.claims.json`; its evidence never replaces
|
|
77
|
+
`.testguard/evidence.json`; only `survived` and `nocover` exit 1, so a
|
|
78
|
+
proposal the tool could not anchor or compile is reported and never gates.
|
|
79
|
+
Offline, like every other verifying command.
|
|
80
|
+
- Selection follows Google's mutation service: at most 7 × |files| candidates,
|
|
81
|
+
spread round-robin across files, ordered on the measured productivity of the
|
|
82
|
+
fault class and shrunk toward a neutral prior so one observation never
|
|
83
|
+
dominates (`src/supply/select.mjs`).
|
|
84
|
+
- `persistence-payload-unasserted` — why a survivor on a write path was missed:
|
|
85
|
+
the defender mocks the persistence layer and the file's call assertions never
|
|
86
|
+
name an exact payload, so a field dropped from a write cannot fail them.
|
|
87
|
+
Descriptive, never predictive; attached only to a survivor on a write path
|
|
88
|
+
(`src/supply/persistence.mjs`).
|
|
89
|
+
- `urn:claimspec:v1:sweep` — the sweep document, with semantic rules that check
|
|
90
|
+
a cap never hides its own remainder.
|
|
91
|
+
- Five self-claims (8 faults) covering the cap arithmetic, the exit-code rule,
|
|
92
|
+
where the persistence signal may be attached, per-file cap fairness, and that
|
|
93
|
+
one assertion's arguments are read without reading the next one's.
|
|
94
|
+
|
|
10
95
|
## [0.10.4] - 2026-09-21
|
|
11
96
|
|
|
12
97
|
Automated weekly release — everything merged since `v0.10.3`.
|
package/README.md
CHANGED
|
@@ -127,9 +127,9 @@ raises coverage. TestGuard admits it because it fails when the claim is false.
|
|
|
127
127
|
| npm | `npm i -D testguard-cli` then `npx testguard probe` |
|
|
128
128
|
| pip | `pip install testguard-cli` then `testguard probe` (needs Node ≥ 20) |
|
|
129
129
|
| Homebrew | `brew tap raccioly/tap && brew install testguard` |
|
|
130
|
-
| GitHub Action | `uses: raccioly/testguard@v0.
|
|
130
|
+
| GitHub Action | `uses: raccioly/testguard@v0.12.0` — see [`action.yml`](./action.yml) |
|
|
131
131
|
| pre-commit | `repo: https://github.com/raccioly/testguard`, hooks `testguard-claims`, `testguard-probe` |
|
|
132
|
-
| GitLab CI | `include: - remote: https://raw.githubusercontent.com/raccioly/testguard/v0.
|
|
132
|
+
| GitLab CI | `include: - remote: https://raw.githubusercontent.com/raccioly/testguard/v0.12.0/packaging/gitlab/testguard.gitlab-ci.yml` with `inputs:` — see [`packaging/gitlab/`](./packaging/gitlab/testguard.gitlab-ci.yml) |
|
|
133
133
|
|
|
134
134
|
Projects that set `min-release-age` in `.npmrc` cannot see a version published
|
|
135
135
|
less than that many days ago (`ENOVERSIONS`); install that one with
|
|
@@ -147,6 +147,7 @@ npx testguard-cli probe # try to falsify each claim; report
|
|
|
147
147
|
npx testguard-cli admit test/auth.test.ts --claim AUTH-ADMIN # does this test satisfy the two-gate rule?
|
|
148
148
|
npx testguard-cli baseline # freeze today's unproven findings; only new ones gate
|
|
149
149
|
npx testguard-cli gate --changed origin/main # fail when a changed source file carries no claim
|
|
150
|
+
npx testguard-cli sweep --changed origin/main # no claims yet? propose faults for the changed files and report what nothing noticed
|
|
150
151
|
npx testguard-cli brief --text # tell the agent where the suite is blind, before it writes
|
|
151
152
|
npx testguard-cli replay --since v1.0..HEAD # would this suite have caught the bugs that escaped?
|
|
152
153
|
npx testguard-cli mcp # serve the read-only loop over MCP, on stdio
|
|
@@ -400,6 +401,104 @@ on-demand helper, not a hook**: the session-start hook never touches the
|
|
|
400
401
|
network, and the helper exits 0 with a message when the CLI or the artifact
|
|
401
402
|
is missing. TestGuard itself still makes no network calls.
|
|
402
403
|
|
|
404
|
+
### Starting from zero claims
|
|
405
|
+
|
|
406
|
+
`gate` names the changed files that carry no claim, and stops there —
|
|
407
|
+
correctly, because stating a claim is a human act. But a project adopting this
|
|
408
|
+
tool reads that list, has nothing to compare it against, and closes the tab.
|
|
409
|
+
The bottleneck was never verification; it is **oracle supply**.
|
|
410
|
+
|
|
411
|
+
`sweep` is the lowest rung of that supply. No claim, no concern, nothing
|
|
412
|
+
written by anybody:
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
npx testguard-cli sweep --changed origin/main # propose, probe a bounded selection, report
|
|
416
|
+
npx testguard-cli sweep --changed HEAD --include-dirty --cap 20
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
It takes the files `gate` just called uncovered, proposes faults with the same
|
|
420
|
+
mechanical producers `scaffold` uses, probes a bounded selection, and reports
|
|
421
|
+
what a green suite did not notice. A fault that survives needs no claim to be
|
|
422
|
+
alarming: something was deliberately broken and not one test failed.
|
|
423
|
+
|
|
424
|
+
```
|
|
425
|
+
swept 13 of 14 unclaimed changed files against origin/main.
|
|
426
|
+
proposed 510 faults, probed 10 (cap 10), deferred 500.
|
|
427
|
+
The 500 deferred are not a verdict: raise --cap, or sweep a smaller change.
|
|
428
|
+
|
|
429
|
+
9 findings — a deliberate break that no test noticed:
|
|
430
|
+
|
|
431
|
+
SURVIVED src/app/actions/register.ts:118 [write path]
|
|
432
|
+
[line 118] Field dropped: `password_hash` is no longer written.
|
|
433
|
+
src/tests/actions/register.test.ts mocks @/lib/prisma; 1 exact, 8 partial,
|
|
434
|
+
31 argument-free call assertions in the file. A field dropped from the write
|
|
435
|
+
payload fails only against an assertion that names that field exactly.
|
|
436
|
+
Assert the whole object written by the call this fault changes, including
|
|
437
|
+
`password_hash` — or read the record back and assert on what was stored.
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Three rules make it safe to run on a repository that has never seen this tool:
|
|
441
|
+
|
|
442
|
+
- **It never writes `testguard.claims.json`**, and never will. A claim is a
|
|
443
|
+
sentence someone is willing to stand behind; a sentence nobody wrote is not
|
|
444
|
+
one. Its drafts land beside the evidence to keep or drop.
|
|
445
|
+
- **Its evidence never replaces `.testguard/evidence.json`.** A sweep probes
|
|
446
|
+
faults nobody stated, under TODO statements; folding that into the document
|
|
447
|
+
`status` and `baseline` read would corrupt the record of what the project
|
|
448
|
+
actually claims.
|
|
449
|
+
- **It does not fail on its own bad guess.** Only `survived` and `nocover`
|
|
450
|
+
exit 1. A proposal that would not compile, or an anchor that did not locate,
|
|
451
|
+
is reported and never gates — a tool that fails because its own guess was bad
|
|
452
|
+
is a tool people switch off.
|
|
453
|
+
|
|
454
|
+
The cap and the ordering are Google's: their mutation service surfaces at most
|
|
455
|
+
7 × |files| mutants per change and orders candidates on the measured
|
|
456
|
+
productivity of their operator in similar context, which took their productive
|
|
457
|
+
rate from 15% to 89%
|
|
458
|
+
([Petrović et al., *Practical Mutation Testing at Scale*, 2021](https://arxiv.org/abs/2102.11378)).
|
|
459
|
+
TestGuard's ordering prior is measured the same way — from probed faults on a
|
|
460
|
+
real AI-authored codebase — and shrunk toward a neutral value, so one
|
|
461
|
+
observation of a class surviving never dominates a sweep. The cap is spread
|
|
462
|
+
across files: a single file with many candidates cannot make the others look
|
|
463
|
+
clean.
|
|
464
|
+
|
|
465
|
+
A sweep is weaker evidence than a probe, and says so. Nobody stated that the
|
|
466
|
+
behaviour mattered. It is stronger than nothing, which is what a repository
|
|
467
|
+
with no claims has — and the survivors worth defending become the first claims.
|
|
468
|
+
|
|
469
|
+
#### Sweeping the save surface instead of the diff
|
|
470
|
+
|
|
471
|
+
A diff cannot answer *"when the user clicks save, does it actually save?"* — it
|
|
472
|
+
only knows what changed today. `--save-paths` points the same sweep at every
|
|
473
|
+
file that writes to storage:
|
|
474
|
+
|
|
475
|
+
```bash
|
|
476
|
+
npx testguard-cli sweep --save-paths --cap 20
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
```
|
|
480
|
+
119 writes to storage across 37 files, carrying 226 payload fields. Swept 36.
|
|
481
|
+
proposed 762 faults, probed 20 (cap 20), deferred 742.
|
|
482
|
+
|
|
483
|
+
SURVIVED src/app/actions/register.ts [write path]
|
|
484
|
+
[line 59] Field dropped: `password_hash` is no longer written.
|
|
485
|
+
src/tests/actions/register.test.ts mocks @/lib/prisma; 1 exact, 8 partial,
|
|
486
|
+
31 argument-free call assertions in the file. A field dropped from the write
|
|
487
|
+
payload fails only against an assertion that names that field exactly.
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
The first line is the point. A report that lists findings without saying how
|
|
491
|
+
much surface it looked at invites you to assume the rest is fine, so the
|
|
492
|
+
denominator comes first and the document carries `writeSites` and
|
|
493
|
+
`payloadFields` whether or not anything survived. Measured on a real
|
|
494
|
+
AI-authored application: **12 of 26 probed persistence faults survived a fully
|
|
495
|
+
green suite, and all 12 were a dropped payload field.**
|
|
496
|
+
|
|
497
|
+
This is a *sample* of the surface, and says so. It does not yet prove a write
|
|
498
|
+
reached storage — only that a test would notice if the payload changed.
|
|
499
|
+
Read-back oracles are the next rung
|
|
500
|
+
([#154](https://github.com/raccioly/testguard/issues/154)).
|
|
501
|
+
|
|
403
502
|
### Every change needs a claim
|
|
404
503
|
|
|
405
504
|
`probe` can only verify claims that exist. Every escaped defect in the field
|
|
@@ -481,12 +580,12 @@ detected base that does not resolve is a warning for `status` and `brief`
|
|
|
481
580
|
# GitHub Actions
|
|
482
581
|
- uses: actions/checkout@v4
|
|
483
582
|
with: { fetch-depth: 0 }
|
|
484
|
-
- uses: raccioly/testguard@v0.
|
|
583
|
+
- uses: raccioly/testguard@v0.12.0
|
|
485
584
|
with: { command: gate }
|
|
486
585
|
|
|
487
586
|
# GitLab CI — the component-shaped template: gate + probe, brief as an artifact and, opted in, as a merge-request note
|
|
488
587
|
include:
|
|
489
|
-
- remote: 'https://raw.githubusercontent.com/raccioly/testguard/v0.
|
|
588
|
+
- remote: 'https://raw.githubusercontent.com/raccioly/testguard/v0.12.0/packaging/gitlab/testguard.gitlab-ci.yml'
|
|
490
589
|
inputs: { dir: backend, post_note: true } # post_note needs TESTGUARD_GITLAB_TOKEN (api scope); one note, updated in place
|
|
491
590
|
```
|
|
492
591
|
|
|
@@ -514,6 +613,32 @@ talks to the network; only the job posts, and only when told to.
|
|
|
514
613
|
toward green. The one pass is `killed`; everything else gates.
|
|
515
614
|
- **One exact-pinned runtime dependency** (`ajv`), Node ≥ 20, MIT.
|
|
516
615
|
|
|
616
|
+
### Prior art, and what was taken from where
|
|
617
|
+
|
|
618
|
+
Breaking code to test the tests is mutation testing, and it dates to the 1970s.
|
|
619
|
+
Two industrial programmes solved the parts that make it usable, and this tool
|
|
620
|
+
takes from both.
|
|
621
|
+
|
|
622
|
+
**Google** made it affordable: mutate only *changed* lines, cap what is
|
|
623
|
+
surfaced at seven per file, and order candidates by the measured productivity
|
|
624
|
+
of the operator in similar context — developer feedback took their productive
|
|
625
|
+
rate from 15% to 89%
|
|
626
|
+
([Petrović et al., 2021](https://arxiv.org/abs/2102.11378)). `sweep`'s diff
|
|
627
|
+
scope, its cap and its ordering are theirs; the productivity prior here is
|
|
628
|
+
measured on real probed faults rather than assumed.
|
|
629
|
+
|
|
630
|
+
**Meta** made it specific: an engineer writes a *concern* in plain text, a
|
|
631
|
+
model drafts faults for it, and every step is gated by execution before a
|
|
632
|
+
human ever sees it — 73% of the resulting tests were accepted
|
|
633
|
+
([Foster et al., FSE 2025](https://arxiv.org/abs/2501.12862)). The rule that
|
|
634
|
+
nothing a model proposes reaches the claims file without passing `probe` and a
|
|
635
|
+
human keep is theirs.
|
|
636
|
+
|
|
637
|
+
What neither does is bind a fault to a **stated promise**, which is what this
|
|
638
|
+
tool is for. The full record — including what was deliberately *not* taken, the
|
|
639
|
+
measured reason no model decides a verdict, and the gaps in what we borrowed —
|
|
640
|
+
is in [`docs-canonical/PRIOR-ART.md`](docs-canonical/PRIOR-ART.md).
|
|
641
|
+
|
|
517
642
|
### What TestGuard is not
|
|
518
643
|
|
|
519
644
|
- **Not a test generator.** It judges a test the agent wrote (`admit`); the
|
|
@@ -824,7 +949,7 @@ verdicts.
|
|
|
824
949
|
## Status
|
|
825
950
|
|
|
826
951
|
**v0.5.** Eleven commands (`status`, `init`, `claims`, `probe`, `admit`,
|
|
827
|
-
`replay`, `baseline`, `brief`, `gate`, `scaffold`, `mcp`), vitest, jest,
|
|
952
|
+
`replay`, `baseline`, `brief`, `gate`, `scaffold`, `sweep`, `mcp`), vitest, jest,
|
|
828
953
|
Playwright and Python (pytest / stdlib unittest) runners, hand-authored faults
|
|
829
954
|
plus a mechanical scaffold for JavaScript and Python, an agent operating layer
|
|
830
955
|
(`status`, `init`) and a change gate (`gate`). The contract spine — eight JSON
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testguard-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
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",
|
package/spec/lib/validate.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { reproduces, decimals, MAX_PLACES } from './wilson.mjs';
|
|
|
7
7
|
|
|
8
8
|
const schemaDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'schemas');
|
|
9
9
|
|
|
10
|
-
export const KINDS = Object.freeze(['claims', 'evidence', 'baseline', 'ignore', 'calibration', 'brief', 'status', 'gate', 'replay']);
|
|
10
|
+
export const KINDS = Object.freeze(['claims', 'evidence', 'baseline', 'ignore', 'calibration', 'brief', 'status', 'gate', 'replay', 'sweep']);
|
|
11
11
|
/**
|
|
12
12
|
* How a document says it tried to falsify its claims. Absent means
|
|
13
13
|
* `fault-injection`, so every document written before the field existed is
|
|
@@ -305,6 +305,60 @@ const semantic = {
|
|
|
305
305
|
return errors;
|
|
306
306
|
},
|
|
307
307
|
|
|
308
|
+
sweep(doc) {
|
|
309
|
+
const errors = [];
|
|
310
|
+
const s = doc.selection;
|
|
311
|
+
// A cap that hides its remainder is a coverage claim nobody made, so the
|
|
312
|
+
// arithmetic that proves nothing was dropped is checked, not trusted.
|
|
313
|
+
if (s.proposed !== s.selected + s.deferred) errors.push({ path: '/selection/proposed', message: `proposed (${s.proposed}) must equal selected (${s.selected}) + deferred (${s.deferred})` });
|
|
314
|
+
if (s.selected > s.cap) errors.push({ path: '/selection/selected', message: `selected (${s.selected}) exceeds the cap (${s.cap})` });
|
|
315
|
+
if (doc.scope.swept > doc.scope.targets) errors.push({ path: '/scope/swept', message: `swept (${doc.scope.swept}) cannot exceed targets (${doc.scope.targets})` });
|
|
316
|
+
// The counters that only mean something in one mode must not appear in the
|
|
317
|
+
// other: `writeSites` on a diff sweep would be a denominator nobody
|
|
318
|
+
// measured, and its absence on a save-paths sweep hides the denominator
|
|
319
|
+
// entirely — which is the whole reason that mode exists.
|
|
320
|
+
const saves = doc.scope.mode === 'save-paths';
|
|
321
|
+
for (const k of ['writeSites', 'payloadFields']) {
|
|
322
|
+
if (saves && doc.scope[k] === undefined) errors.push({ path: `/scope/${k}`, message: `a save-paths sweep must report ${k}: the surface is the denominator its findings are read against` });
|
|
323
|
+
if (!saves && doc.scope[k] !== undefined) errors.push({ path: `/scope/${k}`, message: `${k} belongs to a save-paths sweep; a changed sweep did not measure the write surface` });
|
|
324
|
+
}
|
|
325
|
+
if (saves && doc.scope.writeSites !== undefined && doc.scope.targets > doc.scope.writeSites) {
|
|
326
|
+
errors.push({ path: '/scope/targets', message: `targets (${doc.scope.targets}) exceeds writeSites (${doc.scope.writeSites}); every target is a file with at least one write` });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const byClass = Object.values(s.byClass);
|
|
330
|
+
const proposedByClass = byClass.reduce((a, c) => a + c.proposed, 0);
|
|
331
|
+
const selectedByClass = byClass.reduce((a, c) => a + c.selected, 0);
|
|
332
|
+
if (proposedByClass !== s.proposed) errors.push({ path: '/selection/byClass', message: `byClass proposed sums to ${proposedByClass}; selection.proposed is ${s.proposed}` });
|
|
333
|
+
if (selectedByClass !== s.selected) errors.push({ path: '/selection/byClass', message: `byClass selected sums to ${selectedByClass}; selection.selected is ${s.selected}` });
|
|
334
|
+
for (const [k, c] of Object.entries(s.byClass)) {
|
|
335
|
+
if (c.selected > c.proposed) errors.push({ path: `/selection/byClass/${k}`, message: `selected (${c.selected}) exceeds proposed (${c.proposed})` });
|
|
336
|
+
if (c.writePath > c.proposed) errors.push({ path: `/selection/byClass/${k}`, message: `writePath (${c.writePath}) exceeds proposed (${c.proposed})` });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const probed = Object.values(doc.counts).reduce((a, n) => a + n, 0);
|
|
340
|
+
if (probed > s.selected) errors.push({ path: '/counts', message: `${probed} verdicts recorded for ${s.selected} selected faults` });
|
|
341
|
+
// Findings are every record that was not killed. Stating that here keeps a
|
|
342
|
+
// document from quietly reporting fewer findings than it reached verdicts.
|
|
343
|
+
const notKilled = probed - (doc.counts.killed ?? 0);
|
|
344
|
+
if (doc.findings.length !== notKilled) errors.push({ path: '/findings', message: `${doc.findings.length} findings for ${notKilled} records that were not killed; findings are every non-killed record` });
|
|
345
|
+
|
|
346
|
+
const gating = doc.findings.filter((f) => f.verdict === 'survived' || f.verdict === 'nocover').length;
|
|
347
|
+
if (gating > 0 && doc.exitCode !== 1) errors.push({ path: '/exitCode', message: 'a survived or nocover finding must exit 1; a sweep never passes over a deliberate break nothing noticed' });
|
|
348
|
+
if (gating === 0 && doc.exitCode !== 0) errors.push({ path: '/exitCode', message: 'exit 1 requires a survived or nocover finding; a sweep does not fail on its own unanchorable proposal' });
|
|
349
|
+
|
|
350
|
+
doc.findings.forEach((f, i) => {
|
|
351
|
+
if (f.verdict === 'killed') errors.push({ path: `/findings/${i}/verdict`, message: 'a killed fault is not a finding' });
|
|
352
|
+
// The signal explains why a defender could not notice a changed payload.
|
|
353
|
+
// On any other verdict, or off the write path, it explains nothing and
|
|
354
|
+
// would be exactly the non-actionable noise that gets a check ignored.
|
|
355
|
+
if (f.signals && !(f.verdict === 'survived' && f.writePath)) {
|
|
356
|
+
errors.push({ path: `/findings/${i}/signals`, message: 'signals belong to a survived finding on the write path' });
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
return errors;
|
|
360
|
+
},
|
|
361
|
+
|
|
308
362
|
brief(doc) {
|
|
309
363
|
const errors = [];
|
|
310
364
|
if (!doc.text.startsWith(doc.heading)) errors.push({ path: '/text', message: 'text must begin with heading' });
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "urn:claimspec:v1:sweep",
|
|
3
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
|
+
"title": "claimspec v1 — sweep",
|
|
5
|
+
"description": "What a sweep proposed and what survived it. A sweep probes faults NOBODY STATED: it takes the changed source files that carry no claim, proposes faults with the mechanical producers, and reports the ones a green suite did not notice. Its findings are therefore weaker evidence than a probe's — no human said the behaviour mattered — and stronger than nothing, which is what a repository with no claims has. The drafts it carries are proposals for a human to keep or drop; they are not claims, and nothing here belongs in a claims file until someone states the sentence it defends.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": [
|
|
8
|
+
"schemaVersion",
|
|
9
|
+
"tool",
|
|
10
|
+
"generatedAt",
|
|
11
|
+
"ref",
|
|
12
|
+
"base",
|
|
13
|
+
"head",
|
|
14
|
+
"includeDirty",
|
|
15
|
+
"scope",
|
|
16
|
+
"selection",
|
|
17
|
+
"counts",
|
|
18
|
+
"findings",
|
|
19
|
+
"exitCode"
|
|
20
|
+
],
|
|
21
|
+
"additionalProperties": false,
|
|
22
|
+
"properties": {
|
|
23
|
+
"$schema": {
|
|
24
|
+
"type": "string"
|
|
25
|
+
},
|
|
26
|
+
"schemaVersion": {
|
|
27
|
+
"const": 1
|
|
28
|
+
},
|
|
29
|
+
"tool": {
|
|
30
|
+
"$ref": "urn:claimspec:v1:common#/$defs/tool"
|
|
31
|
+
},
|
|
32
|
+
"generatedAt": {
|
|
33
|
+
"$ref": "urn:claimspec:v1:common#/$defs/isoDateTime"
|
|
34
|
+
},
|
|
35
|
+
"ref": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"description": "The reference the change was measured against."
|
|
38
|
+
},
|
|
39
|
+
"base": {
|
|
40
|
+
"$ref": "urn:claimspec:v1:common#/$defs/gitSha"
|
|
41
|
+
},
|
|
42
|
+
"head": {
|
|
43
|
+
"$ref": "urn:claimspec:v1:common#/$defs/gitSha"
|
|
44
|
+
},
|
|
45
|
+
"includeDirty": {
|
|
46
|
+
"type": "boolean"
|
|
47
|
+
},
|
|
48
|
+
"scope": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"required": [
|
|
51
|
+
"mode",
|
|
52
|
+
"changed",
|
|
53
|
+
"targets",
|
|
54
|
+
"swept"
|
|
55
|
+
],
|
|
56
|
+
"additionalProperties": false,
|
|
57
|
+
"properties": {
|
|
58
|
+
"mode": {
|
|
59
|
+
"enum": [
|
|
60
|
+
"changed",
|
|
61
|
+
"save-paths"
|
|
62
|
+
],
|
|
63
|
+
"description": "What the sweep was pointed at. `changed` proposes for the changed source files that carry no claim. `save-paths` proposes for every file that writes to storage, because a diff cannot answer how many save paths exist — and a clean report over an unstated denominator is a sample of unknown size, not a guarantee."
|
|
64
|
+
},
|
|
65
|
+
"changed": {
|
|
66
|
+
"type": "integer",
|
|
67
|
+
"minimum": 0,
|
|
68
|
+
"description": "Files in the change, before any filter."
|
|
69
|
+
},
|
|
70
|
+
"targets": {
|
|
71
|
+
"type": "integer",
|
|
72
|
+
"minimum": 0,
|
|
73
|
+
"description": "Changed SOURCE files carrying no claim — what the gate calls uncovered. A sweep proposes against these and nothing else."
|
|
74
|
+
},
|
|
75
|
+
"swept": {
|
|
76
|
+
"type": "integer",
|
|
77
|
+
"minimum": 0,
|
|
78
|
+
"description": "Targets that yielded at least one proposal. Never more than `targets`: a file the producers cannot touch is skipped, not swept."
|
|
79
|
+
},
|
|
80
|
+
"skipped": {
|
|
81
|
+
"type": "array",
|
|
82
|
+
"description": "Targets that produced nothing, and why. Recorded rather than dropped: a file that silently yielded no proposal reads as a file with no findings.",
|
|
83
|
+
"items": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"required": [
|
|
86
|
+
"file",
|
|
87
|
+
"reason"
|
|
88
|
+
],
|
|
89
|
+
"additionalProperties": false,
|
|
90
|
+
"properties": {
|
|
91
|
+
"file": {
|
|
92
|
+
"$ref": "urn:claimspec:v1:common#/$defs/repoPath"
|
|
93
|
+
},
|
|
94
|
+
"reason": {
|
|
95
|
+
"type": "string",
|
|
96
|
+
"minLength": 1
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"writeSites": {
|
|
102
|
+
"type": "integer",
|
|
103
|
+
"minimum": 0,
|
|
104
|
+
"description": "save-paths only: calls that write to storage across the whole surface, not only the ones probed."
|
|
105
|
+
},
|
|
106
|
+
"payloadFields": {
|
|
107
|
+
"type": "integer",
|
|
108
|
+
"minimum": 0,
|
|
109
|
+
"description": "save-paths only: payload keys sitting on their own line, which is what a field-dropped fault can anchor to."
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
"selection": {
|
|
114
|
+
"type": "object",
|
|
115
|
+
"description": "What was proposed and what was probed. A sweep is capped — past a few findings per file nobody reads them, and the runner is spent either way — so the count NOT probed is reported beside the count that was. A cap that hides its own remainder is a coverage claim nobody made.",
|
|
116
|
+
"required": [
|
|
117
|
+
"proposed",
|
|
118
|
+
"selected",
|
|
119
|
+
"deferred",
|
|
120
|
+
"cap",
|
|
121
|
+
"byClass"
|
|
122
|
+
],
|
|
123
|
+
"additionalProperties": false,
|
|
124
|
+
"properties": {
|
|
125
|
+
"proposed": {
|
|
126
|
+
"type": "integer",
|
|
127
|
+
"minimum": 0
|
|
128
|
+
},
|
|
129
|
+
"selected": {
|
|
130
|
+
"type": "integer",
|
|
131
|
+
"minimum": 0
|
|
132
|
+
},
|
|
133
|
+
"deferred": {
|
|
134
|
+
"type": "integer",
|
|
135
|
+
"minimum": 0,
|
|
136
|
+
"description": "Proposed and not probed. Not a verdict of any kind."
|
|
137
|
+
},
|
|
138
|
+
"cap": {
|
|
139
|
+
"type": "integer",
|
|
140
|
+
"minimum": 1
|
|
141
|
+
},
|
|
142
|
+
"byClass": {
|
|
143
|
+
"type": "object",
|
|
144
|
+
"description": "Per fault class: how many were proposed, how many probed, and how many sat on a write payload.",
|
|
145
|
+
"additionalProperties": {
|
|
146
|
+
"type": "object",
|
|
147
|
+
"required": [
|
|
148
|
+
"proposed",
|
|
149
|
+
"selected",
|
|
150
|
+
"writePath"
|
|
151
|
+
],
|
|
152
|
+
"additionalProperties": false,
|
|
153
|
+
"properties": {
|
|
154
|
+
"proposed": {
|
|
155
|
+
"type": "integer",
|
|
156
|
+
"minimum": 0
|
|
157
|
+
},
|
|
158
|
+
"selected": {
|
|
159
|
+
"type": "integer",
|
|
160
|
+
"minimum": 0
|
|
161
|
+
},
|
|
162
|
+
"writePath": {
|
|
163
|
+
"type": "integer",
|
|
164
|
+
"minimum": 0
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
"counts": {
|
|
172
|
+
"type": "object",
|
|
173
|
+
"description": "Every verdict the probe reached, including `killed`. A sweep whose faults were all caught is a real result and must be readable as one.",
|
|
174
|
+
"additionalProperties": {
|
|
175
|
+
"type": "integer",
|
|
176
|
+
"minimum": 0
|
|
177
|
+
},
|
|
178
|
+
"propertyNames": {
|
|
179
|
+
"$ref": "urn:claimspec:v1:common#/$defs/verdict"
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
"findings": {
|
|
183
|
+
"type": "array",
|
|
184
|
+
"description": "Every record that was not killed, most actionable first. `survived` and `nocover` are statements about the test suite; the rest are statements about the PROPOSAL and do not gate, because a sweep's faults are machine-made and a tool that fails on its own bad guess gets switched off.",
|
|
185
|
+
"items": {
|
|
186
|
+
"type": "object",
|
|
187
|
+
"required": [
|
|
188
|
+
"file",
|
|
189
|
+
"claimId",
|
|
190
|
+
"faultId",
|
|
191
|
+
"faultClass",
|
|
192
|
+
"verdict",
|
|
193
|
+
"description",
|
|
194
|
+
"writePath",
|
|
195
|
+
"hint"
|
|
196
|
+
],
|
|
197
|
+
"additionalProperties": false,
|
|
198
|
+
"properties": {
|
|
199
|
+
"file": {
|
|
200
|
+
"$ref": "urn:claimspec:v1:common#/$defs/repoPath"
|
|
201
|
+
},
|
|
202
|
+
"line": {
|
|
203
|
+
"type": "integer",
|
|
204
|
+
"minimum": 1
|
|
205
|
+
},
|
|
206
|
+
"claimId": {
|
|
207
|
+
"$ref": "urn:claimspec:v1:common#/$defs/identifier"
|
|
208
|
+
},
|
|
209
|
+
"faultId": {
|
|
210
|
+
"$ref": "urn:claimspec:v1:common#/$defs/identifier"
|
|
211
|
+
},
|
|
212
|
+
"faultClass": {
|
|
213
|
+
"$ref": "urn:claimspec:v1:common#/$defs/faultClass"
|
|
214
|
+
},
|
|
215
|
+
"verdict": {
|
|
216
|
+
"$ref": "urn:claimspec:v1:common#/$defs/verdict"
|
|
217
|
+
},
|
|
218
|
+
"reason": {
|
|
219
|
+
"type": "string",
|
|
220
|
+
"minLength": 1
|
|
221
|
+
},
|
|
222
|
+
"description": {
|
|
223
|
+
"type": "string",
|
|
224
|
+
"minLength": 1
|
|
225
|
+
},
|
|
226
|
+
"writePath": {
|
|
227
|
+
"type": "boolean",
|
|
228
|
+
"description": "The fault sits on a database write or the payload of one. Every surviving fault in the measurement that prompted this field was such a line."
|
|
229
|
+
},
|
|
230
|
+
"hint": {
|
|
231
|
+
"type": "string",
|
|
232
|
+
"minLength": 1,
|
|
233
|
+
"description": "One line naming the mechanism, never only the verdict."
|
|
234
|
+
},
|
|
235
|
+
"signals": {
|
|
236
|
+
"type": "array",
|
|
237
|
+
"minItems": 1,
|
|
238
|
+
"description": "Static, annotation-grade signals about the defenders of a SURVIVOR on the write path. They never change a verdict. `persistence-payload-unasserted`: the defender mocks the persistence layer and the file's call assertions never name an exact payload, so a field dropped from a write cannot fail them. Descriptive, never predictive: no threshold on the assertion mix separates the files whose payload faults survive from the files whose do not.",
|
|
239
|
+
"items": {
|
|
240
|
+
"type": "object",
|
|
241
|
+
"required": [
|
|
242
|
+
"file",
|
|
243
|
+
"signal",
|
|
244
|
+
"reason"
|
|
245
|
+
],
|
|
246
|
+
"additionalProperties": false,
|
|
247
|
+
"properties": {
|
|
248
|
+
"file": {
|
|
249
|
+
"$ref": "urn:claimspec:v1:common#/$defs/repoPath"
|
|
250
|
+
},
|
|
251
|
+
"signal": {
|
|
252
|
+
"enum": [
|
|
253
|
+
"persistence-payload-unasserted"
|
|
254
|
+
]
|
|
255
|
+
},
|
|
256
|
+
"reason": {
|
|
257
|
+
"type": "string",
|
|
258
|
+
"minLength": 1
|
|
259
|
+
},
|
|
260
|
+
"specifiers": {
|
|
261
|
+
"type": "array",
|
|
262
|
+
"items": {
|
|
263
|
+
"type": "string",
|
|
264
|
+
"minLength": 1
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
"counts": {
|
|
268
|
+
"type": "object",
|
|
269
|
+
"required": [
|
|
270
|
+
"exact",
|
|
271
|
+
"partial",
|
|
272
|
+
"argless"
|
|
273
|
+
],
|
|
274
|
+
"additionalProperties": false,
|
|
275
|
+
"properties": {
|
|
276
|
+
"exact": {
|
|
277
|
+
"type": "integer",
|
|
278
|
+
"minimum": 0
|
|
279
|
+
},
|
|
280
|
+
"partial": {
|
|
281
|
+
"type": "integer",
|
|
282
|
+
"minimum": 0
|
|
283
|
+
},
|
|
284
|
+
"argless": {
|
|
285
|
+
"type": "integer",
|
|
286
|
+
"minimum": 0
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
"exitCode": {
|
|
297
|
+
"enum": [
|
|
298
|
+
0,
|
|
299
|
+
1
|
|
300
|
+
]
|
|
301
|
+
},
|
|
302
|
+
"drafts": {
|
|
303
|
+
"type": "array",
|
|
304
|
+
"description": "The draft claims documents the proposals came from, carried so a human can keep one without re-running the sweep. Drafts, never claims: every statement is a TODO nobody has written yet.",
|
|
305
|
+
"items": {
|
|
306
|
+
"type": "object"
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|