testguard-cli 0.3.1 → 0.5.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 CHANGED
@@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-17
11
+
12
+ The change gate: unclaimed code is now a finding.
13
+
14
+ ### Added
15
+
16
+ - **`testguard gate --changed <ref>`** — the change gate. Every escaped
17
+ defect in the field reports was a *claim gap*: the feature shipped green
18
+ with zero claims, and `probe` is silent about unclaimed code by
19
+ construction. `gate` measures the files changed since
20
+ `merge-base(ref, HEAD)` (or in the working tree with `--include-dirty`)
21
+ and exits `1` when any changed source file carries no fault, does not
22
+ resolve as a defender (test files), and is not excused by an unexpired
23
+ `path` entry in `testguard.ignore.json`. Every reliance on an ignore entry
24
+ is printed with its reason; expired entries excuse nothing. Non-source
25
+ files and documented never-claimed patterns are excluded and listed
26
+ (`--explain`, `--exclude <glob>`); `--strict` fails a change that
27
+ evaluated nothing. The base branch is detected in GitHub Actions and
28
+ GitLab CI (`TESTGUARD_CHANGED_REF` overrides). A new spec kind,
29
+ `gate.schema.json`, with conformance examples.
30
+ - **`status --changed <ref>`** — a new state `unclaimed-changes` and action
31
+ `claim` that precede every evidence state; `next.file` names the first
32
+ unclaimed file. `brief` renders unclaimed files before the findings, and
33
+ briefs them even when there is no evidence yet.
34
+ - `testguard.ignore.json` at the repository root: TestGuard's own excused
35
+ paths (dispatch, thin command wrappers, rendering), with reasons.
36
+ - Nine new self-claims: the gate, status and brief invariants, explicit
37
+ `--changed` being required, `init` idempotency, and — because the gate
38
+ flagged it on its own pull request — the release version-sync check,
39
+ which had no test before. The gate runs on this repository's own pull
40
+ requests.
41
+ - GitHub Action `command: gate` with a `changed-ref` input; pre-commit hook
42
+ `testguard-gate`; a GitLab CI template under `packaging/gitlab/` with
43
+ `testguard:gate` (merge request pipelines, measured against
44
+ `CI_MERGE_REQUEST_DIFF_BASE_SHA`) and `testguard:probe`.
45
+
46
+ ## [0.4.0] - 2026-09-17
47
+
48
+ ### Added
49
+
50
+ - **jest runner.** `--runner vitest|jest|auto` (default `auto`: the first
51
+ that resolves in the project). jest runs with `--ci --json --runTestsByPath`
52
+ so file arguments are exact paths, not regexes; its `Exceeded timeout`
53
+ wording is recognised as a timeout, never a kill; `__tests__/` files are
54
+ collected for escalation. `run.runner.name` records which runner produced
55
+ the evidence.
56
+ - A CommonJS edition of the known-answer fixture (`fixtures/known-answer-jest`)
57
+ with the same claims and the same expected verdicts; the acceptance suite
58
+ and CI prove jest against it exactly as vitest is proven against the ESM
59
+ one.
60
+
61
+ ### Changed
62
+
63
+ - Runners are now modules behind one interface (`src/probe/runners/`); the
64
+ vitest path is unchanged and still reproduces its oracle.
65
+
10
66
  ## [0.3.1] - 2026-09-17
11
67
 
12
68
  Dogfooding `init` and `status` on this repository.
package/README.md CHANGED
@@ -52,7 +52,7 @@ tests were written against the survivors, 39/39 were killed.
52
52
  | npm | `npm i -D testguard-cli` then `npx testguard probe` |
53
53
  | pip | `pip install testguard-cli` then `testguard probe` (needs Node ≥ 20) |
54
54
  | Homebrew | `brew tap raccioly/tap && brew install testguard` |
55
- | GitHub Action | `uses: raccioly/testguard@v0.3.1` — see [`action.yml`](./action.yml) |
55
+ | GitHub Action | `uses: raccioly/testguard@v0.5.0` — see [`action.yml`](./action.yml) |
56
56
  | pre-commit | `repo: https://github.com/raccioly/testguard`, hooks `testguard-claims`, `testguard-probe` |
57
57
 
58
58
  Projects that set `min-release-age` in `.npmrc` cannot see a version published
@@ -68,6 +68,7 @@ npx testguard-cli claims # what does this project claim, and is every claim
68
68
  npx testguard-cli probe # try to falsify each claim; report what the tests missed
69
69
  npx testguard-cli baseline # freeze today's unproven findings; from now on only new ones gate
70
70
  npx testguard-cli brief # tell the agent where the suite is blind, before it writes
71
+ npx testguard-cli gate --changed origin/main # fail when a changed source file carries no claim at all
71
72
  npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to keep or drop
