testguard-cli 0.1.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 +39 -0
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/cli/testguard.mjs +6 -0
- package/package.json +66 -0
- package/spec/GATE-SEMANTICS.md +89 -0
- package/spec/README.md +54 -0
- package/spec/lib/fingerprint.mjs +19 -0
- package/spec/lib/validate.mjs +119 -0
- package/spec/schemas/baseline.schema.json +21 -0
- package/spec/schemas/brief.schema.json +65 -0
- package/spec/schemas/calibration.schema.json +54 -0
- package/spec/schemas/claims.schema.json +103 -0
- package/spec/schemas/common.schema.json +94 -0
- package/spec/schemas/evidence.schema.json +206 -0
- package/spec/schemas/ignore.schema.json +35 -0
- package/src/baseline/baseline.mjs +34 -0
- package/src/brief/brief.mjs +87 -0
- package/src/claims/annotations.mjs +59 -0
- package/src/claims/load.mjs +23 -0
- package/src/cli.mjs +105 -0
- package/src/commands/baseline.mjs +21 -0
- package/src/commands/brief.mjs +27 -0
- package/src/commands/claims.mjs +27 -0
- package/src/commands/probe.mjs +53 -0
- package/src/evidence/writer.mjs +36 -0
- package/src/git.mjs +32 -0
- package/src/probe/classify.mjs +37 -0
- package/src/probe/inject.mjs +86 -0
- package/src/probe/probe.mjs +182 -0
- package/src/probe/rank.mjs +51 -0
- package/src/probe/runner-vitest.mjs +85 -0
- package/src/probe/worktree.mjs +64 -0
- package/src/render.mjs +28 -0
- package/src/util/glob.mjs +56 -0
- package/src/util/hash.mjs +5 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-09-17
|
|
11
|
+
|
|
12
|
+
First release. A claim verifier, not a test generator.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **Contract spine** (`spec/`): six shared JSON Schemas — claims, evidence,
|
|
17
|
+
baseline, ignore, calibration, brief — identified as `urn:guard-spec:v1:*`,
|
|
18
|
+
with a validator that enforces the semantic rules a schema cannot express,
|
|
19
|
+
a single fingerprint derivation, `GATE-SEMANTICS.md`, and a conformance
|
|
20
|
+
corpus (one valid document per kind, 22 must-reject documents).
|
|
21
|
+
- **`testguard claims`** — validates the claims file and reports drift
|
|
22
|
+
against `@claim <ID>` annotations in source.
|
|
23
|
+
- **`testguard probe`** — applies each fault in a scratch git worktree,
|
|
24
|
+
confirms the defenders green N times, runs them N times with the fault,
|
|
25
|
+
escalates survivors to the whole suite with N-run attribution, restores,
|
|
26
|
+
classifies into a closed verdict set (`killed`, `survived`, `nocover`,
|
|
27
|
+
`unverifiable`, `timeout`, `fault-invalid`, `flaky-defender`), ranks, and
|
|
28
|
+
writes evidence validated against the spec. `--ref` pins the probed commit.
|
|
29
|
+
Verdicts are reused when the target, defenders and N are unchanged.
|
|
30
|
+
- **`testguard baseline`** — freezes every non-passing fingerprint so only
|
|
31
|
+
new findings gate; severity floor via `--severity`.
|
|
32
|
+
- **`testguard brief`** — a ranked, capped `## TEST BLINDSPOT CONTEXT` block
|
|
33
|
+
for an agent's session start; `--text` is hook-safe.
|
|
34
|
+
- **Known-answer fixture** with a genuine `objectContaining` blind spot and a
|
|
35
|
+
case for every verdict; probed end to end in the test suite and in CI.
|
|
36
|
+
- **Self-verification**: `testguard.claims.json` states invariants of the
|
|
37
|
+
tool itself, probed by the tool in CI.
|
|
38
|
+
- Distribution: npm (`testguard-cli`), PyPI wrapper (`testguard-cli`),
|
|
39
|
+
GitHub Action, Homebrew formula (staged), pre-commit hooks.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ricardo Accioly
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# TestGuard
|
|
2
|
+
|
|
3
|
+
[](https://github.com/raccioly/testguard/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/testguard-cli)
|
|
5
|
+
[](https://pypi.org/project/testguard-cli/)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
[](./package.json)
|
|
9
|
+
|
|
10
|
+
> Proves that a test suite actually defends the claims a project makes — by
|
|
11
|
+
> injecting the faults those claims say cannot happen, and reporting every
|
|
12
|
+
> fault the tests fail to detect.
|
|
13
|
+
|
|
14
|
+
**Not a test generator. A claim verifier.** Test generation is what happens
|
|
15
|
+
after a claim turns out to be unfalsifiable.
|
|
16
|
+
|
|
17
|
+
Third tool following the Guard pattern, alongside
|
|
18
|
+
[`docguard-cli`](https://www.npmjs.com/package/docguard-cli) (docs ↔ code) and
|
|
19
|
+
[`websec-validator`](https://pypi.org/project/websec-validator/) (attack
|
|
20
|
+
surface ↔ code). All three run one loop:
|
|
21
|
+
|
|
22
|
+
> declare what must be true → try mechanically to falsify it → freeze a
|
|
23
|
+
> baseline → gate only the delta → brief the agent before it writes code.
|
|
24
|
+
|
|
25
|
+
## Why
|
|
26
|
+
|
|
27
|
+
Coverage cannot tell a test that pins *correct* behaviour from one that pins
|
|
28
|
+
a *defect*. An agent that writes both the code and its tests encodes whatever
|
|
29
|
+
it believed — including its bugs — and the suite goes green.
|
|
30
|
+
|
|
31
|
+
Measured on a real, entirely AI-authored production codebase with ~4,900
|
|
32
|
+
disciplined tests (no snapshots, 0.4% zero-assertion): **8 of 9 real
|
|
33
|
+
historical bugs were invisible to the suite**, worst case 2,451 tests green
|
|
34
|
+
on known-broken code. The largest gap was a compliance-critical path with
|
|
35
|
+
100% coverage, where the one assertion that mattered used
|
|
36
|
+
`expect.objectContaining({...})` and omitted the field carrying the data.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
| How | Command |
|
|
41
|
+
|---|---|
|
|
42
|
+
| npx (no install) | `npx testguard-cli probe` |
|
|
43
|
+
| npm | `npm i -D testguard-cli` then `npx testguard probe` |
|
|
44
|
+
| pip | `pip install testguard-cli` then `testguard probe` (needs Node ≥ 20) |
|
|
45
|
+
| Homebrew | `brew tap raccioly/tap && brew install testguard` |
|
|
46
|
+
| GitHub Action | `uses: raccioly/testguard@v0.1.0` — see [`action.yml`](./action.yml) |
|
|
47
|
+
| pre-commit | `repo: https://github.com/raccioly/testguard`, hooks `testguard-claims`, `testguard-probe` |
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx testguard-cli claims # what does this project claim, and is every claim probeable?
|
|
53
|
+
npx testguard-cli probe # try to falsify each claim; report what the tests missed
|
|
54
|
+
npx testguard-cli baseline # freeze today's unproven findings; from now on only new ones gate
|
|
55
|
+
npx testguard-cli brief # tell the agent where the suite is blind, before it writes
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
1. **Claims** live in `testguard.claims.json`: a statement, where it comes
|
|
59
|
+
from, which tests supposedly defend it, and one or more *faults* — each a
|
|
60
|
+
deterministic source change that would make the statement false. Every
|
|
61
|
+
claim and every fault records who produced it. `testguard claims`
|
|
62
|
+
validates the file and reports drift against `@claim <ID>` annotations in
|
|
63
|
+
source.
|
|
64
|
+
2. **Probe** confirms the defenders are green N times unmodified, applies
|
|
65
|
+
each fault in a scratch git worktree (your tree is never touched), runs
|
|
66
|
+
the defenders N times, re-runs survivors against the whole suite with
|
|
67
|
+
N-run attribution, restores, and classifies. Verdicts are a closed set:
|
|
68
|
+
|
|
69
|
+
| Verdict | Meaning |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `killed` | a test body rejected the behaviour, N/N — the only pass |
|
|
72
|
+
| `SURVIVED` | the defenders stayed green while the claim was false |
|
|
73
|
+
| `NOCOVER` | no test file defends the claim at all |
|
|
74
|
+
| `UNVERIFIABLE` | the fault's anchor is missing or ambiguous — loud, never a skip |
|
|
75
|
+
| `TIMEOUT` | the defenders hung; a hang is not a detection |
|
|
76
|
+
| `FAULT-INVALID` | the replacement does not load — a bad fault, not a finding |
|
|
77
|
+
| `FLAKY-DEFENDER` | the defenders are not reliably green, or disagreed across runs |
|
|
78
|
+
|
|
79
|
+
Never a single score. Findings are ranked by severity, claim provenance
|
|
80
|
+
and blast radius, and written to `.testguard/evidence.json` — validated
|
|
81
|
+
against the spec before it is written.
|
|
82
|
+
3. **Baseline** freezes every non-passing fingerprint. Later probes suppress
|
|
83
|
+
what was already known and exit non-zero only on what is new. Claims whose
|
|
84
|
+
source and defenders are unchanged reuse their prior verdict, so a probe
|
|
85
|
+
in CI costs only what changed.
|
|
86
|
+
4. **Brief** turns evidence plus baseline into a ranked, capped
|
|
87
|
+
`## TEST BLINDSPOT CONTEXT` block. Wire it into an agent's session start
|
|
88
|
+
— for Claude Code, in `.claude/settings.json`:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{ "hooks": { "SessionStart": [ { "hooks": [
|
|
92
|
+
{ "type": "command", "command": "npx testguard-cli brief --text" }
|
|
93
|
+
] } ] } }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`--text` prints only, and exits 0 silently when there is no evidence yet,
|
|
97
|
+
so the hook can never break a session.
|
|
98
|
+
|
|
99
|
+
**Commit `.testguard/baseline.json`; ignore `evidence.json` and `brief.json`.**
|
|
100
|
+
The baseline is the frozen contract; the other two are regenerated per run.
|
|
101
|
+
|
|
102
|
+
The fault model is the auditable artifact. You never reach 100% of
|
|
103
|
+
correctness; you reach **100% of stated claims verified**, and the statement
|
|
104
|
+
of claims is what an assessor reads. A claims file is code — its `replace`
|
|
105
|
+
strings run under your test runner — so review it like code.
|
|
106
|
+
|
|
107
|
+
## Try it
|
|
108
|
+
|
|
109
|
+
The repository ships a known-answer fixture with a real blind spot:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
git clone <this repo> && cd testguard && npm install
|
|
113
|
+
npm test # includes probing the fixture end to end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`fixtures/known-answer/` is a tiny project whose audit-row test asserts with
|
|
117
|
+
`expect.objectContaining({...})` and omits the `content` key. Swap the
|
|
118
|
+
redacted text for the raw input and the test stays green. `probe` reports it
|
|
119
|
+
as `SURVIVED`; the fixture's [README](fixtures/known-answer/README.md) walks
|
|
120
|
+
through every verdict.
|
|
121
|
+
|
|
122
|
+
## Status
|
|
123
|
+
|
|
124
|
+
**v0.1.** Four commands, vitest runner, hand-authored faults. The contract
|
|
125
|
+
spine — six JSON Schemas shared with the other Guard tools — is under
|
|
126
|
+
[`spec/`](spec/). Zero runtime dependencies; Node ≥ 20.
|
|
127
|
+
|
|
128
|
+
Not yet: test generation (the two-gate acceptance loop), other runners,
|
|
129
|
+
mechanical fault producers, and calibration of fault classes against real
|
|
130
|
+
escaped bugs. Each is designed for; none is claimed.
|
|
131
|
+
|
|
132
|
+
## Licence
|
|
133
|
+
|
|
134
|
+
MIT.
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "testguard-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Proves a test suite defends the claims a project makes: injects the faults those claims forbid and reports every one the tests fail to detect.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20.0.0"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"cli",
|
|
12
|
+
"src",
|
|
13
|
+
"spec/schemas",
|
|
14
|
+
"spec/lib",
|
|
15
|
+
"spec/GATE-SEMANTICS.md",
|
|
16
|
+
"spec/README.md",
|
|
17
|
+
"README.md",
|
|
18
|
+
"CHANGELOG.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"testguard": "node cli/testguard.mjs",
|
|
23
|
+
"claims": "node cli/testguard.mjs claims",
|
|
24
|
+
"probe": "node cli/testguard.mjs probe",
|
|
25
|
+
"baseline": "node cli/testguard.mjs baseline",
|
|
26
|
+
"brief": "node cli/testguard.mjs brief",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:spec": "vitest run spec/conformance",
|
|
29
|
+
"self:probe": "node cli/testguard.mjs probe . --budget 180000",
|
|
30
|
+
"release:sync": "node .github/scripts/sync-release-version.mjs",
|
|
31
|
+
"prepublishOnly": "node cli/testguard.mjs --version && node .github/scripts/sync-release-version.mjs --check"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"testing",
|
|
35
|
+
"fault-injection",
|
|
36
|
+
"mutation-testing",
|
|
37
|
+
"test-quality",
|
|
38
|
+
"claims",
|
|
39
|
+
"verification",
|
|
40
|
+
"vitest",
|
|
41
|
+
"ai-agents",
|
|
42
|
+
"test-generation",
|
|
43
|
+
"claim-verification",
|
|
44
|
+
"github-actions",
|
|
45
|
+
"quality-gate"
|
|
46
|
+
],
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"ajv": "^8.20.0",
|
|
49
|
+
"vitest": "^5.0.1"
|
|
50
|
+
},
|
|
51
|
+
"bin": {
|
|
52
|
+
"testguard": "cli/testguard.mjs"
|
|
53
|
+
},
|
|
54
|
+
"author": "Ricardo Accioly <raccioly@gmail.com>",
|
|
55
|
+
"repository": {
|
|
56
|
+
"type": "git",
|
|
57
|
+
"url": "git+https://github.com/raccioly/testguard.git"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://github.com/raccioly/testguard#readme",
|
|
60
|
+
"bugs": {
|
|
61
|
+
"url": "https://github.com/raccioly/testguard/issues"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Gate semantics
|
|
2
|
+
|
|
3
|
+
How a Guard-spec tool decides whether CI goes red. Shared by every tool that
|
|
4
|
+
adopts the spec; each tool may add stricter rules, never looser ones.
|
|
5
|
+
|
|
6
|
+
## Verdicts
|
|
7
|
+
|
|
8
|
+
The verdict set is closed. Only one value is a pass.
|
|
9
|
+
|
|
10
|
+
| Verdict | Meaning | Gates by default |
|
|
11
|
+
|---|---|---|
|
|
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
|
+
| `survived` | Fault applied; every one of N probe runs passed. The claim is **unproven**. | **yes** |
|
|
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 fault could not be applied: anchor missing or ambiguous. Carries a `reason`. This is a loud, gating verdict — a claim that cannot be probed is not "skipped", it is undefended until someone re-authors the fault. | **yes** |
|
|
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
|
+
| `fault-invalid` | The replacement does not load or compile. A bad fault, not a detection. | **yes** |
|
|
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** |
|
|
19
|
+
|
|
20
|
+
Rules that follow from the table:
|
|
21
|
+
|
|
22
|
+
1. **Green baseline first.** Defenders run N times unmodified before any fault
|
|
23
|
+
is applied. Anything short of N/N pass is `flaky-defender` and stops there.
|
|
24
|
+
2. **Confirm over N runs.** `killed` and `survived` both require exactly N
|
|
25
|
+
probe runs, all agreeing. Default N is 3.
|
|
26
|
+
3. **Only a test body rejecting the behaviour kills.** A test that fails by
|
|
27
|
+
assertion — or by an exception the fault provoked inside it — counts. A
|
|
28
|
+
timeout does not, and a suite that fails to load does not: neither is
|
|
29
|
+
evidence that the suite defends the claim. Tools parse the runner's
|
|
30
|
+
structured report, never its exit code, because the exit code cannot tell
|
|
31
|
+
these apart.
|
|
32
|
+
4. **A mixed result is not a kill.** If some of the N probe runs fail and
|
|
33
|
+
others pass, the defender's response to the fault is nondeterministic.
|
|
34
|
+
Reporting `killed` would be the optimistic bias flakiness introduces;
|
|
35
|
+
reporting `survived` would be wrong the other way. It is `flaky-defender`.
|
|
36
|
+
5. **Escalation never upgrades a verdict.** A fault that survives its declared
|
|
37
|
+
defenders may be re-run against the whole suite. If the wider suite kills
|
|
38
|
+
it, the verdict stays `survived` with reason `killed-by-undeclared-tests`:
|
|
39
|
+
the claim's stated evidence chain is broken even though the suite is not
|
|
40
|
+
blind. It gates, and ranks below a true survivor.
|
|
41
|
+
6. **Never a single global score.** Output is per claim, ranked. Blindness is
|
|
42
|
+
concentrated, and one number hides where.
|
|
43
|
+
|
|
44
|
+
## Baseline and delta
|
|
45
|
+
|
|
46
|
+
A baseline freezes the fingerprints of every non-passing finding at a point
|
|
47
|
+
in time. On later runs:
|
|
48
|
+
|
|
49
|
+
- A finding whose fingerprint appears in the baseline is **baselined** and
|
|
50
|
+
suppressed, up to the stored `count`.
|
|
51
|
+
- Any other non-passing finding is **new** and gates.
|
|
52
|
+
- `killed` findings are never fingerprinted; a baseline holds only debt.
|
|
53
|
+
|
|
54
|
+
The fingerprint is `sha256(claimId \n subjectId \n file \n verdict)`. It is
|
|
55
|
+
derived from identities and outcome, never from the fault's `find`/`replace`
|
|
56
|
+
text, so repairing a rotted anchor does not churn the baseline — while a
|
|
57
|
+
change of verdict on the same claim+subject does surface as new.
|
|
58
|
+
|
|
59
|
+
Adopting tools may reconcile an existing baseline format (for example a
|
|
60
|
+
`{version, fingerprints:{hash:count}}` file) by mapping it onto this shape;
|
|
61
|
+
the suppress-up-to-count semantics are identical.
|
|
62
|
+
|
|
63
|
+
## Ignore and annotations
|
|
64
|
+
|
|
65
|
+
- An ignore entry removes a subject from *scope* before probing. Every entry
|
|
66
|
+
carries a `reason` of at least eight characters; the structured form is what
|
|
67
|
+
an auditor reads. A plain gitignore-syntax file may be accepted as shorthand
|
|
68
|
+
for reasonless `path` entries.
|
|
69
|
+
- An annotation is **strictly additive**. It never changes, suppresses, or
|
|
70
|
+
drops a finding. Ranking may read annotations; verdicts never do.
|
|
71
|
+
|
|
72
|
+
## Severity floor
|
|
73
|
+
|
|
74
|
+
`--severity <level>` gates only findings whose claim severity is at or above
|
|
75
|
+
the level. Findings below the floor are still reported and still written to
|
|
76
|
+
evidence; they simply do not turn CI red.
|
|
77
|
+
|
|
78
|
+
## Exit codes
|
|
79
|
+
|
|
80
|
+
| Code | Meaning |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `0` | No new gating findings at or above the severity floor. |
|
|
83
|
+
| `1` | At least one new gating finding. |
|
|
84
|
+
| `2` | Precondition failed: defenders not green, working tree dirty for a target file, runner not found, claims file invalid. Nothing was probed. |
|
|
85
|
+
| `3` | Usage or configuration error. |
|
|
86
|
+
|
|
87
|
+
A tool must never exit `0` because it had nothing to check. If the claims
|
|
88
|
+
file is empty or every claim is out of scope, that is reported explicitly and
|
|
89
|
+
the exit code is `2`.
|
package/spec/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Guard spec — the contract spine
|
|
2
|
+
|
|
3
|
+
Six 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.
|
|
8
|
+
|
|
9
|
+
The pattern all such tools run:
|
|
10
|
+
|
|
11
|
+
> declare what must be true → try mechanically to falsify it → freeze a
|
|
12
|
+
> baseline → gate only the delta → brief the agent before it writes code.
|
|
13
|
+
|
|
14
|
+
## Formats
|
|
15
|
+
|
|
16
|
+
| Kind | Schema | Purpose |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `claims` | [`schemas/claims.schema.json`](schemas/claims.schema.json) | What must be true, and one or more concrete faults that would make it false. Producer-agnostic: a human, an operator, an agent, or a derivation may all emit the same fault shape, and every artifact says which. |
|
|
19
|
+
| `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
|
+
| `baseline` | [`schemas/baseline.schema.json`](schemas/baseline.schema.json) | Frozen fingerprints of existing debt. Gate only what is new. |
|
|
21
|
+
| `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. |
|
|
23
|
+
| `brief` | [`schemas/brief.schema.json`](schemas/brief.schema.json) | What to tell an agent before it writes code — ranked, capped, never a single score. |
|
|
24
|
+
|
|
25
|
+
Shared definitions (verdicts, fault classes, provenance, annotations) live in
|
|
26
|
+
[`schemas/common.schema.json`](schemas/common.schema.json). Gate behaviour —
|
|
27
|
+
which verdicts turn CI red, baseline and delta rules, exit codes — is in
|
|
28
|
+
[`GATE-SEMANTICS.md`](GATE-SEMANTICS.md).
|
|
29
|
+
|
|
30
|
+
Schemas are JSON Schema 2020-12 and identified as `urn:guard-spec:v1:<kind>`.
|
|
31
|
+
|
|
32
|
+
## Conformance
|
|
33
|
+
|
|
34
|
+
`lib/validate.mjs` validates a document of any kind: JSON Schema first, then
|
|
35
|
+
the semantic rules a schema cannot express (unique ids, fingerprint
|
|
36
|
+
derivation, N-run agreement, green-baseline requirement, interval sanity).
|
|
37
|
+
`lib/fingerprint.mjs` is the single fingerprint implementation every tool
|
|
38
|
+
must use.
|
|
39
|
+
|
|
40
|
+
`conformance/examples/` holds one valid document per kind; `conformance/invalid/`
|
|
41
|
+
holds documents that must be rejected, each named for its defect. A tool
|
|
42
|
+
conforms when its output validates and its behaviour matches
|
|
43
|
+
`GATE-SEMANTICS.md`.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm run test:spec
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Sequencing
|
|
50
|
+
|
|
51
|
+
The spec is drafted here and validated against `testguard` as its first
|
|
52
|
+
consumer. It moves to its own repository only when a second tool adopts it.
|
|
53
|
+
Adoption by an existing tool is one additive output writer — no rewrite, no
|
|
54
|
+
behaviour change, and optional until that tool wants it.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one fingerprint derivation every Guard-spec tool must use.
|
|
5
|
+
*
|
|
6
|
+
* Built from identities and outcome only — never from the fault's find/replace
|
|
7
|
+
* text — so repairing a rotted anchor does not churn a baseline, while a
|
|
8
|
+
* change of verdict on the same claim+subject surfaces as a new finding.
|
|
9
|
+
*
|
|
10
|
+
* `file` is the empty string when the subject has no file (non-TestGuard adopters).
|
|
11
|
+
*/
|
|
12
|
+
export function fingerprint({ claimId, subjectId, file = '', verdict }) {
|
|
13
|
+
for (const [k, v] of Object.entries({ claimId, subjectId, verdict })) {
|
|
14
|
+
if (typeof v !== 'string' || v.length === 0) throw new TypeError(`fingerprint: ${k} is required`);
|
|
15
|
+
}
|
|
16
|
+
return createHash('sha256')
|
|
17
|
+
.update([claimId, subjectId, file, verdict].join('\n'))
|
|
18
|
+
.digest('hex');
|
|
19
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
2
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fingerprint } from './fingerprint.mjs';
|
|
6
|
+
|
|
7
|
+
const schemaDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'schemas');
|
|
8
|
+
|
|
9
|
+
export const KINDS = Object.freeze(['claims', 'evidence', 'baseline', 'ignore', 'calibration', 'brief']);
|
|
10
|
+
export const PASSING_VERDICTS = Object.freeze(new Set(['killed']));
|
|
11
|
+
|
|
12
|
+
const ajv = new Ajv2020({ strict: true, allErrors: true });
|
|
13
|
+
for (const f of readdirSync(schemaDir).filter((n) => n.endsWith('.schema.json'))) {
|
|
14
|
+
ajv.addSchema(JSON.parse(readFileSync(join(schemaDir, f), 'utf8')));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const schemaFor = (kind) => {
|
|
18
|
+
const v = ajv.getSchema(`urn:guard-spec:v1:${kind}`);
|
|
19
|
+
if (!v) throw new RangeError(`unknown Guard-spec kind: ${kind}`);
|
|
20
|
+
return v;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Rules JSON Schema cannot express. Each returns an array of {path, message}.
|
|
24
|
+
const semantic = {
|
|
25
|
+
claims(doc) {
|
|
26
|
+
const errors = [];
|
|
27
|
+
const seenClaim = new Set();
|
|
28
|
+
doc.claims.forEach((c, ci) => {
|
|
29
|
+
if (seenClaim.has(c.id)) errors.push({ path: `/claims/${ci}/id`, message: `duplicate claim id "${c.id}"` });
|
|
30
|
+
seenClaim.add(c.id);
|
|
31
|
+
const seenFault = new Set();
|
|
32
|
+
c.faults.forEach((f, fi) => {
|
|
33
|
+
const p = `/claims/${ci}/faults/${fi}`;
|
|
34
|
+
if (seenFault.has(f.id)) errors.push({ path: `${p}/id`, message: `duplicate fault id "${f.id}" in claim "${c.id}"` });
|
|
35
|
+
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' });
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
return errors;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
evidence(doc) {
|
|
46
|
+
const errors = [];
|
|
47
|
+
const n = doc.run.confirmRuns;
|
|
48
|
+
doc.records.forEach((r, i) => {
|
|
49
|
+
const p = `/records/${i}`;
|
|
50
|
+
const expected = fingerprint({ claimId: r.claim.id, subjectId: r.subject.id, file: r.subject.file ?? '', verdict: r.verdict });
|
|
51
|
+
if (r.fingerprint !== expected) errors.push({ path: `${p}/fingerprint`, message: `fingerprint does not match spec derivation (expected ${expected})` });
|
|
52
|
+
|
|
53
|
+
const { baselineRuns, probeRuns } = r.detail;
|
|
54
|
+
if (r.verdict === 'killed' || r.verdict === 'survived') {
|
|
55
|
+
if (baselineRuns.length !== n) errors.push({ path: `${p}/detail/baselineRuns`, message: `${r.verdict} requires exactly confirmRuns (${n}) baseline runs, got ${baselineRuns.length}` });
|
|
56
|
+
if (probeRuns.length !== n) errors.push({ path: `${p}/detail/probeRuns`, message: `${r.verdict} requires exactly confirmRuns (${n}) probe runs, got ${probeRuns.length}` });
|
|
57
|
+
if (baselineRuns.some((b) => b.outcome !== 'pass')) errors.push({ path: `${p}/detail/baselineRuns`, message: `${r.verdict} is only valid on a green baseline` });
|
|
58
|
+
}
|
|
59
|
+
if (r.verdict === 'killed' && !probeRuns.every((x) => x.outcome === 'fail' && (x.assertionFailures ?? 0) > 0)) {
|
|
60
|
+
errors.push({ path: `${p}/detail/probeRuns`, message: 'killed requires every probe run to fail with at least one assertion failure' });
|
|
61
|
+
}
|
|
62
|
+
if (r.verdict === 'survived' && !probeRuns.every((x) => x.outcome === 'pass')) {
|
|
63
|
+
errors.push({ path: `${p}/detail/probeRuns`, message: 'survived requires every probe run to pass' });
|
|
64
|
+
}
|
|
65
|
+
if (r.verdict === 'flaky-defender') {
|
|
66
|
+
const baselineNotGreen = baselineRuns.length === 0 || baselineRuns.some((b) => b.outcome !== 'pass');
|
|
67
|
+
const mixedProbe = probeRuns.some((x) => x.outcome === 'pass') && probeRuns.some((x) => x.outcome === 'fail');
|
|
68
|
+
if (!baselineNotGreen && !mixedProbe) {
|
|
69
|
+
errors.push({ path: `${p}/detail`, message: 'flaky-defender requires a non-green baseline or mixed pass/fail probe runs' });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (r.verdict === 'nocover' && !r.defenders.nocover) {
|
|
73
|
+
errors.push({ path: `${p}/defenders/nocover`, message: 'nocover verdict requires defenders.nocover = true' });
|
|
74
|
+
}
|
|
75
|
+
if (r.verdict === 'unverifiable' && !r.detail.reason) {
|
|
76
|
+
errors.push({ path: `${p}/detail/reason`, message: 'unverifiable requires a reason (e.g. anchor-missing, anchor-ambiguous)' });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return errors;
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
calibration(doc) {
|
|
83
|
+
const errors = [];
|
|
84
|
+
for (const [key, b] of Object.entries(doc.buckets)) {
|
|
85
|
+
const p = `/buckets/${key}`;
|
|
86
|
+
if (b.positives > b.n) errors.push({ path: `${p}/positives`, message: `positives (${b.positives}) exceed n (${b.n})` });
|
|
87
|
+
const [lo, hi] = b.ci;
|
|
88
|
+
if (lo > hi) errors.push({ path: `${p}/ci`, message: `ci lower bound ${lo} exceeds upper bound ${hi}` });
|
|
89
|
+
if (b.p < lo || b.p > hi) errors.push({ path: `${p}/p`, message: `p (${b.p}) lies outside ci [${lo}, ${hi}]` });
|
|
90
|
+
}
|
|
91
|
+
return errors;
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
brief(doc) {
|
|
95
|
+
const errors = [];
|
|
96
|
+
if (!doc.text.startsWith(doc.heading)) errors.push({ path: '/text', message: 'text must begin with heading' });
|
|
97
|
+
const byVerdictTotal = Object.values(doc.summary.byVerdict).reduce((a, b) => a + b, 0);
|
|
98
|
+
if (doc.summary.new + doc.summary.baselined > byVerdictTotal) {
|
|
99
|
+
errors.push({ path: '/summary', message: 'new + baselined cannot exceed the total of byVerdict' });
|
|
100
|
+
}
|
|
101
|
+
return errors;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validate a document against a Guard-spec kind: JSON Schema first, then the
|
|
107
|
+
* semantic rules for that kind. Returns { ok, errors: [{ path, message }] }.
|
|
108
|
+
*/
|
|
109
|
+
export function validate(kind, doc) {
|
|
110
|
+
const check = schemaFor(kind);
|
|
111
|
+
if (!check(doc)) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
errors: check.errors.map((e) => ({ path: e.instancePath || '/', message: e.message ?? 'invalid' })),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const errors = semantic[kind] ? semantic[kind](doc) : [];
|
|
118
|
+
return { ok: errors.length === 0, errors };
|
|
119
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:guard-spec:v1:baseline",
|
|
4
|
+
"title": "Guard spec v1 — baseline",
|
|
5
|
+
"description": "Frozen fingerprints of findings that existed when the baseline was taken. A finding whose fingerprint appears here is suppressed up to `count` occurrences; everything else is new and gates. Only non-passing verdicts are ever fingerprinted.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["schemaVersion", "tool", "createdAt", "fingerprints"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": { "type": "string" },
|
|
10
|
+
"schemaVersion": { "const": 1 },
|
|
11
|
+
"tool": { "$ref": "urn:guard-spec:v1:common#/$defs/tool" },
|
|
12
|
+
"createdAt": { "$ref": "urn:guard-spec:v1:common#/$defs/isoDateTime" },
|
|
13
|
+
"head": { "$ref": "urn:guard-spec:v1:common#/$defs/gitSha" },
|
|
14
|
+
"fingerprints": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"propertyNames": { "pattern": "^[a-f0-9]{64}$" },
|
|
17
|
+
"additionalProperties": { "type": "integer", "minimum": 1 }
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"additionalProperties": false
|
|
21
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:guard-spec:v1:brief",
|
|
4
|
+
"title": "Guard spec v1 — agent brief",
|
|
5
|
+
"description": "What to tell an agent before it writes code. `text` is the rendered block for an additionalContext payload; `items` is the same content structured so other tools can compose briefs. Ranked, capped, and never a single score.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["schemaVersion", "tool", "generatedAt", "heading", "summary", "items", "text"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": { "type": "string" },
|
|
10
|
+
"schemaVersion": { "const": 1 },
|
|
11
|
+
"tool": { "$ref": "urn:guard-spec:v1:common#/$defs/tool" },
|
|
12
|
+
"generatedAt": { "$ref": "urn:guard-spec:v1:common#/$defs/isoDateTime" },
|
|
13
|
+
"head": { "$ref": "urn:guard-spec:v1:common#/$defs/gitSha" },
|
|
14
|
+
"heading": {
|
|
15
|
+
"description": "The markdown heading that opens `text`, e.g. `## TEST BLINDSPOT CONTEXT`.",
|
|
16
|
+
"type": "string",
|
|
17
|
+
"minLength": 1,
|
|
18
|
+
"maxLength": 128
|
|
19
|
+
},
|
|
20
|
+
"summary": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"required": ["claims", "byVerdict", "new", "baselined"],
|
|
23
|
+
"properties": {
|
|
24
|
+
"claims": { "type": "integer", "minimum": 0 },
|
|
25
|
+
"byVerdict": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"propertyNames": {
|
|
28
|
+
"enum": ["killed", "survived", "nocover", "unverifiable", "timeout", "fault-invalid", "flaky-defender"]
|
|
29
|
+
},
|
|
30
|
+
"additionalProperties": { "type": "integer", "minimum": 0 }
|
|
31
|
+
},
|
|
32
|
+
"new": { "type": "integer", "minimum": 0 },
|
|
33
|
+
"baselined": { "type": "integer", "minimum": 0 }
|
|
34
|
+
},
|
|
35
|
+
"additionalProperties": false
|
|
36
|
+
},
|
|
37
|
+
"items": {
|
|
38
|
+
"type": "array",
|
|
39
|
+
"maxItems": 50,
|
|
40
|
+
"items": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"required": ["fingerprint", "claimId", "statement", "verdict", "severity", "isNew"],
|
|
43
|
+
"properties": {
|
|
44
|
+
"fingerprint": { "$ref": "urn:guard-spec:v1:common#/$defs/sha256" },
|
|
45
|
+
"claimId": { "$ref": "urn:guard-spec:v1:common#/$defs/identifier" },
|
|
46
|
+
"subjectId": { "$ref": "urn:guard-spec:v1:common#/$defs/identifier" },
|
|
47
|
+
"statement": { "type": "string", "minLength": 1, "maxLength": 2000 },
|
|
48
|
+
"verdict": { "$ref": "urn:guard-spec:v1:common#/$defs/verdict" },
|
|
49
|
+
"severity": { "$ref": "urn:guard-spec:v1:common#/$defs/severity" },
|
|
50
|
+
"file": { "$ref": "urn:guard-spec:v1:common#/$defs/repoPath" },
|
|
51
|
+
"rank": { "type": "number" },
|
|
52
|
+
"hint": {
|
|
53
|
+
"description": "One line the agent can act on, e.g. which assertion shape left the gap.",
|
|
54
|
+
"type": "string",
|
|
55
|
+
"maxLength": 512
|
|
56
|
+
},
|
|
57
|
+
"isNew": { "type": "boolean" }
|
|
58
|
+
},
|
|
59
|
+
"additionalProperties": false
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"text": { "type": "string", "minLength": 1 }
|
|
63
|
+
},
|
|
64
|
+
"additionalProperties": false
|
|
65
|
+
}
|