testguard-cli 0.18.3 → 0.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testguard-cli",
3
- "version": "0.18.3",
3
+ "version": "0.20.0",
4
4
  "description": "Breaks your code on purpose and reports every promise your tests did not notice breaking. Mutation testing bound to the claims a project makes — vitest, jest, Playwright, pytest and unittest.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,6 +16,24 @@ These are declarations, not authentication of independence, chronology,
16
16
  production occurrence or correctness. The optional reference remains opaque
17
17
  metadata (at most 512 characters); verification does not fetch it. Producer
18
18
  provenance and measured test independence are separate signals.
19
+
20
+ One prefix is reserved. A `ref` that starts with `req:` is a structured
21
+ requirement reference, `req:<spec-id>#<requirement-id>`, with `<spec-id>`
22
+ matching `[A-Za-z0-9][A-Za-z0-9._-]{0,127}` and `<requirement-id>` matching
23
+ `[A-Za-z][A-Za-z0-9._-]{0,63}`. It must be declared with kind `spec`.
24
+ Validation rejects a malformed one at `/source/ref` and one under another kind
25
+ at `/source/kind`; it is never degraded to opaque, because a typo would
26
+ otherwise read as "no evidence for this requirement". Every other `ref` stays
27
+ opaque. A structured reference is still a declaration. TestGuard does not
28
+ resolve it, fetch the spec or check that the requirement exists. A consumer
29
+ that keeps a requirement index, such as DocGuard, compares
30
+ `<spec-id>#<requirement-id>` exactly against its own keys. `requirementRef()`
31
+ in `spec/lib/origins.mjs` parses the same pattern the shared
32
+ `$defs/claimSource` declares. Older readers accept the form as an opaque string,
33
+ so adopting it needs no upgrade; only readers with this rule catch a malformed
34
+ one. The reference is claim metadata: editing it makes saved evidence for that
35
+ claim stale, as any source edit does. Fingerprints, verdicts and gates do not
36
+ change.
19
37
  Adding kinds does not change verdicts, baseline/fingerprint identity or default
20
38
  gates. Existing ranking weights stay unchanged; new kinds use the existing 0.9
21
39
  fallback, not an invented strength hierarchy. Existing documents remain valid
@@ -254,7 +272,12 @@ Rules that follow from the table:
254
272
  decided and leave a caller with an exit code this document does not define,
255
273
  which is strictly worse than one loud unverifiable claim. A `probe-error`
256
274
  record is never reused by a later run: it says something about the run, not
257
- about the code. The one exception is a **precondition** failure — the
275
+ about the code. The same holds for a record whose reason is
276
+ `defenders-failed-to-load`: a broken `--runner-cmd`, a missing dependency or
277
+ an interpreter without the test requirements changes none of the hashed
278
+ inputs, so fixing the environment would otherwise leave the failure
279
+ reported as the current answer. Re-measuring it costs one baseline run per
280
+ defender set. The one exception is a **precondition** failure — the
258
281
  interpreter loading the source from outside the probed tree, a runner that
259
282
  does not resolve — which is a statement about every verdict in the run and
260
283
  still refuses it outright.
@@ -306,6 +329,11 @@ Rules that follow from the table:
306
329
  files; creating or editing any of them forces remeasurement. Evidence that
307
330
  predates this hash, or whose hash
308
331
  changed, is readable but never reusable for a current native discovery run.
332
+ A custom `--runner-cmd` has no native listing; its `testUniverseHash` binds
333
+ the static file set **and the parsed command argv**, so a changed command
334
+ is a changed universe and never reuses a verdict the old command measured.
335
+ Reformatting whitespace that parses to the same argv is not a change. Only
336
+ the hash is recorded, never the command text.
309
337
  When several configured runners list defenders, exact manifest membership
310
338
  assigns each file. An owned runner takes precedence over the project runner;
311
339
  competing owned runners or a declared defender absent from every manifest
@@ -578,12 +606,42 @@ Adopting tools may reconcile an existing baseline format (for example a
578
606
  `{version, fingerprints:{hash:count}}` file) by mapping it onto this shape;
579
607
  the suppress-up-to-count semantics are identical.
580
608
 
609
+ A baseline command's machine output is the status document plus an optional
610
+ `baseline` result, exactly as a probe's is the status document plus `run`:
611
+ `{path, frozen}` when it froze one (`frozen` counts the unproven findings it
612
+ holds), `{path, restamped}` when it moved an existing baseline's head (the
613
+ commit it now names). The document is validated before it is printed. One
614
+ document carries at most one invocation result, never both `run` and
615
+ `baseline`.
616
+
581
617
  ## Ignore and annotations
582
618
 
583
- - An ignore entry removes a subject from *scope* before probing. Every entry
619
+ - An ignore entry excuses a *coverage* or *removal* finding; it never
620
+ removes anything from a probe and never touches a verdict. A probe reads no
621
+ ignore file: a claim that is in the claims file is probed, so an excuse can
622
+ never hide a survivor. A known finding is accepted in a baseline (above),
623
+ which is fingerprinted, counted and reported as baselined. Every entry
584
624
  carries a `reason` of at least eight characters; the structured form is what
