testguard-cli 0.6.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.
- package/CHANGELOG.md +373 -0
- package/README.md +277 -27
- package/package.json +2 -2
- package/spec/GATE-SEMANTICS.md +112 -5
- package/spec/README.md +28 -10
- package/spec/lib/validate.mjs +84 -8
- package/spec/schemas/baseline.schema.json +7 -7
- package/spec/schemas/brief.schema.json +12 -12
- package/spec/schemas/calibration.schema.json +5 -5
- package/spec/schemas/claims.schema.json +122 -25
- package/spec/schemas/common.schema.json +127 -29
- package/spec/schemas/evidence.schema.json +169 -73
- package/spec/schemas/gate.schema.json +12 -12
- package/spec/schemas/ignore.schema.json +4 -4
- package/spec/schemas/replay.schema.json +10 -10
- package/spec/schemas/status.schema.json +25 -25
- package/src/cli.mjs +20 -5
- package/src/commands/claims.mjs +22 -2
- package/src/commands/probe.mjs +24 -4
- package/src/gate/changed.mjs +9 -2
- package/src/probe/attribution.mjs +123 -0
- package/src/probe/classify.mjs +18 -2
- package/src/probe/cost.mjs +145 -0
- package/src/probe/discover.mjs +14 -6
- package/src/probe/inject.mjs +41 -9
- package/src/probe/mocks.mjs +2 -0
- package/src/probe/probe.mjs +235 -52
- package/src/probe/progress.mjs +87 -0
- package/src/probe/pyimports.mjs +287 -0
- package/src/probe/rank.mjs +2 -0
- package/src/probe/runners/index.mjs +38 -11
- package/src/probe/runners/jest.mjs +13 -1
- package/src/probe/runners/playwright.mjs +9 -0
- package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
- package/src/probe/runners/python/_testguard_report.py +141 -0
- package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
- package/src/probe/runners/python.mjs +316 -0
- package/src/probe/runners/shared.mjs +10 -1
- package/src/probe/runners/vitest.mjs +13 -1
- package/src/replay/replay.mjs +3 -2
- package/src/scaffold/producers.python.mjs +209 -0
- package/src/scaffold/scaffold.mjs +86 -32
- package/src/util/glob.mjs +7 -1
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# TestGuard
|
|
2
2
|
|
|
3
|
+
<!-- docguard:quality negation-load off — this README explains a tool defined by what must not happen; the negations are the product. -->
|
|
4
|
+
<!-- docguard:quality passive-voice off — verdicts and artifacts are the subjects throughout ("a fault is applied", "evidence is written"); naming an actor would misdescribe a tool nobody operates interactively. -->
|
|
5
|
+
|
|
3
6
|
[](https://github.com/raccioly/testguard/actions/workflows/ci.yml)
|
|
4
7
|
[](https://www.npmjs.com/package/testguard-cli)
|
|
5
8
|
[](https://pypi.org/project/testguard-cli/)
|
|
@@ -7,9 +10,8 @@
|
|
|
7
10
|
[](./LICENSE)
|
|
8
11
|
[](./package.json)
|
|
9
12
|
|
|
10
|
-
>
|
|
11
|
-
>
|
|
12
|
-
> fault the tests fail to detect.
|
|
13
|
+
> Breaks your code on purpose and reports every promise your tests did not
|
|
14
|
+
> notice breaking.
|
|
13
15
|
|
|
14
16
|
**Not a test generator. A claim verifier.** Test generation is what happens
|
|
15
17
|
after a claim turns out to be unfalsifiable.
|
|
@@ -22,6 +24,65 @@ surface ↔ code). All three run one loop:
|
|
|
22
24
|
> declare what must be true → try mechanically to falsify it → freeze a
|
|
23
25
|
> baseline → gate only the delta → brief the agent before it writes code.
|
|
24
26
|
|
|
27
|
+
## In one minute
|
|
28
|
+
|
|
29
|
+
**Coverage tells you a line ran. It never tells you anyone checked the
|
|
30
|
+
result.** So a codebase can be fully covered and completely undefended, and
|
|
31
|
+
nothing in CI will say a word.
|
|
32
|
+
|
|
33
|
+
Here is a real test, from this repository's own fixture:
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
expect(store.writeAudit).toHaveBeenCalledWith(
|
|
37
|
+
expect.objectContaining({ action: 'MASK', scope: 'g1', ruleCount: 1 }),
|
|
38
|
+
); // `content` is never named — so nothing checks it
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`objectContaining` ignores keys it does not list. Swap the redacted text for
|
|
42
|
+
the **raw secret** and this test still passes. Coverage of that line: 100%.
|
|
43
|
+
The audit log now leaks the very thing it exists to protect.
|
|
44
|
+
|
|
45
|
+
**The method: break the code deliberately, then watch what the tests do.**
|
|
46
|
+
|
|
47
|
+
- **killed** — you broke it, a test failed. Good. That behaviour is genuinely
|
|
48
|
+
defended.
|
|
49
|
+
- **SURVIVED** — you broke it, everything stayed green. A blind spot.
|
|
50
|
+
|
|
51
|
+
The word trips everyone once: it is the *fault* that survived, not the test.
|
|
52
|
+
**SURVIVED is the bad news.** A healthy report is full of `killed`.
|
|
53
|
+
|
|
54
|
+
But *"did the tests fail?"* is a sloppy question — a red suite is not proof of
|
|
55
|
+
detection. So there are seven verdicts, and each means something different:
|
|
56
|
+
|
|
57
|
+
| Verdict | What it means |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `killed` | A test body ran and rejected the behaviour. The only good outcome. |
|
|
60
|
+
| `SURVIVED` | Everything passed. A real blind spot in the tests. |
|
|
61
|
+
| `NOCOVER` | No test even looks at this code. Not "weak tests" — *no* tests. |
|
|
62
|
+
| `UNVERIFIABLE` | The fault could not be applied: its anchor moved, or matches twice. Nothing was learned. |
|
|
63
|
+
| `FAULT-INVALID` | The break itself was broken — it did not compile. Our fault, not yours. |
|
|
64
|
+
| `TIMEOUT` | The suite hung. A hang is not a detection. |
|
|
65
|
+
| `FLAKY-DEFENDER` | The tests are not reliable enough on untouched code to be asked the question. |
|
|
66
|
+
|
|
67
|
+
Every ambiguity rounds toward *unproven*: three runs rather than one, a green
|
|
68
|
+
baseline required before any fault is injected, and a timeout, a load failure
|
|
69
|
+
or a mixed result is never a kill. The reason is the constraint the whole
|
|
70
|
+
design follows from — **a tool that reports everything as caught is worse than
|
|
71
|
+
no tool at all, because nobody questions good news.**
|
|
72
|
+
|
|
73
|
+
**What is not new:** breaking code to test your tests is *mutation testing*,
|
|
74
|
+
and it dates to the 1970s. Stryker, PIT, mutmut and Cosmic Ray all do it.
|
|
75
|
+
|
|
76
|
+
**What is different is the question.** Classic mutation testing mutates
|
|
77
|
+
everything mechanically and hands you *"mutation score: 73%"* — a number that
|
|
78
|
+
is not actionable, not auditable, and cannot tell you which promise is at
|
|
79
|
+
risk. TestGuard binds every fault to a **stated claim**, so the output is not
|
|
80
|
+
a score but a finding: *"Your project says a missing scope fails closed.
|
|
81
|
+
Nothing checks that."* That is the difference between a metric and an audit.
|
|
82
|
+
|
|
83
|
+
📄 **[Read the three-page technical brief (PDF)](docs/testguard-explained.pdf)**
|
|
84
|
+
— the idea on page one, mechanics and prior art after it.
|
|
85
|
+
|
|
25
86
|
## Why
|
|
26
87
|
|
|
27
88
|
Coverage cannot tell a test that pins *correct* behaviour from one that pins
|
|
@@ -65,14 +126,34 @@ raises coverage. TestGuard admits it because it fails when the claim is false.
|
|
|
65
126
|
| npm | `npm i -D testguard-cli` then `npx testguard probe` |
|
|
66
127
|
| pip | `pip install testguard-cli` then `testguard probe` (needs Node ≥ 20) |
|
|
67
128
|
| Homebrew | `brew tap raccioly/tap && brew install testguard` |
|
|
68
|
-
| GitHub Action | `uses: raccioly/testguard@v0.
|
|
129
|
+
| GitHub Action | `uses: raccioly/testguard@v0.7.0` — see [`action.yml`](./action.yml) |
|
|
69
130
|
| pre-commit | `repo: https://github.com/raccioly/testguard`, hooks `testguard-claims`, `testguard-probe` |
|
|
70
|
-
| GitLab CI | `include: - remote: https://raw.githubusercontent.com/raccioly/testguard/v0.
|
|
131
|
+
| GitLab CI | `include: - remote: https://raw.githubusercontent.com/raccioly/testguard/v0.7.0/packaging/gitlab/testguard.gitlab-ci.yml` with `inputs:` — see [`packaging/gitlab/`](./packaging/gitlab/testguard.gitlab-ci.yml) |
|
|
71
132
|
|
|
72
133
|
Projects that set `min-release-age` in `.npmrc` cannot see a version published
|
|
73
134
|
less than that many days ago (`ENOVERSIONS`); install that one with
|
|
74
135
|
`npm i -D testguard-cli --min-release-age=0`.
|
|
75
136
|
|
|
137
|
+
## Usage
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
npx testguard-cli init # install the agent layer at the git root
|
|
141
|
+
npx testguard-cli status --json # where the project is, and the ONE next action
|
|
142
|
+
npx testguard-cli claims # what does this project claim, and is each claim probeable?
|
|
143
|
+
npx testguard-cli scaffold src/auth.ts # propose faults for a file, as a draft to keep or drop
|
|
144
|
+
npx testguard-cli probe # try to falsify each claim; report what the tests missed
|
|
145
|
+
npx testguard-cli admit test/auth.test.ts --claim AUTH-ADMIN # does this test satisfy the two-gate rule?
|
|
146
|
+
npx testguard-cli baseline # freeze today's unproven findings; only new ones gate
|
|
147
|
+
npx testguard-cli gate --changed origin/main # fail when a changed source file carries no claim
|
|
148
|
+
npx testguard-cli brief --text # tell the agent where the suite is blind, before it writes
|
|
149
|
+
npx testguard-cli replay --since v1.0..HEAD # would this suite have caught the bugs that escaped?
|
|
150
|
+
npx testguard-cli mcp # serve the read-only loop over MCP, on stdio
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Exit codes are the contract: `0` nothing new to prove, `1` unproven claims or
|
|
154
|
+
unclaimed changes, `2` a precondition failed and nothing was probed, `3` usage.
|
|
155
|
+
Every command accepts `--json`.
|
|
156
|
+
|
|
76
157
|
## How it works
|
|
77
158
|
|
|
78
159
|
```bash
|
|
@@ -146,14 +227,39 @@ npx testguard-cli admit test/x.test.ts --claim X # is this test green on HEAD
|
|
|
146
227
|
there is, and the exact signature of one escaped bug in the field
|
|
147
228
|
reports. Silence it, visibly, with `// unasserted: <why>` above the mock.
|
|
148
229
|
|
|
149
|
-
Runners: **vitest** and **
|
|
150
|
-
|
|
151
|
-
resolved from the **project's own package** first — its pinned
|
|
152
|
-
its own bin script — and only then from an executable on PATH,
|
|
153
|
-
evidence records as `runner.source: "path"`; `npx` is never
|
|
154
|
-
because its cache answers for packages a project does not have.
|
|
155
|
-
else goes through `--runner-cmd`. Each runner is proven against
|
|
156
|
-
copy of the known-answer fixture.
|
|
230
|
+
Runners: **vitest**, **jest**, **Playwright** and **Python** (`--runner
|
|
231
|
+
auto` picks the first of vitest, jest, python that resolves). A JavaScript
|
|
232
|
+
runner is resolved from the **project's own package** first — its pinned
|
|
233
|
+
version, its own bin script — and only then from an executable on PATH,
|
|
234
|
+
which the evidence records as `runner.source: "path"`; `npx` is never
|
|
235
|
+
asked, because its cache answers for packages a project does not have.
|
|
236
|
+
Anything else goes through `--runner-cmd`. Each runner is proven against
|
|
237
|
+
its own copy of the known-answer fixture.
|
|
238
|
+
|
|
239
|
+
**Python** runs under `pytest` when the project's interpreter can import
|
|
240
|
+
it and stdlib `unittest` when it cannot, and the evidence records the
|
|
241
|
+
engine that actually ran — `--runner pytest` or `--runner unittest` pins
|
|
242
|
+
the choice and fails rather than falling back. Nothing is installed into
|
|
243
|
+
the project: the reporters ship inside TestGuard and reach the interpreter
|
|
244
|
+
through `PYTHONPATH`, so a codebase whose test dependencies are the
|
|
245
|
+
standard library stays that way. The interpreter is the project's
|
|
246
|
+
`.venv`/`venv`, the active `VIRTUAL_ENV`, or `--python <path>`. A `.py`
|
|
247
|
+
defender runs under Python whatever the project runner is, so one claim
|
|
248
|
+
can be defended by a vitest test and a pytest test at once.
|
|
249
|
+
|
|
250
|
+
Two things Python forces that JavaScript does not. **`patch("pkg.mod.fn")`
|
|
251
|
+
is not `vi.mock`**: it replaces one attribute, so the file is still a
|
|
252
|
+
defender of every other fault in that module — it keeps its place and
|
|
253
|
+
carries a `target-attribute-patched` signal naming the attributes. And
|
|
254
|
+
**the fault must be the code that ran**: a strict editable install puts an
|
|
255
|
+
import hook ahead of `sys.path`, so Python can load the original file while
|
|
256
|
+
TestGuard faults the copy — a green baseline, every claim `SURVIVED`, and a
|
|
257
|
+
report that reads as a devastating finding while being entirely false. With
|
|
258
|
+
the fault applied, TestGuard asks which file was actually imported and
|
|
259
|
+
refuses the run when it is outside the tree being probed. When no defender
|
|
260
|
+
imported the subject at all, the run continues and the record says so
|
|
261
|
+
(`detail.targetNotImported`), because that is a fact about the defenders'
|
|
262
|
+
reach and not about their assertions.
|
|
157
263
|
|
|
158
264
|
The two-gate rule, as one verb: `testguard admit <test-file> --claim <ID>`
|
|
159
265
|
runs the claim's faults against your uncommitted test and answers
|
|
@@ -318,12 +424,12 @@ detected base that does not resolve is a warning for `status` and `brief`
|
|
|
318
424
|
# GitHub Actions
|
|
319
425
|
- uses: actions/checkout@v4
|
|
320
426
|
with: { fetch-depth: 0 }
|
|
321
|
-
- uses: raccioly/testguard@v0.
|
|
427
|
+
- uses: raccioly/testguard@v0.7.0
|
|
322
428
|
with: { command: gate }
|
|
323
429
|
|
|
324
430
|
# GitLab CI — the component-shaped template: gate + probe, brief as an artifact and, opted in, as a merge-request note
|
|
325
431
|
include:
|
|
326
|
-
- remote: 'https://raw.githubusercontent.com/raccioly/testguard/v0.
|
|
432
|
+
- remote: 'https://raw.githubusercontent.com/raccioly/testguard/v0.7.0/packaging/gitlab/testguard.gitlab-ci.yml'
|
|
327
433
|
inputs: { dir: backend, post_note: true } # post_note needs TESTGUARD_GITLAB_TOKEN (api scope); one note, updated in place
|
|
328
434
|
```
|
|
329
435
|
|
|
@@ -384,6 +490,45 @@ is the same one as everywhere else: a `claim` entry in
|
|
|
384
490
|
Removal is allowed — claims can be wrong, superseded or split — and it is
|
|
385
491
|
never silent.
|
|
386
492
|
|
|
493
|
+
### Probing a Python project
|
|
494
|
+
|
|
495
|
+
Nothing is installed into your project. TestGuard carries its own reporters and
|
|
496
|
+
puts them on the interpreter's `PYTHONPATH`, so a codebase whose test
|
|
497
|
+
dependencies are the standard library keeps none.
|
|
498
|
+
|
|
499
|
+
```bash
|
|
500
|
+
npx testguard-cli probe . # auto: pytest if importable, else stdlib unittest
|
|
501
|
+
npx testguard-cli probe . --runner unittest # pin the stdlib engine
|
|
502
|
+
npx testguard-cli probe . --python .venv/bin/python
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
The evidence records the engine that actually ran, and `--runner pytest` fails
|
|
506
|
+
rather than quietly using `unittest` — which engine ran changes what the
|
|
507
|
+
evidence means.
|
|
508
|
+
|
|
509
|
+
Three things behave differently from JavaScript, and each one is a place a
|
|
510
|
+
naive port would have produced a confident wrong answer:
|
|
511
|
+
|
|
512
|
+
- **Defenders are matched by module name**, the way Python itself matches, so
|
|
513
|
+
a test that reaches your package through `sys.path`, a `conftest.py` or an
|
|
514
|
+
installed distribution is found either way.
|
|
515
|
+
- **`patch("pkg.mod.fn")` is not `vi.mock`.** It replaces one attribute, so the
|
|
516
|
+
file still detects a fault anywhere else in that module: it stays a defender
|
|
517
|
+
and carries a `target-attribute-patched` signal naming the attributes. Only a
|
|
518
|
+
patch of the module *itself* removes a defender.
|
|
519
|
+
- **The fault has to be the code that ran.** A strict editable install puts an
|
|
520
|
+
import hook ahead of `sys.path`, so Python can load your original file while
|
|
521
|
+
TestGuard faults its copy — green baseline, every claim `SURVIVED`, and a
|
|
522
|
+
report that reads as a catastrophe while being entirely false. With the fault
|
|
523
|
+
applied TestGuard asks which file was really imported and **refuses the run**
|
|
524
|
+
if it came from outside the tree being probed. If no defender imported the
|
|
525
|
+
subject at all, the run continues and the record says so
|
|
526
|
+
(`detail.targetNotImported`): that is a fact about the tests' reach, not
|
|
527
|
+
about their assertions, and the two must not read alike.
|
|
528
|
+
|
|
529
|
+
`testguard scaffold path/to/module.py` proposes Python faults the same way it
|
|
530
|
+
does for JavaScript.
|
|
531
|
+
|
|
387
532
|
### Run it from any harness
|
|
388
533
|
|
|
389
534
|
The operating loop above lives in a Claude Code skill and a session-start
|
|
@@ -452,6 +597,92 @@ things make that safe:
|
|
|
452
597
|
previous verdict, and makes reviewing that edit the next action. Editing a
|
|
453
598
|
claim is allowed — claims can be wrong — but it is never invisible.
|
|
454
599
|
|
|
600
|
+
### Watching a probe that is still running
|
|
601
|
+
|
|
602
|
+
A probe runs the defenders N times clean and N times per fault, which takes
|
|
603
|
+
minutes. It used to print its stage line only when `stderr` was a terminal —
|
|
604
|
+
so CI, a redirected log and an agent harness saw **nothing at all** for the
|
|
605
|
+
whole run, which is indistinguishable from a hang. A gate you cannot tell
|
|
606
|
+
from a hang is a gate people start killing.
|
|
607
|
+
|
|
608
|
+
The rewriting is a rendering choice, not a reason to withhold the
|
|
609
|
+
information:
|
|
610
|
+
|
|
611
|
+
| `--progress` | What you get |
|
|
612
|
+
|---|---|
|
|
613
|
+
| `auto` (default) | `tty` at a terminal, `plain` everywhere else |
|
|
614
|
+
| `tty` | one line, rewritten in place — for a human watching |
|
|
615
|
+
| `plain` | one append-only line per stage — a log file, CI, an agent |
|
|
616
|
+
| `ndjson` | one JSON object per line, stages **and** verdicts as they land |
|
|
617
|
+
| `none` | silence |
|
|
618
|
+
|
|
619
|
+
```bash
|
|
620
|
+
npx testguard-cli probe . > probe.log 2>&1 # now reports; used to be silent
|
|
621
|
+
npx testguard-cli probe . --progress ndjson 2>events.ndjson
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
```
|
|
625
|
+
{"event":"stage","claim":"TG-COST-NAMES-SHARED-DEFENDERS","fault":"F1","stage":"baseline","run":1,"of":3,"at":"…"}
|
|
626
|
+
{"event":"verdict","claim":"TG-COST-NAMES-SHARED-DEFENDERS","fault":"F1","verdict":"killed","severity":"medium","file":"src/probe/cost.mjs","at":"…"}
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
**Progress always goes to stderr**, so `--json` leaves stdout as one document
|
|
630
|
+
and nothing else. `--quiet` and `--json` imply `none`, and an explicit
|
|
631
|
+
`--progress` overrides both: an operator who asks for a stream of events has
|
|
632
|
+
said what they want.
|
|
633
|
+
|
|
634
|
+
### What the probe costs, and why a gate gets slow
|
|
635
|
+
|
|
636
|
+
A probe's wall clock is not a function of how many claims you have. Per claim
|
|
637
|
+
it is roughly
|
|
638
|
+
|
|
639
|
+
```
|
|
640
|
+
(baseline runs + faults × --confirm) × the cost of that claim's defender SET
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
so one slow acceptance test, named as a defender by five claims, is paid for
|
|
644
|
+
thirty times. The symptom is a gate that takes twenty minutes; the cause is a
|
|
645
|
+
single line in the claims file, and nothing in the output used to say which.
|
|
646
|
+
|
|
647
|
+
```bash
|
|
648
|
+
npx testguard-cli claims . --cost # read back from the last probe's evidence
|
|
649
|
+
npx testguard-cli probe . --cost # what the run you just made spent
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
```
|
|
653
|
+
63 fault records cost 24m across 378 defender runs.
|
|
654
|
+
|
|
655
|
+
most expensive claims
|
|
656
|
+
4m 58s TG-FAULT-EDIT-VISIBLE 1 fault · 6 runs
|
|
657
|
+
|
|
658
|
+
defender files, by the time of the runs that included them
|
|
659
|
+
(a claim runs its whole defender set at once, so these overlap and do not sum to the total)
|
|
660
|
+
24m 3s test/probe.fixture.test.mjs 5 claims
|
|
661
|
+
|
|
662
|
+
shared defenders — each claim pays the file's full cost again
|
|
663
|
+
24m 3s test/probe.fixture.test.mjs
|
|
664
|
+
named by TG-ESCALATION-N-RUNS, TG-FAULT-EDIT-VISIBLE, …
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
Every number is read back out of the `durationMs` the probe already records.
|
|
668
|
+
`--cost` re-measures nothing, spawns nothing, and costs the price of reading
|
|
669
|
+
one JSON file.
|
|
670
|
+
|
|
671
|
+
**The per-file figures overlap.** A run executes a claim's whole defender set
|
|
672
|
+
at once, so the runner never says how much of it belonged to which file. A
|
|
673
|
+
file's `ms` is the time of every run that *included* it: an upper bound on
|
|
674
|
+
what removing it could save, which is the decision you are making. Only
|
|
675
|
+
`totalMs` is additive.
|
|
676
|
+
|
|
677
|
+
**What to do about a shared defender.** Extract the decision it proves into a
|
|
678
|
+
pure function, unit-test that, and point the claim at the unit test. This is
|
|
679
|
+
not a testing trick — it is the same reason `classify()` in this codebase is
|
|
680
|
+
pure. A rule you can state in three lines should not need a fifty-second
|
|
681
|
+
acceptance test to falsify it. When the extraction is right the claim still
|
|
682
|
+
fails on the fault; when it is wrong the claim SURVIVES, and the probe tells
|
|
683
|
+
you so before you have shipped a weaker gate. Do not narrow `defendedBy` and
|
|
684
|
+
assume — re-probe the claim and read the verdict.
|
|
685
|
+
|
|
455
686
|
### Authoring faults mechanically
|
|
456
687
|
|
|
457
688
|
Writing faults by hand means reading the code to find exact anchors. Two
|
|
@@ -475,6 +706,18 @@ npx testguard-cli scaffold src/auth.ts --claim AUTH-ADMIN # every proposal und
|
|
|
475
706
|
| `element-removed` | a one-line JSX element — self-closing (`<Toggle … />`) or paired (`<button …>Save</button>`) — removed; the UI shape behind "the toggle is invisible", killable by a browser-layer defender |
|
|
476
707
|
| `handler-dropped` | an `on<Event>={…}` prop removed, whether it is its own line or inline in the tag; the control renders and does nothing |
|
|
477
708
|
|
|
709
|
+
The table above is JavaScript. `scaffold` reads Python too, and proposes the
|
|
710
|
+
same fault classes in Python syntax: `if <guard>:` → `if False:`,
|
|
711
|
+
`return <check>` → `return True`, `verify=True` → `verify=False`, a
|
|
712
|
+
parameter-derived argument swapped for `None`, a key dropped from a payload
|
|
713
|
+
`dict` or an allow-list. Two differences are deliberate. A statement is
|
|
714
|
+
removed by replacing it with `pass`, never by deleting the line, because a
|
|
715
|
+
block whose only statement is gone is an `IndentationError`; and a line that
|
|
716
|
+
leaves a bracket open (`COLOURS = {`) is never removed at all. Both exist
|
|
717
|
+
because a fault that cannot compile is a `fault-invalid` verdict — a probe run
|
|
718
|
+
spent saying nothing about the tests. The two JSX shapes (`element-removed`,
|
|
719
|
+
`handler-dropped`) have no Python meaning and are absent rather than faked.
|
|
720
|
+
|
|
478
721
|
Every proposal's `find` is the exact line with `expectHits`/`occurrence`
|
|
479
722
|
computed from the file, so it is verifiable by construction; provenance is
|
|
480
723
|
`producer: derived`; `defendedBy` is prefilled from the tests that import
|
|
@@ -507,21 +750,28 @@ redacted text for the raw input and the test stays green. `probe` reports it
|
|
|
507
750
|
as `SURVIVED`; the fixture's [README](fixtures/known-answer/README.md) walks
|
|
508
751
|
through every verdict.
|
|
509
752
|
|
|
510
|
-
|
|
753
|
+
The same blind spot, in Python, is
|
|
754
|
+
[`fixtures/known-answer-python/`](fixtures/known-answer-python/README.md) —
|
|
755
|
+
run under **both** stdlib `unittest` and `pytest`, which have different report
|
|
756
|
+
shapes and different notions of failure and must still agree on all sixteen
|
|
757
|
+
verdicts.
|
|
511
758
|
|
|
512
|
-
|
|
513
|
-
**v0.5.** Nine commands (`status`, `init`, `claims`, `probe`, `admit`, `baseline`, `brief`, `gate`, `scaffold`), vitest, jest and Playwright runners, hand-authored faults plus
|
|
514
|
-
a mechanical scaffold, an agent operating layer (`status`, `init`) and a
|
|
515
|
-
change gate (`gate`). The contract
|
|
516
|
-
spine — eight JSON Schemas shared with the other Guard tools — is under
|
|
517
|
-
[`spec/`](spec/). One exact-pinned runtime dependency (`ajv`, for schema validation); Node ≥ 20.
|
|
759
|
+
## Status
|
|
518
760
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
761
|
+
**v0.5.** Eleven commands (`status`, `init`, `claims`, `probe`, `admit`,
|
|
762
|
+
`replay`, `baseline`, `brief`, `gate`, `scaffold`, `mcp`), vitest, jest,
|
|
763
|
+
Playwright and Python (pytest / stdlib unittest) runners, hand-authored faults
|
|
764
|
+
plus a mechanical scaffold for JavaScript and Python, an agent operating layer
|
|
765
|
+
(`status`, `init`) and a change gate (`gate`). The contract spine — eight JSON
|
|
766
|
+
Schemas shared with the other Guard tools — is under [`spec/`](spec/). One
|
|
767
|
+
exact-pinned runtime dependency (`ajv`, for schema validation); Node ≥ 20, and
|
|
768
|
+
Python ≥ 3.8 only when probing Python.
|
|
769
|
+
|
|
770
|
+
Not yet: test generation (the acceptance half, `admit`, exists; the generating
|
|
771
|
+
half stays the agent's), runners beyond those four, AST-aware producers, and
|
|
772
|
+
the transfer of a calibration between repositories — `replay` measures it now;
|
|
773
|
+
whether it carries to a repository with no history is unproven. Each is
|
|
522
774
|
designed for; none is claimed.
|
|
523
|
-
vitest, jest and Playwright, AST-aware producers, and calibration of fault classes
|
|
524
|
-
against real escaped bugs. Each is designed for; none is claimed.
|
|
525
775
|
|
|
526
776
|
## Licence
|
|
527
777
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testguard-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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": {
|
package/spec/GATE-SEMANTICS.md
CHANGED
|
@@ -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,
|
|
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.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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,107 @@ 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
|
+
`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.
|
|
71
178
|
|
|
72
179
|
## Replay reports; it never gates
|
|
73
180
|
|
package/spec/README.md
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# claimspec — the contract spine
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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 |
|
|
@@ -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:
|
|
42
|
+
Schemas are JSON Schema 2020-12 and identified as `urn:claimspec:v1:<kind>`.
|
|
34
43
|
|
|
35
44
|
## Conformance
|
|
36
45
|
|
|
@@ -53,5 +62,14 @@ 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
|
-
|
|
57
|
-
|
|
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.
|