72
73
  ```
73
74
 
@@ -111,6 +112,11 @@ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to
111
112
  files that import the fault's target, by relative path or resolved alias.
112
113
  `NOCOVER` then means exactly "no test file imports this source".
113
114
 
115
+ Runners: **vitest** and **jest** (`--runner auto` picks the first that
116
+ resolves; both read the same jest-compatible JSON report). Anything else
117
+ goes through `--runner-cmd`. Each runner is proven against its own copy
118
+ of the known-answer fixture.
119
+
114
120
  Practical loop: first pass `--no-escalate` (escalation re-runs the whole
115
121
  suite N times per survivor); iterate on one claim with `--claim <ID>` and
116
122
  either `--include-dirty` or `--in-place` (only fault target files must be
@@ -141,6 +147,54 @@ npx testguard-cli scaffold src/x.ts # propose faults for a file, as a draft to
141
147
  `--text` prints only, and exits 0 silently when there is no evidence yet,
142
148
  so the hook can never break a session.
143
149
 
150
+ ### Every change needs a claim
151
+
152
+ `probe` can only verify claims that exist. Every escaped defect in the field
153
+ reports so far was a **claim gap**: the feature shipped green with zero
154
+ claims, and a verifier with no claim about a feature is silent about it by
155
+ construction. `gate` closes that hole on the delta:
156
+
157
+ ```bash
158
+ npx testguard-cli gate --changed origin/main # in a PR: the files changed since the base branch
159
+ npx testguard-cli gate --changed HEAD --include-dirty # before a commit: the working tree, staged or not
160
+ ```
161
+
162
+ Every changed source file must carry a fault, resolve as a defender of a
163
+ claim (test files), or be excused by an unexpired `path` entry in
164
+ `testguard.ignore.json` — with a reason a reviewer will accept. One
165
+ unclaimed file exits `1`; there is no percentage. Every reliance on an ignore
166
+ entry is printed, so a reviewer sees *why* the gate passed; an expired entry
167
+ excuses nothing. Non-source files and documented never-claimed patterns
168
+ (`*.d.ts`, `*.config.*`, fixtures, mocks; `--explain` lists them) are
169
+ excluded and said so; `--strict` fails a change that evaluated nothing.
170
+
171
+ With a reference known, `status --changed <ref>` reports `unclaimed-changes`
172
+ **before** any evidence state and makes the claim the next action; the brief
173
+ lists the unclaimed files first. The claim is written before more code.
174
+
175
+ **In CI the base is detected** — GitHub Actions (`GITHUB_BASE_REF`) and GitLab
176
+ merge request pipelines (`CI_MERGE_REQUEST_DIFF_BASE_SHA`, then
177
+ `CI_MERGE_REQUEST_TARGET_BRANCH_NAME`); `TESTGUARD_CHANGED_REF` overrides both.
178
+ The base must exist locally: GitHub — `actions/checkout` with `fetch-depth: 0`;
179
+ GitLab — the diff base sha needs nothing extra on a merge request pipeline,
180
+ the branch name needs `GIT_DEPTH: 0` or a `git fetch origin <target>`. A
181
+ detected base that does not resolve is a warning for `status` and `brief`
182
+ (they keep working) and an error for `gate` (its whole job is the measurement).
183
+
184
+ ```yaml
185
+ # GitHub Actions
186
+ - uses: actions/checkout@v4
187
+ with: { fetch-depth: 0 }
188
+ - uses: raccioly/testguard@v0.5.0
189
+ with: { command: gate }
190
+
191
+ # GitLab CI — or include: remote: the template in packaging/gitlab/
192
+ testguard:gate:
193
+ image: node:22
194
+ rules: [{ if: $CI_PIPELINE_SOURCE == "merge_request_event" }]
195
+ script: [npx -y testguard-cli gate .]
196
+ ```
197
+
144
198
  ### Built for agents to run
145
199
 
146
200
  TestGuard is meant to be driven by an AI agent, not typed by a person. Three
@@ -215,14 +269,15 @@ through every verdict.
215
269
 
216
270
  ## Status
217
271
 
218
- **v0.3.** Seven commands, vitest runner, hand-authored faults plus a
219
- mechanical scaffold, and an agent operating layer (`status`, `init`). The contract
220
- spine — six JSON Schemas shared with the other Guard tools — is under
272
+ **v0.4.** Eight commands, vitest and jest runners, hand-authored faults plus
273
+ a mechanical scaffold, an agent operating layer (`status`, `init`) and a
274
+ change gate (`gate`). The contract
275
+ spine — eight JSON Schemas shared with the other Guard tools — is under
221
276
  [`spec/`](spec/). One exact-pinned runtime dependency (`ajv`, for schema validation); Node ≥ 20.
222
277
 
223
- Not yet: test generation (the two-gate acceptance loop), other runners,
224
- AST-aware producers, and calibration of fault classes against real escaped
225
- bugs. Each is designed for; none is claimed.
278
+ Not yet: test generation (the two-gate acceptance loop), runners beyond
279
+ vitest and jest, AST-aware producers, and calibration of fault classes
280
+ against real escaped bugs. Each is designed for; none is claimed.
226
281
 
227
282
  ## Licence
228
283
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testguard-cli",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
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
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,6 +46,7 @@
46
46
  "quality-gate"
47
47
  ],
48
48
  "devDependencies": {
49
+ "jest": "^30.5.1",
49
50
  "vitest": "^5.0.1"
50
51
  },
51
52
  "bin": {
@@ -85,6 +85,57 @@ the suppress-up-to-count semantics are identical.
85
85
  - An annotation is **strictly additive**. It never changes, suppresses, or
86
86
  drops a finding. Ranking may read annotations; verdicts never do.
87
87
 
88
+ ## Claim coverage of a change
89
+
90
+ `probe` asks whether the tests defend the claims that exist. It says nothing
91
+ about code that has no claim, by construction — and every escaped defect the
92
+ field reports share was a *claim gap*, not a defender gap: the feature shipped
93
+ green with zero claims. A repository with ten old claims and one new,
94
+ unclaimed module exits 0 under `probe` forever.
95
+
96
+ The change gate closes that hole. Given a reference, a tool measures the
97
+ **delta** — the files changed since `merge-base(ref, HEAD)`, or since that
98
+ merge-base in the working tree — file by file:
99
+
100
+ - A changed file is **excluded** when it is not source (by extension) or
101
+ matches a documented never-claimed pattern (`*.d.ts`, `*.config.*`,
102
+ fixtures, mocks, snapshots, the tool's own directory). Exclusions are
103
+ listed, never silent.
104
+ - A changed source file is **covered** when at least one fault anchors to it,
105
+ when it is a test file that resolves as a defender of some claim, or when an
106
+ **unexpired** `path` ignore entry excuses it.
107
+ - Anything else is **uncovered**. The unit is the file, on purpose: a fault
108
+ anchors to a file, so the file is the smallest unit the rest of the
109
+ contract already understands.
110
+
111
+ Rules:
112
+
113
+ 1. **One uncovered file gates.** Exit `1`. There is no threshold and no
114
+ percentage; a percentage is how the gap hid before.
115
+ 2. **Every reliance on an ignore entry is reported.** The gate may pass
116
+ *because of* an excuse; a reviewer reads which entries carried it, with
117
+ their reasons and expiry. An expired entry excuses nothing and is reported
118
+ as expired.
119
+ 3. **A test file needs a claim too.** A test that defends no claim is the
120
+ authorship trap the pattern exists for; it is uncovered until a claim
121
+ names it in `defendedBy` (or discovery resolves it as a defender).
122
+ 4. **Never a silent pass on nothing.** A non-empty change whose files were
123
+ all excluded passes with an explicit "0 evaluated" line; a tool offers a
124
+ strict mode that fails it instead. An empty change is an honest `0`.
125
+ 5. **The reference is never guessed.** An explicit flag, or a CI-provided base
126
+ branch, or an error. An upstream that already contains the change has an
127
+ empty diff and would pass trivially.
128
+ 6. **Unclaimed changes precede every evidence state.** When the status
129
+ document knows a reference and finds uncovered files, its state is
130
+ `unclaimed-changes` and its next action is to write the claim, before any
131
+ `unproven` finding is surfaced. The brief renders them first. The claim is
132
+ written before more code.
133
+
134
+ Exit codes: `0` every changed source file is claimed or excused (or nothing
135
+ changed); `1` at least one uncovered file (or strict mode over an
136
+ all-excluded change); `2` the change cannot be evaluated (unresolvable
137
+ reference, invalid claims or ignore file, no repository); `3` no reference.
138
+
88
139
  ## Severity floor
89
140
 
90
141
  `--severity <level>` gates only findings whose claim severity is at or above
package/spec/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Guard spec — the contract spine
2
2
 
3
- Six formats that tools following the Guard pattern share. They share
3
+ Eight formats that tools following the Guard pattern share. They share
4
4
  *formats*, not code: [`docguard-cli`](https://www.npmjs.com/package/docguard-cli)
5
5
  is Node ESM, [`websec-validator`](https://pypi.org/project/websec-validator/)
6
6
  is Python, and porting one runtime into the other is not worth it. A tool
@@ -21,7 +21,8 @@ The pattern all such tools run:
21
21
  | `ignore` | [`schemas/ignore.schema.json`](schemas/ignore.schema.json) | Reviewable scoping. Every entry has a reason. |
22
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
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, and carrying the one next action. |
24
- | `status` | [`schemas/status.schema.json`](schemas/status.schema.json) | Where the project is and what happens next — the single machine-readable truth every human rendering derives from. Surfaces faults whose content changed since they were probed. |
24
+ | `status` | [`schemas/status.schema.json`](schemas/status.schema.json) | Where the project is and what happens next — the single machine-readable truth every human rendering derives from. Surfaces faults whose content changed since they were probed, and changed files that carry no claim. |
25
+ | `gate` | [`schemas/gate.schema.json`](schemas/gate.schema.json) | Claim coverage of one change: which changed files carry a claim, which are excused (and by which ignore entry), which are unclaimed. The delta gate for code that has no claim yet. |
25
26
 
26
27
  Shared definitions (verdicts, fault classes, provenance, annotations) live in
27
28
  [`schemas/common.schema.json`](schemas/common.schema.json). Gate behaviour —
@@ -6,7 +6,7 @@ import { fingerprint } from './fingerprint.mjs';
6
6
 
7
7
  const schemaDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'schemas');
8
8
 
9
- export const KINDS = Object.freeze(['claims', 'evidence', 'baseline', 'ignore', 'calibration', 'brief', 'status']);
9
+ export const KINDS = Object.freeze(['claims', 'evidence', 'baseline', 'ignore', 'calibration', 'brief', 'status', 'gate']);
10
10
  export const PASSING_VERDICTS = Object.freeze(new Set(['killed']));
11
11
 
12
12
  const ajv = new Ajv2020({ strict: true, allErrors: true });
@@ -99,8 +99,26 @@ const semantic = {
99
99
  status(doc) {
100
100
  const errors = [];
101
101
  if (['write-test', 'review-fault-change'].includes(doc.next.action) && !doc.next.target) errors.push({ path: '/next/target', message: `${doc.next.action} requires a target` });
102
+ if (doc.next.action === 'claim' && !doc.next.file) errors.push({ path: '/next/file', message: 'claim requires the file the claim is about' });
102
103
  if (doc.state === 'no-claims' && doc.counts.claims !== 0) errors.push({ path: '/counts/claims', message: 'no-claims with a non-zero claim count' });
103
104
  if (doc.state === 'clean' && (doc.counts.new ?? 0) > 0) errors.push({ path: '/state', message: 'clean with new findings' });
105
+ if (doc.state === 'unclaimed-changes' && !(doc.changes && doc.changes.uncovered.length > 0)) errors.push({ path: '/changes/uncovered', message: 'unclaimed-changes requires at least one uncovered changed file' });
106
+ if (doc.state === 'unclaimed-changes' && doc.next.action !== 'claim') errors.push({ path: '/next/action', message: 'unclaimed-changes requires next.action = claim' });
107
+ if (doc.changes && doc.changes.uncovered.length > 0 && !['no-claims', 'unclaimed-changes'].includes(doc.state)) errors.push({ path: '/state', message: 'uncovered changed files are hidden behind a later state; unclaimed-changes precedes every evidence state' });
108
+ return errors;
109
+ },
110
+
111
+ gate(doc) {
112
+ const errors = [];
113
+ if (doc.evaluated !== doc.covered.length + doc.uncovered.length) errors.push({ path: '/evaluated', message: `evaluated (${doc.evaluated}) must equal covered (${doc.covered.length}) + uncovered (${doc.uncovered.length})` });
114
+ if (doc.changed !== doc.evaluated + doc.excluded.length) errors.push({ path: '/changed', message: `changed (${doc.changed}) must equal evaluated (${doc.evaluated}) + excluded (${doc.excluded.length})` });
115
+ if (doc.uncovered.length > 0 && doc.exitCode !== 1) errors.push({ path: '/exitCode', message: 'an uncovered changed file must exit 1; the gate never passes over unclaimed code' });
116
+ if (doc.uncovered.length === 0 && doc.exitCode === 1 && !(doc.strict && doc.changed > 0 && doc.evaluated === 0)) errors.push({ path: '/exitCode', message: 'exit 1 without uncovered files is only valid under --strict when a non-empty change evaluated nothing' });
117
+ doc.covered.forEach((c, i) => {
118
+ if (c.by === 'ignore' && !c.pattern) errors.push({ path: `/covered/${i}/pattern`, message: 'a file covered by an ignore entry must name the pattern that excused it' });
119
+ if (c.by !== 'ignore' && !c.claimIds?.length) errors.push({ path: `/covered/${i}/claimIds`, message: `a file covered by a ${c.by} must name the claim(s)` });
120
+ });
121
+ for (const e of doc.expired) if (!e.expires) errors.push({ path: '/expired', message: `expired entry "${e.pattern}" has no expires instant` });
104
122
  return errors;
105
123
  },
106
124
 
@@ -39,12 +39,22 @@
39
39
  "type": "object",
40
40
  "required": ["action", "command", "why"],
41
41
  "properties": {
42
- "action": { "enum": ["scaffold", "probe", "write-test", "baseline", "review-fault-change", "none"] },
42
+ "action": { "enum": ["scaffold", "claim", "probe", "write-test", "baseline", "review-fault-change", "none"] },
43
43
  "command": { "type": "string", "minLength": 1, "maxLength": 1024 },
44
44
  "why": { "type": "string", "minLength": 1, "maxLength": 2000 }
45
45
  },
46
46
  "additionalProperties": false
47
47
  },
48
+ "unclaimed": {
49
+ "description": "Changed files that carry no claim, when the brief was built with a change reference. Rendered before the findings: an agent writes the claim before more code.",
50
+ "type": "object",
51
+ "required": ["ref", "files"],
52
+ "properties": {
53
+ "ref": { "type": "string", "minLength": 1, "maxLength": 256 },
54
+ "files": { "type": "array", "minItems": 1, "items": { "$ref": "urn:guard-spec:v1:common#/$defs/uncoveredFile" } }
55
+ },
56
+ "additionalProperties": false
57
+ },
48
58
  "items": {
49
59
  "type": "array",
50
60
  "maxItems": 50,
@@ -77,6 +77,33 @@
77
77
  },
78
78
  "additionalProperties": false
79
79
  },
80
+ "uncoveredFile": {
81
+ "description": "A changed source or test file that no claim covers and no unexpired ignore entry excuses. Shared by the gate and status documents.",
82
+ "type": "object",
83
+ "required": ["file", "kind", "suggestion"],
84
+ "properties": {
85
+ "file": { "$ref": "#/$defs/repoPath" },
86
+ "kind": { "enum": ["source", "test"] },
87
+ "nearestClaimId": {
88
+ "description": "The claim whose fault files share the longest directory prefix with this file — where to attach, not a verdict.",
89
+ "$ref": "#/$defs/identifier"
90
+ },
91
+ "suggestion": { "type": "string", "minLength": 1, "maxLength": 1024 }
92
+ },
93
+ "additionalProperties": false
94
+ },
95
+ "ignoreReliance": {
96
+ "description": "An ignore entry the gate relied on (or would have, had it not expired), with the changed files it matched.",
97
+ "type": "object",
98
+ "required": ["pattern", "reason", "files"],
99
+ "properties": {
100
+ "pattern": { "type": "string", "minLength": 1, "maxLength": 512 },
101
+ "reason": { "type": "string", "minLength": 8, "maxLength": 2000 },
102
+ "expires": { "$ref": "#/$defs/isoDateTime" },
103
+ "files": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/repoPath" } }
104
+ },
105
+ "additionalProperties": false
106
+ },
80
107
  "annotation": {
81
108
  "description": "Strictly additive. An annotation never changes, suppresses, or drops a finding; it only attaches context to it.",
82
109
  "type": "object",
@@ -0,0 +1,52 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:guard-spec:v1:gate",
4
+ "title": "Guard spec v1 — change gate",
5
+ "description": "Claim coverage of one change. `probe` asks whether the tests defend the claims that exist; `gate` asks whether the files a change touched carry any claim at all. Measured per file against the merge-base of a reference and HEAD (or the working tree). A file is covered when a fault anchors to it, when it is a test file that defends a claim, or when an unexpired ignore entry excuses it — and every reliance on an ignore entry is listed, so a reviewer sees why the gate passed.",
6
+ "type": "object",
7
+ "required": ["schemaVersion", "tool", "generatedAt", "ref", "base", "head", "includeDirty", "strict", "changed", "evaluated", "excluded", "covered", "uncovered", "reliedOn", "expired", "exitCode"],
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
+ "ref": { "description": "The reference the change was measured against, as given (a branch, a tag, a sha).", "type": "string", "minLength": 1, "maxLength": 256 },
14
+ "base": { "description": "merge-base(ref, HEAD): the commit the change is relative to.", "$ref": "urn:guard-spec:v1:common#/$defs/gitSha" },
15
+ "head": { "$ref": "urn:guard-spec:v1:common#/$defs/gitSha" },
16
+ "includeDirty": { "description": "True when the working tree was compared instead of HEAD.", "type": "boolean" },
17
+ "strict": { "description": "True when a non-empty change with zero evaluated files fails instead of passing with a note.", "type": "boolean" },
18
+ "changed": { "description": "Files changed inside the project directory, deletions excluded.", "type": "integer", "minimum": 0 },
19
+ "evaluated": { "description": "covered + uncovered: the changed files that are source and not excluded.", "type": "integer", "minimum": 0 },
20
+ "excluded": {
21
+ "type": "array",
22
+ "items": {
23
+ "type": "object",
24
+ "required": ["file", "by"],
25
+ "properties": {
26
+ "file": { "$ref": "urn:guard-spec:v1:common#/$defs/repoPath" },
27
+ "by": { "description": "`non-source`, `default:<glob>` or `exclude:<glob>`.", "type": "string", "minLength": 1, "maxLength": 600 }
28
+ },
29
+ "additionalProperties": false
30
+ }
31
+ },
32
+ "covered": {
33
+ "type": "array",
34
+ "items": {
35
+ "type": "object",
36
+ "required": ["file", "by"],
37
+ "properties": {
38
+ "file": { "$ref": "urn:guard-spec:v1:common#/$defs/repoPath" },
39
+ "by": { "enum": ["fault", "defender", "ignore"] },
40
+ "claimIds": { "type": "array", "minItems": 1, "items": { "$ref": "urn:guard-spec:v1:common#/$defs/identifier" } },
41
+ "pattern": { "type": "string", "minLength": 1, "maxLength": 512 }
42
+ },
43
+ "additionalProperties": false
44
+ }
45
+ },
46
+ "uncovered": { "type": "array", "items": { "$ref": "urn:guard-spec:v1:common#/$defs/uncoveredFile" } },
47
+ "reliedOn": { "description": "Unexpired ignore entries that excused at least one changed file. The gate passed *because of* these; a reviewer reads them.", "type": "array", "items": { "$ref": "urn:guard-spec:v1:common#/$defs/ignoreReliance" } },
48
+ "expired": { "description": "Expired ignore entries that would have matched a changed file. They excuse nothing; the files are reported as uncovered.", "type": "array", "items": { "$ref": "urn:guard-spec:v1:common#/$defs/ignoreReliance" } },
49
+ "exitCode": { "enum": [0, 1] }
50
+ },
51
+ "additionalProperties": false
52
+ }
@@ -11,17 +11,21 @@
11
11
  "tool": { "$ref": "urn:guard-spec:v1:common#/$defs/tool" },
12
12
  "generatedAt": { "$ref": "urn:guard-spec:v1:common#/$defs/isoDateTime" },
13
13
  "state": {
14
- "description": "no-claims: no claims file. unprobed: claims but no confirmed evidence. evidence-stale: evidence exists but a claim, fault, target or defender changed since. provisional-only: only sub-N evidence exists. unproven: current evidence has findings not covered by the baseline. clean: every finding is killed or baselined.",
15
- "enum": ["no-claims", "unprobed", "evidence-stale", "provisional-only", "unproven", "clean"]
14
+ "description": "no-claims: no claims file. unclaimed-changes: the change measured against a reference touches source files that carry no claim and no excusing ignore entry (only computed when a reference is known; it precedes every evidence state because the claim is written before more code). unprobed: claims but no confirmed evidence. evidence-stale: evidence exists but a claim, fault, target or defender changed since. provisional-only: only sub-N evidence exists. unproven: current evidence has findings not covered by the baseline. clean: every finding is killed or baselined.",
15
+ "enum": ["no-claims", "unclaimed-changes", "unprobed", "evidence-stale", "provisional-only", "unproven", "clean"]
16
16
  },
17
17
  "next": {
18
- "description": "The one action that moves the project forward. `command` is a shell line when the action is a tool command, or an instruction when it is not (write-test).",
18
+ "description": "The one action that moves the project forward. `command` is a shell line when the action is a tool command, or an instruction when it is not (write-test, claim for a test file).",
19
19
  "type": "object",
20
20
  "required": ["action", "command", "why"],
21
21
  "properties": {
22
- "action": { "enum": ["scaffold", "probe", "write-test", "baseline", "review-fault-change", "none"] },
22
+ "action": { "enum": ["scaffold", "claim", "probe", "write-test", "baseline", "review-fault-change", "none"] },
23
23
  "command": { "type": "string", "minLength": 1, "maxLength": 1024 },
24
24
  "why": { "type": "string", "minLength": 1, "maxLength": 2000 },
25
+ "file": {
26
+ "description": "For claim (and for scaffold when a change is known): the first uncovered changed file the action is about.",
27
+ "$ref": "urn:guard-spec:v1:common#/$defs/repoPath"
28
+ },
25
29
  "target": {
26
30
  "description": "For write-test / review-fault-change: the finding the action is about.",
27
31
  "type": "object",
@@ -95,10 +99,28 @@
95
99
  "additionalProperties": false
96
100
  }
97
101
  },
102
+ "changes": {
103
+ "description": "Claim coverage of the change since `ref`, present only when a reference was given (--changed) or detected from CI. Uncovered files come first in every rendering: the claim is written before more code. Same per-file shapes as the gate document.",
104
+ "type": "object",
105
+ "required": ["ref", "base", "changed", "evaluated", "excluded", "uncovered", "reliedOn", "expired"],
106
+ "properties": {
107
+ "ref": { "type": "string", "minLength": 1, "maxLength": 256 },
108
+ "base": { "$ref": "urn:guard-spec:v1:common#/$defs/gitSha" },
109
+ "includeDirty": { "type": "boolean" },
110
+ "changed": { "type": "integer", "minimum": 0 },
111
+ "evaluated": { "type": "integer", "minimum": 0 },
112
+ "excluded": { "type": "integer", "minimum": 0 },
113
+ "uncovered": { "type": "array", "items": { "$ref": "urn:guard-spec:v1:common#/$defs/uncoveredFile" } },
114
+ "reliedOn": { "type": "array", "items": { "$ref": "urn:guard-spec:v1:common#/$defs/ignoreReliance" } },
115
+ "expired": { "type": "array", "items": { "$ref": "urn:guard-spec:v1:common#/$defs/ignoreReliance" } }
116
+ },
117
+ "additionalProperties": false
118
+ },
98
119
  "paths": {
99
120
  "type": "object",
100
121
  "properties": {
101
122
  "claims": { "type": "string" },
123
+ "ignore": { "type": "string" },
102
124
  "evidence": { "type": "string" },
103
125
  "provisionalEvidence": { "type": "string" },
104
126
  "baseline": { "type": "string" }
@@ -31,7 +31,7 @@ function orderItems(a, b) {
31
31
  return (b.isNew - a.isNew) || (ORDER.indexOf(a.verdict) - ORDER.indexOf(b.verdict)) || ((b.rank ?? 0) - (a.rank ?? 0));
32
32
  }
33
33
 
34
- export function buildBrief(evidence, baseline, { max = 20, generatedAt = new Date().toISOString(), next } = {}) {
34
+ export function buildBrief(evidence, baseline, { max = 20, generatedAt = new Date().toISOString(), next, changes } = {}) {
35
35
  const g = gate(evidence.records, baseline);
36
36
  const toItem = (r, isNew) => ({
37
37
  fingerprint: r.fingerprint,
@@ -54,7 +54,8 @@ export function buildBrief(evidence, baseline, { max = 20, generatedAt = new Dat
54
54
  new: g.new.length + g.belowFloor.length,
55
55
  baselined: g.baselined.length,
56
56
  };
57
- const doc = { schemaVersion: 1, tool: evidence.tool, generatedAt, head: evidence.run.repo.head, heading: HEADING, summary, ...(next ? { next: { action: next.action, command: next.command, why: next.why } } : {}), items, text: '' };
57
+ const unclaimed = changes?.uncovered?.length ? { ref: changes.ref, files: changes.uncovered } : undefined;
58
+ const doc = { schemaVersion: 1, tool: evidence.tool, generatedAt, head: evidence.run.repo.head, heading: HEADING, summary, ...(next ? { next: { action: next.action, command: next.command, why: next.why } } : {}), ...(unclaimed ? { unclaimed } : {}), items, text: '' };
58
59
  doc.text = renderBriefText({ ...doc, provisional: Boolean(evidence.run.provisional) }, { hasBaseline: Boolean(baseline), total: evidence.records.length });
59
60
  return doc;
60
61
  }
@@ -68,6 +69,13 @@ export function renderBriefText(brief, { hasBaseline, total }) {
68
69
  `testguard ${brief.tool.version}${brief.head ? ` @ ${brief.head.slice(0, 12)}` : ''} — ${brief.summary.claims} claims, ${total} faults probed, ${unproven} unproven` +
69
70
  (unproven === 0 ? '.' : hasBaseline ? ` (${brief.summary.new} new since baseline).` : ' (no baseline; everything is new).'),
70
71
  ];
72
+ // Unclaimed changes come before everything else: the claim is written
73
+ // before more code, and TestGuard is silent about unclaimed code otherwise.
74
+ if (brief.unclaimed) {
75
+ const n = brief.unclaimed.files.length;
76
+ lines.push('', `UNCLAIMED CHANGES since ${brief.unclaimed.ref}: ${n} changed file${n === 1 ? '' : 's'} carr${n === 1 ? 'ies' : 'y'} no claim. State the claim first; nothing below can see this code.`);
77
+ for (const u of brief.unclaimed.files) lines.push(` - ${u.file} (${u.kind}) → ${u.suggestion}`);
78
+ }
71
79
  if (brief.next) lines.push('', `NEXT [${brief.next.action}]: ${brief.next.command}`, ` why: ${brief.next.why}`);
72
80
  if (brief.items.length === 0) {
73
81
  lines.push('', 'Every probed claim is defended. Keep it that way: new claims need a fault and a test that fails on it.');
@@ -87,3 +95,19 @@ export function renderBriefText(brief, { hasBaseline, total }) {
87
95
  if (unproven > brief.items.length) lines.push('', `… and ${unproven - brief.items.length} more in the evidence file.`);
88
96
  return lines.join('\n') + '\n';
89
97
  }
98
+
99
+ /**
100
+ * A brief for a project that has unclaimed changes but no evidence yet. The
101
+ * session-start hook must still tell the agent to write the claim; an empty
102
+ * summary is honest, silence is not.
103
+ */
104
+ export function buildUnclaimedBrief({ tool, next, changes, generatedAt = new Date().toISOString() }) {
105
+ const summary = { claims: 0, byVerdict: {}, new: 0, baselined: 0 };
106
+ const doc = { schemaVersion: 1, tool, generatedAt, heading: HEADING, summary, ...(next ? { next: { action: next.action, command: next.command, why: next.why } } : {}), unclaimed: { ref: changes.ref, files: changes.uncovered }, items: [], text: '' };
107
+ const lines = [HEADING, '', `testguard ${tool.version} — no evidence yet; ${changes.uncovered.length} unclaimed changed file${changes.uncovered.length === 1 ? '' : 's'} since ${changes.ref}.`];
108
+ lines.push('', `UNCLAIMED CHANGES since ${changes.ref}: state the claim first; nothing can be probed for this code until it has one.`);
109
+ for (const u of changes.uncovered) lines.push(` - ${u.file} (${u.kind}) → ${u.suggestion}`);
110
+ if (next) lines.push('', `NEXT [${next.action}]: ${next.command}`, ` why: ${next.why}`);
111
+ doc.text = lines.join('\n') + '\n';
112
+ return doc;
113
+ }
package/src/cli.mjs CHANGED
@@ -13,6 +13,7 @@ import { briefCommand } from './commands/brief.mjs';
13
13
  import { scaffoldCommand } from './commands/scaffold.mjs';
14
14
  import { statusCommand } from './commands/status.mjs';
15
15
  import { initCommand } from './commands/init.mjs';
16
+ import { gateCommand } from './commands/gate.mjs';
16
17
 
17
18
  const VERSION = JSON.parse(readFileSync(join(fileURLToPath(import.meta.url), '..', '..', 'package.json'), 'utf8')).version;
18
19
 
@@ -24,6 +25,7 @@ const USAGE = `testguard ${VERSION} — proves a test suite defends the claims a
24
25
  testguard probe [dir] inject each claim's faults, run its defenders, report what survived