585
- an auditor reads. A plain gitignore-syntax file may be accepted as shorthand
586
- for reasonless `path` entries.
625
+ an auditor reads. An entry past its `expires` excuses nothing and is
626
+ reported as expired. The kinds, and the only thing each does:
627
+ - `path` — a glob over project paths. Excuses a changed source file from
628
+ the change gate (below); every reliance is reported.
629
+ - `claim` — exactly one claim id. Excuses the removal of that claim, and of
630
+ any of its faults, when claims are compared against an earlier reference.
631
+ - `fault` — exactly `<claimId>/<faultId>`. Excuses the removal of that one
632
+ fault in the same comparison.
633
+
634
+ There is no fingerprint kind: suppressing a finding by fingerprint is what a
635
+ baseline does, and a second, uncounted channel for it would let debt be
636
+ accepted where no baseline report shows it. A `claim` or `fault` pattern
637
+ that is not an exact identity is invalid, because it would excuse nothing —
638
+ or, for a `fault` entry without its fault id, the whole claim. A plain
639
+ gitignore-syntax file may be accepted as shorthand for reasonless `path`
640
+ entries.
641
+ - A concerns file is read like every other spec document: one that does not
642
+ parse or does not conform fails the command that reads it (exit `2`). It
643
+ never degrades to a concern that matches nothing — a sweep aimed at nothing
644
+ reports clean.
587
645
  - An annotation is **strictly additive**. It never changes, suppresses, or
588
646
  drops a finding. Ranking may read annotations; verdicts never do.
589
647
  - A **signal** is a static, annotation-grade fact about a defender, recorded
@@ -792,6 +850,25 @@ evidence; they simply do not turn CI red.
792
850
 
793
851
  ## Exit codes
794
852
 
853
+ | Code | Meaning |
854
+ |---|---|
855
+ | `0` | No new gating findings at or above the severity floor. |
856
+ | `1` | At least one new gating finding. |
857
+ | `2` | Precondition failed: test runner not resolvable (e.g. no `node_modules` linked into the scratch worktree), working tree dirty for a fault target file, claims file invalid, no commits. Nothing was probed. |
858
+ | `3` | Usage or configuration error. |
859
+
860
+ A tool must never exit `0` because it had nothing to check. If the claims
861
+ file is empty or every claim is out of scope, that is reported explicitly and
862
+ the exit code is `2`.
863
+
864
+ `probe --allow-empty` is an explicit adoption exception for a valid claims
865
+ file containing zero claims, with no `--claim` selection. It exits `0`, says
866
+ that verification was skipped, and writes no evidence or baseline. Its JSON
867
+ output is the existing `no-claims` status document, without run metadata;
868
+ it never reports a clean measurement. Missing or invalid claims still fail,
869
+ and selected claims cannot be silently skipped. Run `gate` separately to
870
+ enforce coverage of changed files during adoption. Integrations must opt in.
871
+
795
872
  ### Explicit declared-origin policy result contract
796
873
 
