testguard-cli 0.18.3 → 0.19.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 +89 -0
- package/README.md +84 -1097
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +72 -24
- package/spec/README.md +4 -2
- package/spec/lib/validate.mjs +22 -1
- package/spec/schemas/evidence.schema.json +2 -2
- package/spec/schemas/ignore.schema.json +2 -2
- package/spec/schemas/status.schema.json +23 -0
- package/src/brief/brief.mjs +18 -0
- package/src/cli.mjs +34 -10
- package/src/commands/admit.mjs +8 -1
- package/src/commands/baseline.mjs +16 -2
- package/src/commands/brief.mjs +2 -2
- package/src/commands/claims.mjs +1 -1
- package/src/commands/concerns.mjs +2 -10
- package/src/commands/gate.mjs +2 -4
- package/src/commands/init.mjs +12 -2
- package/src/commands/probe.mjs +13 -5
- package/src/commands/replay.mjs +9 -2
- package/src/commands/runner-options.mjs +28 -0
- package/src/commands/sweep.mjs +8 -1
- package/src/gate/changed.mjs +11 -0
- package/src/git.mjs +12 -2
- package/src/init/init.mjs +169 -74
- package/src/probe/attribution.mjs +5 -0
- package/src/probe/contention.mjs +14 -3
- package/src/probe/cost.mjs +7 -1
- package/src/probe/policy-freshness.mjs +5 -1
- package/src/probe/probe.mjs +31 -5
- package/src/probe/runners/python.mjs +19 -5
- package/src/probe/universe.mjs +14 -2
- package/src/replay/replay.mjs +9 -8
- package/src/supply/concerns.mjs +14 -8
- package/src/sweep/sweep.mjs +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testguard-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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",
|
package/spec/GATE-SEMANTICS.md
CHANGED
|
@@ -254,7 +254,12 @@ Rules that follow from the table:
|
|
|
254
254
|
decided and leave a caller with an exit code this document does not define,
|
|
255
255
|
which is strictly worse than one loud unverifiable claim. A `probe-error`
|
|
256
256
|
record is never reused by a later run: it says something about the run, not
|
|
257
|
-
about the code. The
|
|
257
|
+
about the code. The same holds for a record whose reason is
|
|
258
|
+
`defenders-failed-to-load`: a broken `--runner-cmd`, a missing dependency or
|
|
259
|
+
an interpreter without the test requirements changes none of the hashed
|
|
260
|
+
inputs, so fixing the environment would otherwise leave the failure
|
|
261
|
+
reported as the current answer. Re-measuring it costs one baseline run per
|
|
262
|
+
defender set. The one exception is a **precondition** failure — the
|
|
258
263
|
interpreter loading the source from outside the probed tree, a runner that
|
|
259
264
|
does not resolve — which is a statement about every verdict in the run and
|
|
260
265
|
still refuses it outright.
|
|
@@ -306,6 +311,11 @@ Rules that follow from the table:
|
|
|
306
311
|
files; creating or editing any of them forces remeasurement. Evidence that
|
|
307
312
|
predates this hash, or whose hash
|
|
308
313
|
changed, is readable but never reusable for a current native discovery run.
|
|
314
|
+
A custom `--runner-cmd` has no native listing; its `testUniverseHash` binds
|
|
315
|
+
the static file set **and the parsed command argv**, so a changed command
|
|
316
|
+
is a changed universe and never reuses a verdict the old command measured.
|
|
317
|
+
Reformatting whitespace that parses to the same argv is not a change. Only
|
|
318
|
+
the hash is recorded, never the command text.
|
|
309
319
|
When several configured runners list defenders, exact manifest membership
|
|
310
320
|
assigns each file. An owned runner takes precedence over the project runner;
|
|
311
321
|
competing owned runners or a declared defender absent from every manifest
|
|
@@ -578,12 +588,42 @@ Adopting tools may reconcile an existing baseline format (for example a
|
|
|
578
588
|
`{version, fingerprints:{hash:count}}` file) by mapping it onto this shape;
|
|
579
589
|
the suppress-up-to-count semantics are identical.
|
|
580
590
|
|
|
591
|
+
A baseline command's machine output is the status document plus an optional
|
|
592
|
+
`baseline` result, exactly as a probe's is the status document plus `run`:
|
|
593
|
+
`{path, frozen}` when it froze one (`frozen` counts the unproven findings it
|
|
594
|
+
holds), `{path, restamped}` when it moved an existing baseline's head (the
|
|
595
|
+
commit it now names). The document is validated before it is printed. One
|
|
596
|
+
document carries at most one invocation result, never both `run` and
|
|
597
|
+
`baseline`.
|
|
598
|
+
|
|
581
599
|
## Ignore and annotations
|
|
582
600
|
|
|
583
|
-
- An ignore entry
|
|
601
|
+
- An ignore entry excuses a *coverage* or *removal* finding; it never
|
|
602
|
+
removes anything from a probe and never touches a verdict. A probe reads no
|
|
603
|
+
ignore file: a claim that is in the claims file is probed, so an excuse can
|
|
604
|
+
never hide a survivor. A known finding is accepted in a baseline (above),
|
|
605
|
+
which is fingerprinted, counted and reported as baselined. Every entry
|
|
584
606
|
carries a `reason` of at least eight characters; the structured form is what
|
|
585
|
-
an auditor reads.
|
|
586
|
-
|
|
607
|
+
an auditor reads. An entry past its `expires` excuses nothing and is
|
|
608
|
+
reported as expired. The kinds, and the only thing each does:
|
|
609
|
+
- `path` — a glob over project paths. Excuses a changed source file from
|
|
610
|
+
the change gate (below); every reliance is reported.
|
|
611
|
+
- `claim` — exactly one claim id. Excuses the removal of that claim, and of
|
|
612
|
+
any of its faults, when claims are compared against an earlier reference.
|
|
613
|
+
- `fault` — exactly `<claimId>/<faultId>`. Excuses the removal of that one
|
|
614
|
+
fault in the same comparison.
|
|
615
|
+
|
|
616
|
+
There is no fingerprint kind: suppressing a finding by fingerprint is what a
|
|
617
|
+
baseline does, and a second, uncounted channel for it would let debt be
|
|
618
|
+
accepted where no baseline report shows it. A `claim` or `fault` pattern
|
|
619
|
+
that is not an exact identity is invalid, because it would excuse nothing —
|
|
620
|
+
or, for a `fault` entry without its fault id, the whole claim. A plain
|
|
621
|
+
gitignore-syntax file may be accepted as shorthand for reasonless `path`
|
|
622
|
+
entries.
|
|
623
|
+
- A concerns file is read like every other spec document: one that does not
|
|
624
|
+
parse or does not conform fails the command that reads it (exit `2`). It
|
|
625
|
+
never degrades to a concern that matches nothing — a sweep aimed at nothing
|
|
626
|
+
reports clean.
|
|
587
627
|
- An annotation is **strictly additive**. It never changes, suppresses, or
|
|
588
628
|
drops a finding. Ranking may read annotations; verdicts never do.
|
|
589
629
|
- A **signal** is a static, annotation-grade fact about a defender, recorded
|
|
@@ -792,6 +832,25 @@ evidence; they simply do not turn CI red.
|
|
|
792
832
|
|
|
793
833
|
## Exit codes
|
|
794
834
|
|
|
835
|
+
| Code | Meaning |
|
|
836
|
+
|---|---|
|
|
837
|
+
| `0` | No new gating findings at or above the severity floor. |
|
|
838
|
+
| `1` | At least one new gating finding. |
|
|
839
|
+
| `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. |
|
|
840
|
+
| `3` | Usage or configuration error. |
|
|
841
|
+
|
|
842
|
+
A tool must never exit `0` because it had nothing to check. If the claims
|
|
843
|
+
file is empty or every claim is out of scope, that is reported explicitly and
|
|
844
|
+
the exit code is `2`.
|
|
845
|
+
|
|
846
|
+
`probe --allow-empty` is an explicit adoption exception for a valid claims
|
|
847
|
+
file containing zero claims, with no `--claim` selection. It exits `0`, says
|
|
848
|
+
that verification was skipped, and writes no evidence or baseline. Its JSON
|
|
849
|
+
output is the existing `no-claims` status document, without run metadata;
|
|
850
|
+
it never reports a clean measurement. Missing or invalid claims still fail,
|
|
851
|
+
and selected claims cannot be silently skipped. Run `gate` separately to
|
|
852
|
+
enforce coverage of changed files during adoption. Integrations must opt in.
|
|
853
|
+
|
|
795
854
|
### Explicit declared-origin policy result contract
|
|
796
855
|
|
|
797
856
|
Optional `originPolicy` on evidence, status and brief (and status's probe `run`)
|
|
@@ -803,7 +862,8 @@ It requires a complete current-project run with confirm >= 3 and refuses
|
|
|
803
862
|
before loading or writing. No policy is inferred when the option is absent.
|
|
804
863
|
After measurement, the command reloads current claims and collects the current
|
|
805
864
|
native runner universe under the same command budget, then binds targets,
|
|
806
|
-
resolved defenders
|
|
865
|
+
resolved defenders, current discovery dependencies and the recorded runner
|
|
866
|
+
identities (name, version and source). Missing bindings or
|
|
807
867
|
discovery failure yields unavailable; custom/static runner universes cannot
|
|
808
868
|
certify this boundary. Expired command budgets still write no partial result.
|
|
809
869
|
This is point-in-time admission, not an atomic filesystem snapshot. Stored
|
|
@@ -834,25 +894,6 @@ Completed probe JSON with unavailable policy exits 2; failed policy or ordinary
|
|
|
834
894
|
new findings exits 1; passed policy without ordinary findings exits 0.
|
|
835
895
|
Partial/provisional invocations cannot carry an origin policy.
|
|
836
896
|
|
|
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
897
|
## Read-only authoring input (candidate)
|
|
857
898
|
|
|
858
899
|
`authoring-input` is metadata-only inspection, never claims or evidence. Its
|
|
@@ -916,6 +957,13 @@ worker ceiling and must be one for serial execution; custom commands omit it.
|
|
|
916
957
|
Native evidence reuse requires the same recorded worker ceiling and serial policy.
|
|
917
958
|
Older native evidence with no worker ceiling is remeasured; native and opaque
|
|
918
959
|
custom-command policies are not interchangeable.
|
|
960
|
+
Every reuse also requires the same recorded `run.runner`: name (the engine that
|
|
961
|
+
ran, so pytest and unittest differ), version and `source`. `source` is recorded
|
|
962
|
+
for every resolved runner — `project` (the project's package, its virtualenv,
|
|
963
|
+
or an interpreter named by path), `path` (an executable found on PATH,
|
|
964
|
+
including a bare command name given to `--python`) or `builtin` — and is absent
|
|
965
|
+
only for a custom command, which resolves nothing. Evidence that recorded no
|
|
966
|
+
runner, or a different one, is remeasured.
|
|
919
967
|
`status.cost.measurements` copies these measurements when available. Historical
|
|
920
968
|
`totalMs` and cost gates retain their existing record attribution, including
|
|
921
969
|
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
|
|
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.
|
|
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 —
|
package/spec/lib/validate.mjs
CHANGED
|
@@ -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
|
-
|
|
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) : [];
|
|
@@ -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",
|
|
@@ -677,7 +677,7 @@
|
|
|
677
677
|
}
|
|
678
678
|
},
|
|
679
679
|
"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.",
|
|
680
|
+
"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
681
|
"$ref": "urn:claimspec:v1:common#/$defs/sha256"
|
|
682
682
|
},
|
|
683
683
|
"discoveryHashes": {
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"required": ["kind", "pattern", "reason"],
|
|
16
16
|
"properties": {
|
|
17
17
|
"kind": {
|
|
18
|
-
"description": "`path` = glob over repo paths
|
|
19
|
-
"enum": ["path", "claim", "fault"
|
|
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",
|
package/src/brief/brief.mjs
CHANGED
|
@@ -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;
|
|
75
|
-
|
|
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
|
|
78
|
-
pytest
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/commands/admit.mjs
CHANGED
|
@@ -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:
|
|
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 ?
|
|
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(
|
|
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}`);
|
package/src/commands/brief.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { readSpecDoc, writeSpecDoc } from '../evidence/writer.mjs';
|
|
4
|
-
import { buildBrief, buildUnclaimedBrief, renderBriefMarkdown, renderUnclaimedMarkdown } from '../brief/brief.mjs';
|
|
4
|
+
import { buildBrief, buildUnclaimedBrief, renderBriefMarkdown, renderUnclaimedMarkdown, installLabel } from '../brief/brief.mjs';
|
|
5
5
|
import { computeStatus } from '../status/status.mjs';
|
|
6
6
|
import { evidencePath, baselinePath } from './probe.mjs';
|
|
7
7
|
import { resolveChangedRef, withChangedRef } from '../gate/changed.mjs';
|
|
@@ -39,7 +39,7 @@ export async function briefCommand({ projectDir, values, version }, io) {
|
|
|
39
39
|
return 3;
|
|
40
40
|
}
|
|
41
41
|
// Where this binary came from, so a stale install is visible in the session-start context.
|
|
42
|
-
const install =
|
|
42
|
+
const install = installLabel(process.argv[1]);
|
|
43
43
|
const brief = buildBrief(evidence, baseline, { max, next: status?.next, changes: status?.changes, install });
|
|
44
44
|
if (!values.text && !values.markdown) writeSpecDoc('brief', values.out ? resolve(values.out) : join(projectDir, '.testguard', 'brief.json'), brief);
|
|
45
45
|
if (values.markdown) io.out(renderBriefMarkdown({ ...brief, provisional: Boolean(evidence.run.provisional) }, { hasBaseline: Boolean(baseline), total: evidence.records.length, install }).trimEnd());
|
package/src/commands/claims.mjs
CHANGED
|
@@ -41,7 +41,7 @@ export async function claimsCommand({ projectDir, values, suppliedOptions, versi
|
|
|
41
41
|
const drift = reconcile(claims, annotations);
|
|
42
42
|
const advisory = annotationAdvisory(claims, annotations, { customClaims: Boolean(values.claims) });
|
|
43
43
|
const anchorChecks = values['check-anchors']
|
|
44
|
-
? await checkAnchors(projectDir, claims, { python: values.python
|
|
44
|
+
? await checkAnchors(projectDir, claims, { python: values.python || undefined }) // a bare name is a PATH lookup; see explicitInterpreter
|
|
45
45
|
: undefined;
|
|
46
46
|
|
|
47
47
|
// A claim that disappeared is invisible to every other command; compare
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { loadConcerns, renderConcerns } from '../supply/concerns.mjs';
|
|
3
|
-
import { validate } from '../../spec/lib/validate.mjs';
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
* `testguard concerns [dir]`: the one-sentence scopes a sweep can be aimed by.
|
|
@@ -13,16 +12,9 @@ import { validate } from '../../spec/lib/validate.mjs';
|
|
|
13
12
|
*/
|
|
14
13
|
export async function concernsCommand({ projectDir, values, version }, io) {
|
|
15
14
|
const path = values.concerns ? resolve(values.concerns) : undefined;
|
|
15
|
+
// A file that does not parse or does not conform throws a SpecDocError
|
|
16
|
+
// naming it, which the CLI reports as exit 2: wrong with the FILE.
|
|
16
17
|
const loaded = loadConcerns(projectDir, { path });
|
|
17
|
-
|
|
18
|
-
if (loaded.source) {
|
|
19
|
-
const result = validate('concerns', { schemaVersion: 1, concerns: loaded.raw });
|
|
20
|
-
if (!result.ok) {
|
|
21
|
-
io.err(`${loaded.source} does not conform to the spec:`);
|
|
22
|
-
for (const e of result.errors) io.err(` ${e.path}: ${e.message}`);
|
|
23
|
-
return 2;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
18
|
if (values.json) {
|
|
27
19
|
io.out(JSON.stringify({ schemaVersion: 1, tool: { name: 'testguard', version }, source: loaded.source, shadowed: loaded.shadowed, concerns: loaded.concerns }, null, 2));
|
|
28
20
|
} else {
|
package/src/commands/gate.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join, resolve } from 'node:path';
|
|
2
|
-
import { computeChangedGate, resolveChangedRef, renderGate,
|
|
2
|
+
import { computeChangedGate, resolveChangedRef, renderGate, explainExclusions } from '../gate/changed.mjs';
|
|
3
3
|
import { writeSpecDoc } from '../evidence/writer.mjs';
|
|
4
4
|
|
|
5
5
|
export const gatePath = (projectDir) => join(projectDir, '.testguard', 'gate.json');
|
|
@@ -11,9 +11,7 @@ export const gatePath = (projectDir) => join(projectDir, '.testguard', 'gate.jso
|
|
|
11
11
|
*/
|
|
12
12
|
export async function gateCommand({ projectDir, values, version }, io) {
|
|
13
13
|
if (values.explain) {
|
|
14
|
-
|
|
15
|
-
for (const g of DEFAULT_EXCLUDES) io.out(` ${g}`);
|
|
16
|
-
io.out('Non-source files (anything but .js .mjs .cjs .ts .mts .cts .jsx .tsx) are excluded before these patterns apply.');
|
|
14
|
+
for (const line of explainExclusions()) io.out(line);
|
|
17
15
|
return 0;
|
|
18
16
|
}
|
|
19
17
|
const resolved = resolveChangedRef({ explicit: values.changed, projectDir });
|
package/src/commands/init.mjs
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import { initProject } from '../init/init.mjs';
|
|
1
|
+
import { initProject, InitUsageError } from '../init/init.mjs';
|
|
2
2
|
|
|
3
3
|
export async function initCommand({ projectDir, values }, io) {
|
|
4
|
-
|
|
4
|
+
let r;
|
|
5
|
+
try {
|
|
6
|
+
r = initProject({ projectDir, force: values.force, here: values.here, ciEvidence: values['ci-evidence'], mcp: values.mcp });
|
|
7
|
+
} catch (e) {
|
|
8
|
+
// A bad option is the caller's to fix, and init has written nothing yet:
|
|
9
|
+
// every option is checked before the first write.
|
|
10
|
+
if (!(e instanceof InitUsageError)) throw e;
|
|
11
|
+
io.err(e.message);
|
|
12
|
+
io.err("Run 'testguard init --help' for its options. Nothing was written.");
|
|
13
|
+
return 3;
|
|
14
|
+
}
|
|
5
15
|
const { done, skipped, warnings, agentRoot, dir, mcpConfig } = r;
|
|
6
16
|
if (values.json) {
|
|
7
17
|
io.out(JSON.stringify({ done, skipped, warnings, agentRoot, dir, ...(mcpConfig ? { mcpConfig } : {}) }, null, 2));
|