25
26
  testguard baseline [dir] freeze today's unproven findings so only new ones gate
26
27
  testguard brief [dir] emit the blind-spot block for an agent's session-start context
28
+ testguard gate [dir] fail when a changed source file carries no claim and no excusing ignore entry
27
29
  testguard scaffold <file> propose faults mechanically for one source file, as a draft claims document
28
30
 
29
31
  probe
@@ -38,6 +40,7 @@ probe
38
40
  --include-dirty probe the working tree (a snapshot commit) instead of HEAD; uncommitted tests count
39
41
  --verbose also print each killed fault (default: only unproven ones, plus a count)
40
42
  --confirm below 3 is PROVISIONAL: verdicts print with "?", evidence goes to .testguard/evidence-provisional.json
43
+ --runner <name> vitest | jest | auto (default: auto — first of vitest, jest that resolves)
41
44
  --runner-cmd "<cmd>" custom runner; must contain {files} and {out}, e.g. "pnpm vitest run {files} --reporter=json --outputFile={out}"
42
45
  --node-modules <dir> node_modules to link into the scratch worktree (or TESTGUARD_NODE_MODULES)
43
46
  --in-place mutate the working tree instead of a scratch worktree
@@ -48,17 +51,24 @@ probe
48
51
  scaffold --claim <ID> (put every proposal under this claim; copies it if it exists) --out <path> --json