797
874
  Optional `originPolicy` on evidence, status and brief (and status's probe `run`)
@@ -803,7 +880,8 @@ It requires a complete current-project run with confirm >= 3 and refuses
803
880
  before loading or writing. No policy is inferred when the option is absent.
804
881
  After measurement, the command reloads current claims and collects the current
805
882
  native runner universe under the same command budget, then binds targets,
806
- resolved defenders and current discovery dependencies. Missing bindings or
883
+ resolved defenders, current discovery dependencies and the recorded runner
884
+ identities (name, version and source). Missing bindings or
807
885
  discovery failure yields unavailable; custom/static runner universes cannot
808
886
  certify this boundary. Expired command budgets still write no partial result.
809
887
  This is point-in-time admission, not an atomic filesystem snapshot. Stored
@@ -834,25 +912,6 @@ Completed probe JSON with unavailable policy exits 2; failed policy or ordinary
834
912
  new findings exits 1; passed policy without ordinary findings exits 0.
835
913
  Partial/provisional invocations cannot carry an origin policy.
836
914
 
837
- | Code | Meaning |
838
- |---|---|
839
- | `0` | No new gating findings at or above the severity floor. |
840
- | `1` | At least one new gating finding. |
841
- | `2` | Precondition failed: test runner not resolvable (e.g. no `node_modules` linked into the scratch worktree), working tree dirty for a fault target file, claims file invalid, no commits. Nothing was probed. |
842
- | `3` | Usage or configuration error. |
843
-
844
- A tool must never exit `0` because it had nothing to check. If the claims
845
- file is empty or every claim is out of scope, that is reported explicitly and
846
- the exit code is `2`.
847
-
848
- `probe --allow-empty` is an explicit adoption exception for a valid claims
849
- file containing zero claims, with no `--claim` selection. It exits `0`, says
850
- that verification was skipped, and writes no evidence or baseline. Its JSON
851
- output is the existing `no-claims` status document, without run metadata;
852
- it never reports a clean measurement. Missing or invalid claims still fail,
853
- and selected claims cannot be silently skipped. Run `gate` separately to
854
- enforce coverage of changed files during adoption. Integrations must opt in.
855
-
856
915
  ## Read-only authoring input (candidate)
857
916
 
858
917
  `authoring-input` is metadata-only inspection, never claims or evidence. Its
@@ -916,6 +975,13 @@ worker ceiling and must be one for serial execution; custom commands omit it.
916
975
  Native evidence reuse requires the same recorded worker ceiling and serial policy.
917
976
  Older native evidence with no worker ceiling is remeasured; native and opaque
918
977
  custom-command policies are not interchangeable.
978
+ Every reuse also requires the same recorded `run.runner`: name (the engine that
979
+ ran, so pytest and unittest differ), version and `source`. `source` is recorded
980
+ for every resolved runner — `project` (the project's package, its virtualenv,
981
+ or an interpreter named by path), `path` (an executable found on PATH,
982
+ including a bare command name given to `--python`) or `builtin` — and is absent
983
+ only for a custom command, which resolves nothing. Evidence that recorded no
984
+ runner, or a different one, is remeasured.
919
985
  `status.cost.measurements` copies these measurements when available. Historical
920
986
  `totalMs` and cost gates retain their existing record attribution, including
921
987
  reused durations and repeated shared baseline durations. Older evidence has no
package/spec/README.md CHANGED
@@ -34,14 +34,16 @@ that carry claims, baselines, scoping and briefs do not care.
34
34
  | `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. |
35
35
  | `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. |
36
36
  | `baseline` | [`schemas/baseline.schema.json`](schemas/baseline.schema.json) | Frozen fingerprints of existing debt. Gate only what is new. |
37
- | `ignore` | [`schemas/ignore.schema.json`](schemas/ignore.schema.json) | Reviewable scoping. Every entry has a reason. |
37
+ | `ignore` | [`schemas/ignore.schema.json`](schemas/ignore.schema.json) | Reviewable excuses: a `path` entry excuses a changed file from the change gate, a `claim` or `fault` entry excuses a removal. Every entry has a reason; none removes a claim from a probe or touches a verdict — accepted findings belong in a baseline. |
38
38
  | `calibration` | [`schemas/calibration.schema.json`](schemas/calibration.schema.json) | The observed rate of a bucket's outcome — what it measures is in the document — with a Wilson interval and the sample size behind it. Every number is recomputable and the validator recomputes it; provenance, a producer floor, backoff tiers and a labelled fallback ride along so a second tool's honesty survives translation. |
39
39
  | `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. |
40
40
  | `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. |
41
41
  | `replay` | [`schemas/replay.schema.json`](schemas/replay.schema.json) | Would this suite have caught the bugs that already escaped? Replays real fix commits: revert the source, remove the test the fix shipped, run what remains. A replayed bug is ground truth — a human already confirmed it was a defect — which is what a fault model is calibrated against. |
42
42
  | `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. |
43
43
  | `annotations` | [`schemas/annotations.schema.json`](schemas/annotations.schema.json) | Explicit file-header authoring preview/apply, refusals and recoverable partial failures. Never verification, a probe verdict, or authentication of intent; internal source contents are excluded. |
44
- | `authoring-input` | [`schemas/authoring-input.schema.json`](schemas/authoring-input.schema.json) | Candidate metadata-only document/fix input handoff. No source text, origin assignment, claims or verification; historical partitions retain excluded/deleted/unsupported counts. CLI exposure remains pending. |
44
+ | `authoring-input` | [`schemas/authoring-input.schema.json`](schemas/authoring-input.schema.json) | Candidate metadata-only document/fix input handoff. No source text, origin assignment, claims or verification; historical partitions retain excluded/deleted/unsupported counts. Emitted by `scaffold --from-document` and `scaffold --from-fix`. |
45
+ | `concerns` | [`schemas/concerns.schema.json`](schemas/concerns.schema.json) | A named kind of promise and where to look for it: a statement, target globs and the fault classes that are relevant. Aims a sweep; never becomes a claim by itself. |
46
+ | `sweep` | [`schemas/sweep.schema.json`](schemas/sweep.schema.json) | Proposed faults for unclaimed changed files (or a concern, or the storage-write surface), the bounded selection probed, and what no test noticed. Drafts only; never replaces evidence. |
45
47
 
46
48
  Shared definitions (verdicts, fault classes, provenance, annotations) live in
47
49
  [`schemas/common.schema.json`](schemas/common.schema.json). Gate behaviour —
@@ -5,6 +5,25 @@ import { isDeepStrictEqual } from 'node:util';
5
5
  const common = JSON.parse(readFileSync(new URL('../schemas/common.schema.json', import.meta.url), 'utf8'));
6
6
  export const CLAIM_SOURCE_KINDS = Object.freeze([...common.$defs.claimSourceKind.enum]);
7
7
  const kindSet = new Set(CLAIM_SOURCE_KINDS);
8
+
9
+ /**
10
+ * A structured requirement reference, `req:<spec-id>#<requirement-id>`, read
11
+ * from the one pattern the shared schema declares (`$defs/claimSource`). It is
12
+ * a declaration: nothing here resolves the spec or checks the requirement
13
+ * exists. Consumers compare `specId#requirementId` exactly.
14
+ */
15
+ const sourceRef = common.$defs.claimSource.properties.ref.pattern;
16
+ // The schema's second alternative is the structured form; the first keeps every other ref opaque.
17
+ export const REQUIREMENT_REF_PATTERN = new RegExp(`^${sourceRef.slice(sourceRef.indexOf('|^') + 2)}`, 'u');
18
+ const REQUIREMENT_REF_PARTS = /^req:([^#]+)#(.+)$/u;
19
+
20
+ /** `{ specId, requirementId }` for a valid structured `spec` reference; `null` for anything else. */
21
+ export function requirementRef(source) {
22
+ if (!source || typeof source !== 'object' || source.kind !== 'spec' || typeof source.ref !== 'string') return null;
23
+ if (!REQUIREMENT_REF_PATTERN.test(source.ref)) return null;
24
+ const [, specId, requirementId] = REQUIREMENT_REF_PARTS.exec(source.ref);
25
+ return { specId, requirementId };
26
+ }
8
27
  const counts = () => Object.fromEntries(CLAIM_SOURCE_KINDS.map((kind) => [kind, 0]));
9
28
 
10
29
  // Consumers validate their full document first. These narrow guards prevent
@@ -112,8 +112,23 @@ function originErrors(kind, doc) {
112
112
  }
113
113
 
114
114
  // Rules JSON Schema cannot express. Each returns an array of {path, message}.
115
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
115
116
  const semantic = {
116
117
  'authoring-input': authoringInputErrors,
118
+ // A claim or fault entry is matched exactly against an identity, so a
119
+ // pattern that is not one excuses nothing — or, for a `fault` entry without
120
+ // its `/<faultId>`, excuses the whole claim. Neither is what a reviewer read.
121
+ ignore(doc) {
122
+ const errors = [];
123
+ doc.entries.forEach((e, i) => {
124
+ if (e.kind === 'claim' && !IDENTIFIER.test(e.pattern)) errors.push({ path: `/entries/${i}/pattern`, message: 'a claim entry names one claim id exactly; globs and paths are not matched' });
125
+ if (e.kind === 'fault') {
126
+ const parts = e.pattern.split('/');
127
+ if (parts.length !== 2 || !parts.every((p) => IDENTIFIER.test(p))) errors.push({ path: `/entries/${i}/pattern`, message: 'a fault entry names one fault exactly, as <claimId>/<faultId>' });
128
+ }
129
+ });
130
+ return errors;
131
+ },
117
132
  annotations(doc) {
118
133
  const errors = [];
119
134
  const fail = (path, message) => errors.push({ path, message });
@@ -419,6 +434,10 @@ const semantic = {
419
434
  const requested = new Set(scope.requestedClaims);
420
435
  if (scope.probedClaims.some((id) => !requested.has(id))) errors.push({ path: '/run/scope/probedClaims', message: 'every probed claim must have been requested' });
421
436
  }
437
+ // One invocation result per document: `run` is what a probe measured,
438
+ // `baseline` is what a baseline command wrote. A document carrying both
439
+ // would attribute one command's result to the other.
440
+ if (doc.run && doc.baseline) errors.push({ path: '/baseline', message: 'a status document carries at most one invocation result: run (probe) or baseline (baseline), never both' });
422
441
  return errors;
423
442
  },
424
443
 
@@ -588,7 +607,9 @@ export function validate(kind, doc) {
588
607
  if (!check(doc)) {
589
608
  return {
590
609
  ok: false,
591
- errors: check.errors.map((e) => ({ path: e.instancePath || '/', message: e.message ?? 'invalid' })),
610
+ // A closed set names its members: "must be equal to one of the allowed
611
+ // values" alone sends the reader to the schema to find out what they are.
612
+ errors: check.errors.map((e) => ({ path: e.instancePath || '/', message: `${e.message ?? 'invalid'}${e.keyword === 'enum' ? `: ${e.params.allowedValues.join(', ')}` : ''}` })),
592
613
  };
593
614
  }
594
615
  const errors = semantic[kind] ? semantic[kind](doc) : [];
@@ -46,21 +46,7 @@
46
46
  "maxLength": 2000
47
47
  },
48
48
  "source": {
49
- "description": "Declared origin of claim intent. A source label or opaque reference does not authenticate independence, chronology or correctness. Producer provenance and measured test independence are separate.",
50
- "type": "object",
51
- "required": [
52
- "kind"
53
- ],
54
- "properties": {
55
- "kind": {
56
- "$ref": "urn:claimspec:v1:common#/$defs/claimSourceKind"
57
- },
58
- "ref": {
59
- "type": "string",
60
- "maxLength": 512
61
- }
62
- },
63
- "additionalProperties": false
49
+ "$ref": "urn:claimspec:v1:common#/$defs/claimSource"
64
50
  },
65
51
  "severity": {
66
52
  "$ref": "urn:claimspec:v1:common#/$defs/severity"
@@ -77,6 +77,22 @@
77
77
  "description": "Declared origin of claim intent, not authenticated independence or an evidence-strength order. References remain opaque; producer provenance and measured test independence are separate.",
78
78
  "enum": ["spec", "adr", "annotation", "comment", "manual", "doc", "bug", "incident", "review", "inferred"]
79
79
  },
80
+ "claimSource": {
81
+ "description": "Declared origin of claim intent. A source label or reference does not authenticate independence, chronology or correctness. Producer provenance and measured test independence are separate. `ref` is opaque, except that the `req:` prefix is reserved for a structured requirement reference, `req:<spec-id>#<requirement-id>`, which must be declared with kind `spec`. Neither form is ever resolved or fetched.",
82
+ "type": "object",
83
+ "required": ["kind"],
84
+ "properties": {
85
+ "kind": { "$ref": "#/$defs/claimSourceKind" },
86
+ "ref": {
87
+ "type": "string",
88
+ "maxLength": 512,
89
+ "pattern": "^(?!req:)[\\s\\S]*$|^req:[A-Za-z0-9][A-Za-z0-9._-]{0,127}#[A-Za-z][A-Za-z0-9._-]{0,63}$"
90
+ }
91
+ },
92
+ "additionalProperties": false,
93
+ "if": { "type": "object", "required": ["ref"], "properties": { "ref": { "type": "string", "pattern": "^req:" } } },
94
+ "then": { "properties": { "kind": { "const": "spec" } } }
95
+ },
80
96
  "nestedProject": {
81
97
  "description": "Changed files delegated to a separately validated descendant TestGuard project. This is not coverage or evidence; run that project's gate separately.",
82
98
  "type": "object",
@@ -81,7 +81,7 @@
81
81
  "maxLength": 64
82
82
  },
83
83
  "source": {
84
- "description": "`project`: the runner package resolved from the project. `path`: an executable on PATH ran instead. `builtin`: the explicitly selected node-test engine uses the Node executable running TestGuard; its version is required.",
84
+ "description": "Where the runner was resolved from; recorded for every resolved runner and absent only for a custom command. `project`: the runner package resolved from the project, the project's virtualenv, or an interpreter named by path. `path`: an executable found on PATH ran instead (including a bare command name given to --python). `builtin`: the explicitly selected node-test engine uses the Node executable running TestGuard; its version is required. Reuse requires the same name, version and source.",
85
85
  "enum": [
86
86
  "project",
87
87
  "path",
@@ -339,20 +339,7 @@
339
339
  "$ref": "urn:claimspec:v1:common#/$defs/severity"
340
340
  },
341
341
  "source": {
342
- "type": "object",
343
- "required": [
344
- "kind"
345
- ],
346
- "properties": {
347
- "kind": {
348
- "$ref": "urn:claimspec:v1:common#/$defs/claimSourceKind"
349
- },
350
- "ref": {
351
- "type": "string",
352
- "maxLength": 512
353
- }
354
- },
355
- "additionalProperties": false
342
+ "$ref": "urn:claimspec:v1:common#/$defs/claimSource"
356
343
  },
357
344
  "producedBy": {
358
345
  "$ref": "urn:claimspec:v1:common#/$defs/provenance"
@@ -677,7 +664,7 @@
677
664
  }
678
665
  },
679
666
  "testUniverseHash": {
680
- "description": "Canonical hash of the runner-native test universe and the configuration files that produced it. Optional for backward compatibility; current probes emit it, and evidence without it is never reusable against a configured discovery run.",
667
+ "description": "Canonical hash of the runner-native test universe and the configuration files that produced it; for a custom --runner-cmd, of the static file set and the parsed command argv. Optional for backward compatibility; current probes emit it, and evidence without it is never reusable against a configured discovery run.",
681
668
  "$ref": "urn:claimspec:v1:common#/$defs/sha256"
682
669
  },
683
670
  "discoveryHashes": {
@@ -15,8 +15,8 @@
15
15
  "required": ["kind", "pattern", "reason"],
16
16
  "properties": {
17
17
  "kind": {
18
- "description": "`path` = glob over repo paths; `claim` = claim id; `fault` = `<claimId>/<faultId>`; `fingerprint` = an evidence fingerprint.",
19
- "enum": ["path", "claim", "fault", "fingerprint"]
18
+ "description": "`path` = glob over repo paths: excuses a changed source file from the change gate. `claim` = claim id: excuses the removal of that claim and of its faults. `fault` = `<claimId>/<faultId>`: excuses the removal of that one fault. No kind removes anything from a probe or changes a verdict; a known finding is accepted in a baseline, never here.",
19
+ "enum": ["path", "claim", "fault"]
20
20
  },
21
21
  "pattern": { "type": "string", "minLength": 1, "maxLength": 512 },
22
22
  "reason": { "type": "string", "minLength": 8, "maxLength": 2000 },
@@ -189,6 +189,29 @@
189
189
  },
190
190
  "additionalProperties": false
191
191
  },
192
+ "baseline": {
193
+ "description": "Present on baseline --json: the validated status document plus what this invocation wrote. `frozen` when a baseline was frozen (the number of unproven findings it holds), `restamped` when an existing baseline's head was moved (the commit it now names). A status document carries at most one invocation result, so it never has both `baseline` and `run`.",
194
+ "oneOf": [
195
+ {
196
+ "type": "object",
197
+ "required": ["path", "frozen"],
198
+ "properties": {
199
+ "path": { "type": "string", "minLength": 1, "maxLength": 4096 },
200
+ "frozen": { "type": "integer", "minimum": 0 }
201
+ },
202
+ "additionalProperties": false
203
+ },
204
+ {
205
+ "type": "object",
206
+ "required": ["path", "restamped"],
207
+ "properties": {
208
+ "path": { "type": "string", "minLength": 1, "maxLength": 4096 },
209
+ "restamped": { "$ref": "urn:claimspec:v1:common#/$defs/gitSha" }
210
+ },
211
+ "additionalProperties": false
212
+ }
213
+ ]
214
+ },
192
215
  "cost": {
193
216
  "description": "Present when probe --json --cost is requested; derived only from the runs in the emitted evidence.",
194
217
  "type": "object",
@@ -7,6 +7,24 @@ export const HEADING = '## TEST BLINDSPOT CONTEXT';
7
7
  export const MARKDOWN_MARKER = '<!-- testguard:brief -->';
8
8
  const ORDER = ['survived', 'nocover', 'unverifiable', 'fault-invalid', 'timeout', 'flaky-defender'];
9
9
 
10
+ const RUNNER_CACHES = [
11
+ [/[\\/]_npx[\\/]/, 'npx cache'],
12
+ [/[\\/]pnpm[\\/]dlx[\\/]/, 'pnpm dlx cache'],
13
+ [/[\\/]bunx-[^\\/]*[\\/]/, 'bunx cache'],
14
+ ];
15
+ /**
16
+ * Where the running CLI came from, for the brief's first line, so a stale
17
+ * binary is visible in the session-start context. A package runner's cache is
18
+ * checked first: it also sits under node_modules/, and calling it a local
19
+ * install hid exactly the stale copy the label exists to show — npx reuses
20
+ * whatever version it cached until that cache is cleared.
21
+ */
22
+ export function installLabel(scriptPath) {
23
+ const path = scriptPath ?? '';
24
+ for (const [pattern, label] of RUNNER_CACHES) if (pattern.test(path)) return label;
25
+ return /[\\/]node_modules[\\/]/.test(path) ? 'local install' : 'global';
26
+ }
27
+
10
28
  /** One line the agent can act on. Names the mechanism, never just the verdict. */
11
29
  export function hintFor(r) {
12
30
  const defenders = r.defenders.resolved.join(', ');
package/src/cli.mjs CHANGED
@@ -71,12 +71,13 @@ probe
71
71
  unittest pin that choice and fail rather than fall back. The engine that ran is what the evidence records.)
72
72
  --runner-cmd "<cmd>" custom runner; must contain {files} and {out}, e.g. "pnpm vitest run {files} --reporter=json --outputFile={out}"
73
73
  --node-modules <dir> node_modules to link into the scratch worktree (or TESTGUARD_NODE_MODULES)
74
- --python <path> Python interpreter for .py defenders (or TESTGUARD_PYTHON; default: $VIRTUAL_ENV, then the project's
75
- .venv/venv, then python3 on PATH). Resolved against your working tree, never the scratch worktree.
74
+ --python <path> Python interpreter for .py defenders (or TESTGUARD_PYTHON); a bare name is looked up on PATH.
75
+ Default: $VIRTUAL_ENV, then the project's .venv, venv, .env, then python3, then python on PATH.
76
+ Resolved against your working tree, never the scratch worktree.
76
77
  --workers <n> maximum built-in runner workers (default: 1); Python remains serial
77
- --serial run one test file at a time (vitest --no-file-parallelism, jest --runInBand, playwright --workers=1,
78
- pytest -p no:xdist);
79
- use it when another test runner is already running — probe warns and records the contention either way
78
+ --serial one worker, even when --workers is higher (vitest --no-file-parallelism, jest --runInBand,
79
+ playwright --workers=1; pytest never loads xdist either way). The default --workers 1 is already
80
+ serial, so it only changes a run that raised --workers; probe warns and records contention either way
80
81
  --in-place mutate the working tree instead of a scratch worktree
81
82
  --no-escalate do not re-run survivors against the whole suite
82
83
  --no-reuse re-probe claims whose inputs have not changed
@@ -98,24 +99,32 @@ sweep --changed <ref> (required unless --save-paths; CI bases and a safe lo
98
99
  denominator: "120 writes across 38 files, swept 12". A clean sweep over an unstated
99
100
  denominator is a sample of unknown size, not a guarantee that saving works
100
101
  --include-dirty --exclude <glob> --confirm <n> --budget <ms> --command-budget <ms> --workers <n> --serial --max <n> --out <path> --json
102
+ --runner <name> --runner-cmd "<cmd>" --node-modules <dir> runner selection, as for probe
103
+ --python <path> Python interpreter for .py defenders (or TESTGUARD_PYTHON); a bare name is looked up on PATH
101
104
  the cold start: no claim and no concern needed. Everything it proposes is a DRAFT — it never writes testguard.claims.json,
102
105
  and its evidence never replaces .testguard/evidence.json. exit 1 on a fault that survived, or a file no test imports
103
106
  shapes: if-guard → if (false) · single-line guard/mutation removed · return <check> → return true
104
107
  · security flag/window/cost literal weakened · verify/validate/check call removed
105
108
  · field dropped from a payload/allow-list/schema/merge · parameter-derived argument swapped for undefined/{}
109
+ · one-line JSX element removed · on<Event> handler prop dropped
106
110
  mcp no options. JSON-RPC 2.0 over stdio; five READ-ONLY tools (status, brief, claims, evidence, next_command).
107
111
  Nothing here runs a probe: next_command hands back the shell line for you to run where the person can see it.
108
112
  Register it with your harness — testguard init --mcp prints the config for Claude Code, Cursor and Codex.
109
113
  replay --workers <n> (default 1) --serial --budget <ms> (per run)
110
- --since <range> commit range to search for fix commits (HEAD~50..HEAD, a tag, origin/main..HEAD)
111
- --max <n> replay at most n fixes (they are slow: one worktree and N runs each)
114
+ --since <range> commit range to search for fix commits (HEAD~50..HEAD, a tag, origin/main..HEAD);
115
+ --changed <range> is accepted as an alias
116
+ --max <n> replay at most n fixes (default 20; they are slow: one worktree and N runs each)
112
117
  --confirm <n> runs per verdict (default 3); a flaky failure reads as "the suite caught it", so mixed runs are never caught
113
118
  --command-budget <ms> cooperative measurement deadline; expiry writes neither replay nor calibration, so an unmeasured suffix cannot look clean
114
119
  --out <path> replay document (default: <dir>/.testguard/replay.json); the calibration goes beside it
120
+ --calibration-out <path> write the calibration document here instead of beside the replay document
121
+ --runner <name> --runner-cmd "<cmd>" --node-modules <dir> runner selection, as for probe
122
+ --python <path> Python interpreter for .py defenders (or TESTGUARD_PYTHON); a bare name is looked up on PATH
115
123
  reports, never gates: a bug that escaped is history, not a regression in this change
116
- · one-line JSX element removed · on<Event> handler prop dropped
117
124
  admit --workers <n> (default 1) --serial --budget <ms> --command-budget <ms>
118
125
  --claim <ID> (required) --fault <FID> (one fault only) --confirm <n> --json
126
+ --runner <name> --runner-cmd "<cmd>" --node-modules <dir> runner selection, as for probe
127
+ --python <path> Python interpreter for .py defenders (or TESTGUARD_PYTHON); a bare name is looked up on PATH
119
128
  ADMITTED (exit 0) only when every fault of the claim is killed N/N by defenders that are green N/N; anything else is NOT ADMITTED (exit 1) and names the first blocking fault
120
129
  the test must be a declared or discovered defender of the claim (exit 3 otherwise); evidence goes to .testguard/evidence-partial.json
121
130
  gate --changed <ref> measure the change since merge-base(ref, HEAD); auto-detected in GitHub Actions / GitLab CI
@@ -137,7 +146,7 @@ init --force (replace an existing skill file) --here (keep the agent laye
137
146
  agent layer (skill, SessionStart hook, AGENTS.md section) → git root; project layer (.gitignore lines) → [dir]
138
147
  the hook prefers a local install, then a testguard already on PATH, then nothing; it never reaches the network. exit 1 if a written file is gitignored
139
148
  --mcp print the MCP server config for Claude Code, Cursor and Codex (printed, never written: a harness config is yours)
140
- every command accepts --json; probe/baseline emit the status document plus their own result
149
+ every command except mcp accepts --json (mcp speaks JSON-RPC on stdio and refuses it); probe/baseline emit the status document plus their own result
141
150
  claims --json
142
151
  --cost what the last probe spent, per claim and per defender file, read back from the recorded run durations. Names the files more than one claim pays for, which is where a slow gate comes from
143
152
  --since <ref> report every claim and fault that existed at <ref> and does not now; a claim entry in testguard.ignore.json (with a reason) excuses one. exit 1 on any unexcused removal
@@ -169,7 +178,9 @@ export function commandUsage(command) {
169
178
  scaffold: 'scaffold src/example.mjs --json', replay: 'replay . --since HEAD~10..HEAD --max 3',
170
179
  admit: 'admit test/example.test.mjs --claim EXAMPLE-001', sweep: 'sweep . --changed origin/main --cap 10',
171
180
  };
172
- return `${lines[0]}\n\n${overview}\n\n${sections.join('\n').trim()}\n\nCommon: --json, --help, --version\nExample: testguard ${examples[command] ?? `${command}${command === 'mcp' ? '' : ' .'}`}\n`;
181
+ // mcp refuses --json (stdout is its protocol stream), so its help does not offer it.
182
+ const common = command === 'mcp' ? '--help, --version' : '--json, --help, --version';
183
+ return `${lines[0]}\n\n${overview}\n\n${sections.join('\n').trim()}\n\nCommon: ${common}\nExample: testguard ${examples[command] ?? `${command}${command === 'mcp' ? '' : ' .'}`}\n`;
173
184
  }
174
185
 
175
186
  export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n'), err: (s) => process.stderr.write(s + '\n') }) {
@@ -190,6 +201,7 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
190
201
  out: { type: 'string' },
191
202
  evidence: { type: 'string' },
192
203
  baseline: { type: 'string' },
204
+ 'calibration-out': { type: 'string' },
193
205
  severity: { type: 'string', default: 'low' },
194
206
  max: { type: 'string', default: '20' },
195
207
  ref: { type: 'string' },
@@ -263,6 +275,18 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
263
275
  io.out(commandUsage(command));
264
276
  return 0;
265
277
  }
278
+ // replay once took --baseline as the calibration output path, undocumented,
279
+ // sharing probe's flag for "the baseline to gate against". Refused rather
280
+ // than ignored: a silently dropped path would write the calibration where
281
+ // the caller did not ask.
282
+ if (command === 'replay' && values.baseline !== undefined) {
283
+ io.err('replay has no --baseline: a baseline gates probe findings, and replay never gates. To choose where the calibration is written, use --calibration-out <path>.');
284
+ return 3;
285
+ }
286
+ if (values['calibration-out'] !== undefined && command !== 'replay') {
287
+ io.err('--calibration-out is only valid on replay');
288
+ return 3;
289
+ }
266
290
  if ((values.annotate || values.apply) && command !== 'claims') {
267
291
  io.err('--annotate and --apply are only valid on claims');
268
292
  return 3;
@@ -11,6 +11,7 @@ import { PreconditionError } from '../probe/worktree.mjs';
11
11
  import { hintFor } from '../brief/brief.mjs';
12
12
  import { formatVerdict, PROVISIONAL_WARNING } from '../render.mjs';
13
13
  import { decideAdmission } from '../admit/admit.mjs';
14
+ import { runnerOptions } from './runner-options.mjs';
14
15
 
15
16
  /** The project is wherever the claims file is, walking up from the test file; else cwd. */
16
17
  function findProjectDir(testAbs, claimsFlag) {
@@ -44,6 +45,11 @@ export async function admitCommand({ file, values, version }, io) {
44
45
  io.err('--claim must name one claim id');
45
46
  return 3;
46
47
  }
48
+ const runner = runnerOptions(values);
49
+ if (runner.error) {
50
+ io.err(runner.error);
51
+ return 3;
52
+ }
47
53
  const confirmRuns = Number(values.confirm);
48
54
  const budgetMs = Number(values.budget);
49
55
  const commandBudgetMs = parseCommandBudget(values['command-budget']);
@@ -100,9 +106,10 @@ export async function admitCommand({ file, values, version }, io) {
100
106
  includeDirty: true,
101
107
  only: [claimId],
102
108
  escalate: false,
103
- runnerCommand: values['runner-cmd'],
109
+ runnerCommand: runner.runnerCommand,
104
110
  runnerName: values.runner,
105
111
  nodeModules: values['node-modules'] ? resolve(values['node-modules']) : process.env.TESTGUARD_NODE_MODULES,
112
+ python: runner.python,
106
113
  toolVersion: version,
107
114
  onStage: !values.quiet && !values.json && process.stderr.isTTY ? ({ claimId: c, faultId, stage, i, n }) => process.stderr.write(`\r\x1b[K … ${c}/${faultId} ${stage} ${i}/${n}`) : undefined,
108
115
  });
@@ -4,6 +4,7 @@ import { readSpecDoc, writeSpecDoc } from '../evidence/writer.mjs';
4
4
  import { buildBaseline, restampBaseline } from '../baseline/baseline.mjs';
5
5
  import { evidencePath, baselinePath } from './probe.mjs';
6
6
  import { computeStatus } from '../status/status.mjs';
7
+ import { validate } from '../../spec/lib/validate.mjs';
7
8
  import { isGitIgnored, GITIGNORE_LINES, COMMITTED_OUTPUTS } from '../init/init.mjs';
8
9
 
9
10
  /**
@@ -20,6 +21,19 @@ function unignoredOutputs(projectDir) {
20
21
  return GITIGNORE_LINES.filter((l) => !isGitIgnored(projectDir, l.replace('*', 'x')));
21
22
  }
22
23
 
24
+ /**
25
+ * The status document plus what this invocation wrote, validated before it is
26
+ * printed — the same contract `probe --json` keeps for its `run`. A JSON mode
27
+ * whose output its own schema refuses is a parser failure handed to every
28
+ * integration that trusted the exit code.
29
+ */
30
+ function statusWith(projectDir, version, baseline) {
31
+ const doc = { ...computeStatus({ projectDir, toolVersion: version }), baseline };
32
+ const result = validate('status', doc);
33
+ if (!result.ok) throw new Error(`baseline JSON document does not conform: ${result.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`);
34
+ return JSON.stringify(doc, null, 2);
35
+ }
36
+
23
37
  export async function baselineCommand({ projectDir, values, version }, io) {
24
38
  const evPath = values.evidence ? resolve(values.evidence) : evidencePath(projectDir);
25
39
  if (!existsSync(evPath)) {
@@ -43,14 +57,14 @@ export async function baselineCommand({ projectDir, values, version }, io) {
43
57
  return 2;
44
58
  }
45
59
  writeSpecDoc('baseline', outPath, r.baseline);
46
- io.out(values.json ? JSON.stringify({ ...computeStatus({ projectDir, toolVersion: version }), baseline: { path: outPath, restamped: r.baseline.head } }, null, 2) : `baseline: re-stamped to ${r.baseline.head.slice(0, 12)} (same fingerprints, clean tree) → ${outPath}`);
60
+ io.out(values.json ? statusWith(projectDir, version, { path: outPath, restamped: r.baseline.head }) : `baseline: re-stamped to ${r.baseline.head.slice(0, 12)} (same fingerprints, clean tree) → ${outPath}`);
47
61
  return 0;
48
62
  }
49
63
  const baseline = buildBaseline(evidence);
50
64
  writeSpecDoc('baseline', outPath, baseline);
51
65
  const n = Object.values(baseline.fingerprints).reduce((a, b) => a + b, 0);
52
66
  if (values.json) {
53
- io.out(JSON.stringify({ ...computeStatus({ projectDir, toolVersion: version }), baseline: { path: outPath, frozen: n } }, null, 2));
67
+ io.out(statusWith(projectDir, version, { path: outPath, frozen: n }));
54
68
  return 0;
55
69
  }
56
70
  io.out(`baseline: ${n} unproven finding${n === 1 ? '' : 's'} frozen at ${baseline.head.slice(0, 12)}${baseline.snapshot ? ` (working-tree snapshot ${baseline.snapshot.slice(0, 7)}; after you commit, a clean probe + \`baseline --restamp\` moves head to that commit)` : baseline.dirty ? ' (working tree was dirty)' : ''}${evidence.run.provisional ? ' — FROM PROVISIONAL EVIDENCE (--allow-provisional)' : ''} → ${outPath}`);