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 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
+ [![CI](https://github.com/raccioly/testguard/actions/workflows/ci.yml/badge.svg)](https://github.com/raccioly/testguard/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/testguard-cli.svg)](https://www.npmjs.com/package/testguard-cli)
5
+ [![PyPI](https://img.shields.io/pypi/v/testguard-cli.svg)](https://pypi.org/project/testguard-cli/)
6
+ [![node](https://img.shields.io/node/v/testguard-cli.svg)](https://nodejs.org)
7
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
8
+ [![zero deps](https://img.shields.io/badge/runtime%20deps-0-brightgreen.svg)](./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.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.mjs';
3
+
4
+ main(process.argv.slice(2)).then((code) => {
5
+ process.exitCode = code;
6
+ });
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
+ }