49
52
  shapes: if-guard → if (false) · single-line guard/mutation removed · return <check> → return true
50
53
  · security flag/window/cost literal weakened · verify/validate/check call removed
51
- status --json (exit 0 clean · 1 unproven/stale · 2 nothing to probe yet)
54
+ gate --changed <ref> measure the change since merge-base(ref, HEAD); auto-detected in GitHub Actions / GitLab CI
55
+ --include-dirty compare the working tree (staged, unstaged and untracked) instead of HEAD — the pre-commit shape
56
+ --exclude <glob> (repeatable) more files that never carry claims; --explain lists the defaults
57
+ --strict a non-empty change that evaluates nothing is a failure, not a note
58
+ --ignore <path> ignore file (default: <dir>/testguard.ignore.json; kind=path entries excuse files, with a reason)
59
+ exit 0 every changed source file is claimed or excused · 1 unclaimed file · 2 cannot evaluate · 3 no reference
60
+ status --json (exit 0 clean · 1 unproven/stale/unclaimed · 2 nothing to probe yet)
61
+ --changed <ref> also compute claim coverage of the change; unclaimed-changes then precedes every evidence state
52
62
  init --force (replace an existing skill file) --json
53
63
  every command accepts --json; probe/baseline emit the status document plus their own result
54
64
  claims --json
55
65
  baseline --evidence <path> --out <path> --allow-provisional (freeze unconfirmed evidence; normally refused)
56
66
  brief --evidence <path> --baseline <path> --max <n> --text (print only; safe for hooks)
57
67
 
58
- exit codes: 0 nothing new to prove · 1 unproven claims (or claim drift) · 2 precondition failed · 3 usage
68
+ exit codes: 0 nothing new to prove · 1 unproven claims (or claim drift, or unclaimed changes) · 2 precondition failed · 3 usage
59
69
  `;
60
70
 
61
- const COMMANDS = { probe: probeCommand, claims: claimsCommand, baseline: baselineCommand, brief: briefCommand, scaffold: scaffoldCommand, status: statusCommand, init: initCommand };
71
+ const COMMANDS = { probe: probeCommand, claims: claimsCommand, baseline: baselineCommand, brief: briefCommand, scaffold: scaffoldCommand, status: statusCommand, init: initCommand, gate: gateCommand };
62
72
 
63
73
  export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n'), err: (s) => process.stderr.write(s + '\n') }) {
64
74
  let parsed;
@@ -78,10 +88,16 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
78
88
  ref: { type: 'string', default: 'HEAD' },
79
89
  claim: { type: 'string' },
80
90
  'include-dirty': { type: 'boolean', default: false },
91
+ changed: { type: 'string' },
92
+ exclude: { type: 'string', multiple: true },
93
+ strict: { type: 'boolean', default: false },
94
+ explain: { type: 'boolean', default: false },
95
+ ignore: { type: 'string' },
81
96
  'allow-provisional': { type: 'boolean', default: false },
82
97
  force: { type: 'boolean', default: false },
83
98
  verbose: { type: 'boolean', default: false },
84
99
  'runner-cmd': { type: 'string' },
100
+ runner: { type: 'string', default: 'auto' },
85
101
  'node-modules': { type: 'string' },
86
102
  'in-place': { type: 'boolean', default: false },
87
103
  'no-escalate': { type: 'boolean', default: false },
@@ -114,6 +130,10 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
114
130
  io.err(USAGE);
115
131
  return 3;
116
132
  }
133
+ if (!['vitest', 'jest', 'auto'].includes(values.runner)) {
134
+ io.err('--runner must be vitest, jest or auto');
135
+ return 3;
136
+ }
117
137
  if (!['critical', 'high', 'medium', 'low'].includes(values.severity)) {
118
138
  io.err('--severity must be one of critical, high, medium, low');
119
139
  return 3;