testguard-cli 0.5.0 → 0.7.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +628 -0
  2. package/README.md +536 -42
  3. package/package.json +2 -2
  4. package/spec/GATE-SEMANTICS.md +166 -2
  5. package/spec/README.md +29 -10
  6. package/spec/lib/validate.mjs +140 -8
  7. package/spec/schemas/baseline.schema.json +13 -5
  8. package/spec/schemas/brief.schema.json +12 -12
  9. package/spec/schemas/calibration.schema.json +5 -5
  10. package/spec/schemas/claims.schema.json +122 -25
  11. package/spec/schemas/common.schema.json +131 -29
  12. package/spec/schemas/evidence.schema.json +476 -69
  13. package/spec/schemas/gate.schema.json +12 -12
  14. package/spec/schemas/ignore.schema.json +4 -4
  15. package/spec/schemas/replay.schema.json +85 -0
  16. package/spec/schemas/status.schema.json +258 -56
  17. package/src/admit/admit.mjs +17 -0
  18. package/src/baseline/baseline.mjs +31 -2
  19. package/src/brief/brief.mjs +71 -7
  20. package/src/claims/removed.mjs +161 -0
  21. package/src/cli.mjs +68 -10
  22. package/src/commands/admit.mjs +116 -0
  23. package/src/commands/baseline.mjs +17 -3
  24. package/src/commands/brief.mjs +13 -7
  25. package/src/commands/claims.mjs +55 -6
  26. package/src/commands/init.mjs +19 -6
  27. package/src/commands/mcp.mjs +12 -0
  28. package/src/commands/probe.mjs +35 -7
  29. package/src/commands/replay.mjs +63 -0
  30. package/src/commands/status.mjs +3 -1
  31. package/src/gate/changed.mjs +9 -2
  32. package/src/git.mjs +5 -0
  33. package/src/init/init.mjs +182 -29
  34. package/src/init/templates/SKILL.md +3 -3
  35. package/src/mcp/server.mjs +104 -0
  36. package/src/mcp/tools.mjs +114 -0
  37. package/src/probe/attribution.mjs +123 -0
  38. package/src/probe/classify.mjs +18 -2
  39. package/src/probe/contention.mjs +60 -0
  40. package/src/probe/cost.mjs +145 -0
  41. package/src/probe/discover.mjs +20 -3
  42. package/src/probe/independence.mjs +59 -0
  43. package/src/probe/inject.mjs +41 -9
  44. package/src/probe/mocks.mjs +122 -0
  45. package/src/probe/probe.mjs +331 -40
  46. package/src/probe/progress.mjs +87 -0
  47. package/src/probe/pyimports.mjs +287 -0
  48. package/src/probe/rank.mjs +75 -15
  49. package/src/probe/runners/index.mjs +79 -8
  50. package/src/probe/runners/jest.mjs +15 -3
  51. package/src/probe/runners/playwright.mjs +142 -0
  52. package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
  53. package/src/probe/runners/python/_testguard_report.py +141 -0
  54. package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
  55. package/src/probe/runners/python.mjs +316 -0
  56. package/src/probe/runners/shared.mjs +80 -14
  57. package/src/probe/runners/vitest.mjs +16 -3
  58. package/src/render.mjs +2 -2
  59. package/src/replay/label.mjs +64 -0
  60. package/src/replay/replay.mjs +310 -0
  61. package/src/scaffold/producers.mjs +217 -6
  62. package/src/scaffold/producers.python.mjs +209 -0
  63. package/src/scaffold/scaffold.mjs +90 -30
  64. package/src/status/status.mjs +47 -6
  65. package/src/util/glob.mjs +7 -1
@@ -2,26 +2,44 @@ import { gate } from '../baseline/baseline.mjs';
2
2
  import { summarize, formatVerdict } from '../render.mjs';
3
3
 
4
4
  export const HEADING = '## TEST BLINDSPOT CONTEXT';
5
+ /** First line of every markdown brief, so a poster can find and update its own note instead of adding another. */
6
+ export const MARKDOWN_MARKER = '<!-- testguard:brief -->';
5
7
  const ORDER = ['survived', 'nocover', 'unverifiable', 'fault-invalid', 'timeout', 'flaky-defender'];
6
8
 
7
9
  /** One line the agent can act on. Names the mechanism, never just the verdict. */
8
10
  export function hintFor(r) {
9
11
  const defenders = r.defenders.resolved.join(', ');
12
+ const base = hintBase(r, defenders);
13
+ const mocked = (r.defenders.mocking ?? []);
14
+ const never = (r.defenders.signals ?? []).filter((s) => s.signal === 'mocked-never-asserted').map((s) => s.file);
15
+ const extra = [
16
+ mocked.length ? `${mocked.length} importing test${mocked.length === 1 ? '' : 's'} mock${mocked.length === 1 ? 's' : ''} the target and cannot detect this (${mocked.join(', ')})` : '',
17
+ never.length ? `${never.join(', ')} mock${never.length === 1 ? 's' : ''} it and never assert${never.length === 1 ? 's' : ''} on it` : '',
18
+ ].filter(Boolean);
19
+ return extra.length ? `${base} ${extra.join('; ')}.` : base;
20
+ }
21
+
22
+ function hintBase(r, defenders) {
10
23
  switch (r.verdict) {
11
24
  case 'survived':
12
25
  return r.detail.reason === 'killed-by-undeclared-tests'
13
26
  ? `Only tests outside its declared defenders (${defenders}) catch this; fix the claim's defendedBy or move the assertion.`
14
27
  : `${defenders} stayed green with this fault applied; add an assertion that fails on it and passes on HEAD. If no test's outcome can change, first check the fault is observable at all.`;
15
28
  case 'nocover':
16
- return `No test file matches ${r.defenders.requested.join(', ') || '(no defenders declared)'}; nothing defends this claim.`;
29
+ return r.defenders.discovered
30
+ ? 'No test file imports the target without mocking it; nothing defends this claim.'
31
+ : `No test file matches ${r.defenders.requested.join(', ') || '(no defenders declared)'}; nothing defends this claim.`;
17
32
  case 'unverifiable':
18
33
  return `Anchor ${r.detail.reason}; re-author fault ${r.subject.id} in the claims file before trusting this claim.`;
19
34
  case 'fault-invalid':
20
35
  return `Replacement does not load (${r.detail.reason}); fix the fault definition, not the code.`;
21
36
  case 'timeout':
22
37
  return 'Defenders time out with this fault applied; a hang is not a detection.';
23
- case 'flaky-defender':
24
- return `${defenders} not reliably green (${r.detail.reason}); fix the flake before trusting any verdict here.`;
38
+ case 'flaky-defender': {
39
+ const fr = r.detail.flakeRate;
40
+ const rate = fr && fr.failures > 0 ? ` — failed ${fr.failures} of ${fr.runs} runs on unmodified source` : '';
41
+ return `${defenders} not reliably green (${r.detail.reason})${rate}; fix the flake before trusting any verdict here.`;
42
+ }
25
43
  default:
26
44
  return '';
27
45
  }
@@ -31,7 +49,7 @@ function orderItems(a, b) {
31
49
  return (b.isNew - a.isNew) || (ORDER.indexOf(a.verdict) - ORDER.indexOf(b.verdict)) || ((b.rank ?? 0) - (a.rank ?? 0));
32
50
  }
33
51
 
34
- export function buildBrief(evidence, baseline, { max = 20, generatedAt = new Date().toISOString(), next, changes } = {}) {
52
+ export function buildBrief(evidence, baseline, { max = 20, generatedAt = new Date().toISOString(), next, changes, install } = {}) {
35
53
  const g = gate(evidence.records, baseline);
36
54
  const toItem = (r, isNew) => ({
37
55
  fingerprint: r.fingerprint,
@@ -54,19 +72,21 @@ export function buildBrief(evidence, baseline, { max = 20, generatedAt = new Dat
54
72
  new: g.new.length + g.belowFloor.length,
55
73
  baselined: g.baselined.length,
56
74
  };
75
+ const kills = evidence.records.filter((r) => r.verdict === 'killed');
76
+ const coAuthored = kills.filter((r) => r.detail.independence?.class === 'co-authored').length;
57
77
  const unclaimed = changes?.uncovered?.length ? { ref: changes.ref, files: changes.uncovered } : undefined;
58
78
  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: '' };
59
- doc.text = renderBriefText({ ...doc, provisional: Boolean(evidence.run.provisional) }, { hasBaseline: Boolean(baseline), total: evidence.records.length });
79
+ doc.text = renderBriefText({ ...doc, provisional: Boolean(evidence.run.provisional), install }, { hasBaseline: Boolean(baseline), total: evidence.records.length, independence: kills.length ? { coAuthored, kills: kills.length } : undefined });
60
80
  return doc;
61
81
  }
62
82
 
63
- export function renderBriefText(brief, { hasBaseline, total }) {
83
+ export function renderBriefText(brief, { hasBaseline, total, independence }) {
64
84
  const unproven = total - (brief.summary.byVerdict.killed ?? 0);
65
85
  const lines = [
66
86
  brief.heading,
67
87
  '',
68
88
  ...(brief.provisional ? ['**PROVISIONAL** — this evidence came from fewer than three confirmation runs; treat every verdict below as unconfirmed and re-probe with --confirm 3 before acting on it.', ''] : []),
69
- `testguard ${brief.tool.version}${brief.head ? ` @ ${brief.head.slice(0, 12)}` : ''} — ${brief.summary.claims} claims, ${total} faults probed, ${unproven} unproven` +
89
+ `testguard ${brief.tool.version}${brief.install ? ` (${brief.install})` : ''}${brief.head ? ` @ ${brief.head.slice(0, 12)}` : ''} — ${brief.summary.claims} claims, ${total} faults probed, ${unproven} unproven` +
70
90
  (unproven === 0 ? '.' : hasBaseline ? ` (${brief.summary.new} new since baseline).` : ' (no baseline; everything is new).'),
71
91
  ];
72
92
  // Unclaimed changes come before everything else: the claim is written
@@ -76,6 +96,9 @@ export function renderBriefText(brief, { hasBaseline, total }) {
76
96
  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
97
  for (const u of brief.unclaimed.files) lines.push(` - ${u.file} (${u.kind}) → ${u.suggestion}`);
78
98
  }
99
+ // L3: a kill written in the same change as the code it guards is not
100
+ // independent evidence. One line, no per-item noise.
101
+ if (independence?.coAuthored) lines.push('', `${independence.coAuthored} of ${independence.kills} kills are co-authored with the code they defend — the test and the code were written in the same change, so those kills are not independent evidence.`);
79
102
  if (brief.next) lines.push('', `NEXT [${brief.next.action}]: ${brief.next.command}`, ` why: ${brief.next.why}`);
80
103
  if (brief.items.length === 0) {
81
104
  lines.push('', 'Every probed claim is defended. Keep it that way: new claims need a fault and a test that fails on it.');
@@ -111,3 +134,44 @@ export function buildUnclaimedBrief({ tool, next, changes, generatedAt = new Dat
111
134
  doc.text = lines.join('\n') + '\n';
112
135
  return doc;
113
136
  }
137
+
138
+ const cell = (v) => String(v ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
139
+
140
+ /**
141
+ * The brief as a merge-request note: same content, same order as the text —
142
+ * unclaimed changes first, then `next`, then the ranked findings, capped at
143
+ * `--max` — in GitLab/GitHub-flavoured markdown. Rendering only; the document
144
+ * it renders is the validated one.
145
+ */
146
+ export function renderBriefMarkdown(brief, { hasBaseline, total, install } = {}) {
147
+ const unproven = total - (brief.summary.byVerdict.killed ?? 0);
148
+ const lines = [MARKDOWN_MARKER, brief.heading, ''];
149
+ if (brief.provisional) lines.push('**PROVISIONAL** — fewer than three confirmation runs; nothing below is confirmed. Re-probe with `--confirm 3`.', '');
150
+ lines.push(`\`testguard ${brief.tool.version}${install ? ` (${install})` : ''}\`${brief.head ? ` @ \`${brief.head.slice(0, 12)}\`` : ''} — **${brief.summary.claims}** claims, **${total}** faults probed, **${unproven}** unproven` +
151
+ (unproven === 0 ? '.' : hasBaseline ? ` (**${brief.summary.new}** new since baseline).` : ' (no baseline; everything is new).'));
152
+ if (brief.unclaimed) {
153
+ const n = brief.unclaimed.files.length;
154
+ 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.`, '');
155
+ for (const u of brief.unclaimed.files) lines.push(`- \`${u.file}\` (${u.kind}) → \`${u.suggestion}\``);
156
+ }
157
+ if (brief.next) lines.push('', `**Next** \`[${brief.next.action}]\`: \`${brief.next.command}\``, '', `> ${brief.next.why}`);
158
+ if (brief.items.length === 0) {
159
+ lines.push('', 'Every probed claim is defended. Keep it that way: new claims need a fault and a test that fails on it.');
160
+ return lines.join('\n') + '\n';
161
+ }
162
+ lines.push('', 'Where the test suite is blind, ranked. A **SURVIVED** fault means its defenders stayed green while the claim was false. Do not close these by asserting current behaviour; write a test that fails on the described fault and passes on HEAD.', '');
163
+ lines.push('| # | verdict | claim / fault | severity | file | what to do |', '|---|---|---|---|---|---|');
164
+ brief.items.forEach((it, i) => {
165
+ lines.push(`| ${i + 1} | ${it.isNew ? '**NEW** ' : ''}${formatVerdict(it.verdict)} | \`${cell(it.claimId)}/${cell(it.subjectId ?? '?')}\`<br>${cell(it.statement)} | ${it.severity} | ${it.file ? `\`${cell(it.file)}\`` : ''} | ${cell(it.hint)} |`);
166
+ });
167
+ if (unproven > brief.items.length) lines.push('', `… and ${unproven - brief.items.length} more in the evidence file.`);
168
+ return lines.join('\n') + '\n';
169
+ }
170
+
171
+ /** Markdown for the no-evidence-yet, unclaimed-changes-only brief. */
172
+ export function renderUnclaimedMarkdown({ tool, next, changes, install }) {
173
+ const lines = [MARKDOWN_MARKER, HEADING, '', `\`testguard ${tool.version}${install ? ` (${install})` : ''}\` — no evidence yet; **${changes.uncovered.length}** unclaimed changed file${changes.uncovered.length === 1 ? '' : 's'} since \`${changes.ref}\`.`, '', `### Unclaimed changes since \`${changes.ref}\``, '', 'State the claim first; nothing can be probed for this code until it has one.', ''];
174
+ for (const u of changes.uncovered) lines.push(`- \`${u.file}\` (${u.kind}) → \`${u.suggestion}\``);
175
+ if (next) lines.push('', `**Next** \`[${next.action}]\`: \`${next.command}\``, '', `> ${next.why}`);
176
+ return lines.join('\n') + '\n';
177
+ }
@@ -0,0 +1,161 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import { basename, dirname, join, relative, resolve } from 'node:path';
3
+ import { git, GitError, repoRoot } from '../git.mjs';
4
+ import { readSpecDoc } from '../evidence/writer.mjs';
5
+ import { defaultIgnorePath } from '../gate/changed.mjs';
6
+ import { PreconditionError } from '../probe/worktree.mjs';
7
+
8
+ /**
9
+ * A claim that no longer exists is invisible by construction: `probe` verifies
10
+ * what is there, `gate` sees the source file covered by some other claim, and
11
+ * `status` says clean. Deleting a claim is therefore cheaper than weakening
12
+ * its fault — and `changedFaults` exists precisely because weakening was the
13
+ * cheap escape. This closes the other half: removal is allowed, and recorded.
14
+ *
15
+ * Identities only. A fault whose text changed is `changedFaults`' business.
16
+ */
17
+ const isExpired = (entry, now) => Boolean(entry.expires) && new Date(entry.expires).getTime() <= now.getTime();
18
+
19
+ /** The claims document as of `ref`, or null when the file did not exist there. */
20
+ export function claimsAt({ projectDir, ref, claimsPath }) {
21
+ // git reports the repository root by its real path (/private/var on macOS);
22
+ // a relative() that does not start from the same place yields `../../..`
23
+ // and `git show` then fails as if the file had never existed.
24
+ const root = repoRoot(realpathSync(resolve(projectDir)));
25
+ // The file may not exist at this ref (or at all); realpath its DIRECTORY so
26
+ // a missing file still resolves to a repository-relative path.
27
+ const p0 = resolve(claimsPath);
28
+ const abs = existsSync(p0) ? realpathSync(p0) : join(realpathSync(dirname(p0)), basename(p0));
29
+ const rel = relative(root, abs).split('\\').join('/');
30
+ if (rel.startsWith('..')) throw new PreconditionError(`the claims file ${claimsPath} is outside the repository at ${root}`);
31
+ let text;
32
+ try {
33
+ text = git(['show', `${ref}:${rel}`], root);
34
+ } catch (e) {
35
+ if (e instanceof GitError) {
36
+ // Distinguish "that ref is not a thing" (the caller's mistake) from
37
+ // "the file did not exist yet" (nothing to compare, not an error).
38
+ try {
39
+ git(['rev-parse', '--verify', `${ref}^{commit}`], root);
40
+ } catch {
41
+ throw new PreconditionError(`cannot read ${ref}: no such commit`);
42
+ }
43
+ return null;
44
+ }
45
+ throw e;
46
+ }
47
+ try {
48
+ return JSON.parse(text);
49
+ } catch (e) {
50
+ throw new PreconditionError(`the claims file at ${ref} is not valid JSON (${e.message})`);
51
+ }
52
+ }
53
+
54
+ const index = (doc) => {
55
+ const claims = new Map();
56
+ for (const c of doc?.claims ?? []) claims.set(c.id, { statement: c.statement, severity: c.severity, faults: new Set((c.faults ?? []).map((f) => f.id)) });
57
+ return claims;
58
+ };
59
+
60
+ /**
61
+ * Compare claim and fault identities at `ref` with the working file.
62
+ *
63
+ * A removed id whose statement reappears verbatim under a new id is a
64
+ * `renamed-claim`, not a removal: an ordinary rename must not read as an
65
+ * escape, or the check becomes noise nobody acts on.
66
+ */
67
+ export function computeRemovedClaims({ projectDir, ref, claimsPath, current, ignorePath, evidencePath, now = new Date(), toolVersion = '0.0.0' }) {
68
+ const before = claimsAt({ projectDir, ref, claimsPath });
69
+ const beforeIdx = index(before);
70
+ const afterIdx = index(current);
71
+
72
+ const iPath = ignorePath ?? defaultIgnorePath(projectDir);
73
+ const ignore = existsSync(iPath) ? readSpecDoc('ignore', iPath) : { entries: [] };
74
+ const excuses = ignore.entries
75
+ .filter((e) => e.kind === 'claim' || e.kind === 'fault')
76
+ .map((e) => ({ entry: e, expired: isExpired(e, now), used: [] }));
77
+
78
+ const lastVerdict = new Map();
79
+ if (evidencePath && existsSync(evidencePath)) {
80
+ try {
81
+ for (const r of readSpecDoc('evidence', evidencePath).records) lastVerdict.set(`${r.claim.id}/${r.subject.id}`, r.verdict);
82
+ } catch {
83
+ // Evidence that cannot be read must not stop the check; the verdict is
84
+ // extra context, never the finding itself.
85
+ }
86
+ }
87
+
88
+ const statementsAfter = new Map();
89
+ for (const [id, c] of afterIdx) if (!beforeIdx.has(id)) statementsAfter.set(c.statement, id);
90
+
91
+ const findings = [];
92
+ for (const [id, c] of beforeIdx) {
93
+ if (!afterIdx.has(id)) {
94
+ const renamedTo = statementsAfter.get(c.statement);
95
+ if (renamedTo) {
96
+ findings.push({ kind: 'renamed-claim', claimId: id, renamedTo, severity: c.severity });
97
+ continue;
98
+ }
99
+ const verdicts = [...c.faults].map((f) => lastVerdict.get(`${id}/${f}`)).filter(Boolean);
100
+ findings.push({ kind: 'removed-claim', claimId: id, severity: c.severity, faults: [...c.faults].sort(), ...(verdicts.length ? { lastVerdicts: [...new Set(verdicts)].sort() } : {}) });
101
+ continue;
102
+ }
103
+ for (const f of c.faults) {
104
+ if (afterIdx.get(id).faults.has(f)) continue;
105
+ const v = lastVerdict.get(`${id}/${f}`);
106
+ findings.push({ kind: 'removed-fault', claimId: id, subjectId: f, severity: c.severity, ...(v ? { lastVerdicts: [v] } : {}) });
107
+ }
108
+ }
109
+
110
+ // An excuse applies to a removal, never to a rename: a rename is already not a finding.
111
+ const gating = [];
112
+ for (const f of findings) {
113
+ if (f.kind === 'renamed-claim') continue;
114
+ const target = f.kind === 'removed-fault' ? `${f.claimId}/${f.subjectId}` : f.claimId;
115
+ const active = excuses.find((e) => !e.expired && (e.entry.pattern === target || (f.kind === 'removed-fault' && e.entry.kind === 'claim' && e.entry.pattern === f.claimId)));
116
+ if (active) {
117
+ active.used.push(target);
118
+ f.excusedBy = active.entry.pattern;
119
+ continue;
120
+ }
121
+ const expired = excuses.find((e) => e.expired && (e.entry.pattern === target || (f.kind === 'removed-fault' && e.entry.kind === 'claim' && e.entry.pattern === f.claimId)));
122
+ if (expired) {
123
+ expired.used.push(target);
124
+ f.expiredExcuse = expired.entry.pattern;
125
+ }
126
+ gating.push(f);
127
+ }
128
+
129
+ const order = { 'removed-claim': 0, 'removed-fault': 1, 'renamed-claim': 2 };
130
+ findings.sort((a, b) => order[a.kind] - order[b.kind] || a.claimId.localeCompare(b.claimId) || (a.subjectId ?? '').localeCompare(b.subjectId ?? ''));
131
+
132
+ return {
133
+ ref,
134
+ comparedAgainst: before ? 'claims-file' : 'nothing',
135
+ findings,
136
+ removed: gating,
137
+ reliedOn: excuses.filter((e) => !e.expired && e.used.length).map((e) => ({ pattern: e.entry.pattern, reason: e.entry.reason, ...(e.entry.expires ? { expires: e.entry.expires } : {}), ids: e.used })),
138
+ expired: excuses.filter((e) => e.expired && e.used.length).map((e) => ({ pattern: e.entry.pattern, reason: e.entry.reason, expires: e.entry.expires, ids: e.used })),
139
+ toolVersion,
140
+ };
141
+ }
142
+
143
+ export function renderRemoved(doc) {
144
+ if (doc.comparedAgainst === 'nothing') return `no claims file at ${doc.ref}; nothing to compare`;
145
+ const lines = [];
146
+ for (const f of doc.findings) {
147
+ const verdicts = f.lastVerdicts?.length ? ` — last ${f.lastVerdicts.length === 1 ? 'verdict' : 'verdicts'} ${f.lastVerdicts.join(', ')}` : '';
148
+ if (f.kind === 'removed-claim') lines.push(`REMOVED ${f.claimId} (${f.severity}, ${f.faults.length} fault${f.faults.length === 1 ? '' : 's'})${verdicts}${f.excusedBy ? ` [excused by ignore "${f.excusedBy}"]` : ''}${f.expiredExcuse ? ` [ignore "${f.expiredExcuse}" has EXPIRED]` : ''}`);
149
+ else if (f.kind === 'removed-fault') lines.push(`REMOVED ${f.claimId}/${f.subjectId} (${f.severity})${verdicts}${f.excusedBy ? ` [excused by ignore "${f.excusedBy}"]` : ''}${f.expiredExcuse ? ` [ignore "${f.expiredExcuse}" has EXPIRED]` : ''}`);
150
+ else lines.push(`renamed ${f.claimId} → ${f.renamedTo} (same statement; not a removal)`);
151
+ }
152
+ for (const r of doc.reliedOn) lines.push(`excused ${r.ids.join(', ')} by ignore "${r.pattern}": ${r.reason}${r.expires ? ` (expires ${r.expires})` : ''}`);
153
+ for (const e of doc.expired) lines.push(`EXPIRED ignore "${e.pattern}" (expired ${e.expires}) no longer excuses ${e.ids.join(', ')}`);
154
+ if (doc.removed.length) {
155
+ lines.push('', `${doc.removed.length} claim${doc.removed.length === 1 ? '' : 's'}/fault${doc.removed.length === 1 ? '' : 's'} present at ${doc.ref} no longer exist. Removing a claim is allowed — claims can be wrong, superseded or split — but it is the cheapest way to make a finding disappear, so it is never silent.`);
156
+ lines.push('Either restore them, or add a `claim` entry to testguard.ignore.json with a reason a reviewer will accept.');
157
+ } else if (doc.findings.length === 0) {
158
+ lines.push(`every claim and fault present at ${doc.ref} still exists`);
159
+ }
160
+ return lines.join('\n');
161
+ }
package/src/cli.mjs CHANGED
@@ -4,6 +4,7 @@ import { join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { ClaimsError } from './claims/load.mjs';
6
6
  import { PreconditionError } from './probe/worktree.mjs';
7
+ import { RUNNER_NAMES } from './probe/runners/index.mjs';
7
8
  import { GitError } from './git.mjs';
8
9
  import { SpecDocError } from './evidence/writer.mjs';
9
10
  import { probeCommand } from './commands/probe.mjs';
@@ -14,19 +15,25 @@ import { scaffoldCommand } from './commands/scaffold.mjs';
14
15
  import { statusCommand } from './commands/status.mjs';
15
16
  import { initCommand } from './commands/init.mjs';
16
17
  import { gateCommand } from './commands/gate.mjs';
18
+ import { replayCommand } from './commands/replay.mjs';
19
+ import { mcpCommand } from './commands/mcp.mjs';
20
+ import { admitCommand } from './commands/admit.mjs';
17
21
 
18
22
  const VERSION = JSON.parse(readFileSync(join(fileURLToPath(import.meta.url), '..', '..', 'package.json'), 'utf8')).version;
19
23
 
20
24
  const USAGE = `testguard ${VERSION} — proves a test suite defends the claims a project makes
21
25
 
22
26
  testguard status [dir] where the project is and the ONE next action; --json is the machine entry point
23
- testguard init [dir] install the agent layer: skill, session-start hook, AGENTS.md section, .gitignore lines
27
+ testguard init [dir] install the agent layer at the git root (skill, session-start hook, AGENTS.md section) and the .gitignore lines in [dir]
24
28
  testguard claims [dir] list the claims file and report drift against @claim annotations in code
25
29
  testguard probe [dir] inject each claim's faults, run its defenders, report what survived
26
30
  testguard baseline [dir] freeze today's unproven findings so only new ones gate
27
31
  testguard brief [dir] emit the blind-spot block for an agent's session-start context
28
32
  testguard gate [dir] fail when a changed source file carries no claim and no excusing ignore entry
29
33
  testguard scaffold <file> propose faults mechanically for one source file, as a draft claims document
34
+ testguard mcp serve the read-only status/brief/claims/evidence tools over MCP on stdio, so the loop works in any agent harness
35
+ testguard replay [dir] would this suite have caught the bugs that already escaped? Replays real fix commits and calibrates fault classes against them
36
+ testguard admit <test> --claim <ID> the two-gate rule as one verb: is this test green on HEAD and does it fail on every fault of the claim?
30
37
 
31
38
  probe
32
39
  --claims <path> claims file (default: <dir>/testguard.claims.json)
@@ -35,22 +42,52 @@ probe
35
42
  --out <path> evidence file (default: <dir>/.testguard/evidence.json)
36
43
  --baseline <path> baseline to gate against (default: <dir>/.testguard/baseline.json if present)
37
44
  --severity <level> gate only at or above (default: low)
38
- --ref <commit> probe this commit in the scratch worktree (default: HEAD)
45
+ --ref <commit> probe this commit in the scratch worktree (default: HEAD). An explicit --ref is honoured even when
46
+ defender/target files are dirty: a warning names them, the evidence records them (repo.ignoredDirty)
47
+ --ignore-dirty probe HEAD as committed although defender/target files are dirty (same warning and record)
39
48
  --claim <ID,ID> probe only these claims; writes .testguard/evidence-partial.json
40
49
  --include-dirty probe the working tree (a snapshot commit) instead of HEAD; uncommitted tests count
50
+ (without it, a dirty defender/target with the implicit HEAD is refused: the silent-mismatch trap)
41
51
  --verbose also print each killed fault (default: only unproven ones, plus a count)
52
+ --cost (probe, claims) report what the defenders cost, per claim and per defender file
53
+ --progress <mode> auto|tty|plain|ndjson|none. auto rewrites one line at a terminal and prints
54
+ append-only lines everywhere else, so a redirected log and CI see progress
55
+ instead of twenty silent minutes. Always on stderr; ndjson also streams verdicts
42
56
  --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)
57
+ --runner <name> vitest | jest | playwright | python | pytest | unittest | auto
58
+ (default: auto — first of vitest, jest, python that resolves. A defender under playwright's testDir
59
+ always runs under playwright, and a .py defender always under python, whatever the project runner.
60
+ python picks pytest when the interpreter can import it and stdlib unittest otherwise; pytest and
61
+ unittest pin that choice and fail rather than fall back. The engine that ran is what the evidence records.)
44
62
  --runner-cmd "<cmd>" custom runner; must contain {files} and {out}, e.g. "pnpm vitest run {files} --reporter=json --outputFile={out}"
45
63
  --node-modules <dir> node_modules to link into the scratch worktree (or TESTGUARD_NODE_MODULES)
64
+ --python <path> Python interpreter for .py defenders (or TESTGUARD_PYTHON; default: $VIRTUAL_ENV, then the project's
65
+ .venv/venv, then python3 on PATH). Resolved against your working tree, never the scratch worktree.
66
+ --serial run one test file at a time (vitest --no-file-parallelism, jest --runInBand, playwright --workers=1,
67
+ pytest -p no:xdist);
68
+ use it when another test runner is already running — probe warns and records the contention either way
46
69
  --in-place mutate the working tree instead of a scratch worktree
47
70
  --no-escalate do not re-run survivors against the whole suite
48
71
  --no-reuse re-probe claims whose inputs have not changed
49
72
  --quiet suppress the per-fault stream and ranked block; print only the summary and evidence path
73
+ --json the status document plus this run's result (records, newSinceBaseline, exitCode)
50
74
 
51
75
  scaffold --claim <ID> (put every proposal under this claim; copies it if it exists) --out <path> --json
52
76
  shapes: if-guard → if (false) · single-line guard/mutation removed · return <check> → return true
53
77
  · security flag/window/cost literal weakened · verify/validate/check call removed
78
+ · field dropped from a payload/allow-list/schema/merge · parameter-derived argument swapped for undefined/{}
79
+ mcp no options. JSON-RPC 2.0 over stdio; five READ-ONLY tools (status, brief, claims, evidence, next_command).
80
+ Nothing here runs a probe: next_command hands back the shell line for you to run where the person can see it.
81
+ Register it with your harness — testguard init --mcp prints the config for Claude Code, Cursor and Codex.
82
+ replay --since <range> commit range to search for fix commits (HEAD~50..HEAD, a tag, origin/main..HEAD)
83
+ --max <n> replay at most n fixes (they are slow: one worktree and N runs each)
84
+ --confirm <n> runs per verdict (default 3); a flaky failure reads as "the suite caught it", so mixed runs are never caught
85
+ --out <path> replay document (default: <dir>/.testguard/replay.json); the calibration goes beside it
86
+ reports, never gates: a bug that escaped is history, not a regression in this change
87
+ · one-line JSX element removed · on<Event> handler prop dropped
88
+ admit --claim <ID> (required) --fault <FID> (one fault only) --confirm <n> --json
89
+ 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
90
+ the test must be a declared or discovered defender of the claim (exit 3 otherwise); evidence goes to .testguard/evidence-partial.json
54
91
  gate --changed <ref> measure the change since merge-base(ref, HEAD); auto-detected in GitHub Actions / GitLab CI
55
92
  --include-dirty compare the working tree (staged, unstaged and untracked) instead of HEAD — the pre-commit shape
56
93
  --exclude <glob> (repeatable) more files that never carry claims; --explain lists the defaults
@@ -58,17 +95,26 @@ gate --changed <ref> measure the change since merge-base(ref, HEAD); aut
58
95
  --ignore <path> ignore file (default: <dir>/testguard.ignore.json; kind=path entries excuse files, with a reason)
59
96
  exit 0 every changed source file is claimed or excused · 1 unclaimed file · 2 cannot evaluate · 3 no reference
60
97
  status --json (exit 0 clean · 1 unproven/stale/unclaimed · 2 nothing to probe yet)
98
+ --evidence <path> read this evidence instead of .testguard/evidence.json (e.g. CI's, fetched as an artifact); staleness is still computed from the recorded input hashes, and both commits are named
61
99
  --changed <ref> also compute claim coverage of the change; unclaimed-changes then precedes every evidence state
62
- init --force (replace an existing skill file) --json
100
+ init --force (replace an existing skill file) --here (keep the agent layer in [dir] instead of the git root) --json
101
+ --ci-evidence github|gitlab write .testguard/fetch-ci-evidence.sh, an ON-DEMAND helper that downloads CI's evidence artifact and briefs from it; the session-start hook stays offline
102
+ agent layer (skill, SessionStart hook, AGENTS.md section) → git root; project layer (.gitignore lines) → [dir]
103
+ the hook prefers a local install, falls back to npx --no-install, never fetches; exit 1 if a written file is gitignored
104
+ --mcp print the MCP server config for Claude Code, Cursor and Codex (printed, never written: a harness config is yours)
63
105
  every command accepts --json; probe/baseline emit the status document plus their own result
64
106
  claims --json
107
+ --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
108
+ --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
65
109
  baseline --evidence <path> --out <path> --allow-provisional (freeze unconfirmed evidence; normally refused)
66
- brief --evidence <path> --baseline <path> --max <n> --text (print only; safe for hooks)
110
+ --restamp move head to the commit of a later CLEAN probe that reproduced the same fingerprints
111
+ (a baseline frozen from a snapshot points at the parent of the commit that carries its tests)
112
+ brief --evidence <path> --baseline <path> --max <n> --text (print only; safe for hooks) --markdown (print only, as a merge-request note)
67
113
 
68
114
  exit codes: 0 nothing new to prove · 1 unproven claims (or claim drift, or unclaimed changes) · 2 precondition failed · 3 usage
69
115
  `;
70
116
 
71
- const COMMANDS = { probe: probeCommand, claims: claimsCommand, baseline: baselineCommand, brief: briefCommand, scaffold: scaffoldCommand, status: statusCommand, init: initCommand, gate: gateCommand };
117
+ const COMMANDS = { mcp: mcpCommand, replay: replayCommand, probe: probeCommand, claims: claimsCommand, baseline: baselineCommand, brief: briefCommand, scaffold: scaffoldCommand, status: statusCommand, init: initCommand, gate: gateCommand, admit: admitCommand };
72
118
 
73
119
  export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n'), err: (s) => process.stderr.write(s + '\n') }) {
74
120
  let parsed;
@@ -85,18 +131,27 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
85
131
  baseline: { type: 'string' },
86
132
  severity: { type: 'string', default: 'low' },
87
133
  max: { type: 'string', default: '20' },
88
- ref: { type: 'string', default: 'HEAD' },
134
+ ref: { type: 'string' },
135
+ 'ignore-dirty': { type: 'boolean', default: false },
136
+ serial: { type: 'boolean', default: false },
89
137
  claim: { type: 'string' },
138
+ fault: { type: 'string' },
90
139
  'include-dirty': { type: 'boolean', default: false },
91
140
  changed: { type: 'string' },
141
+ since: { type: 'string' },
92
142
  exclude: { type: 'string', multiple: true },
93
143
  strict: { type: 'boolean', default: false },
94
144
  explain: { type: 'boolean', default: false },
95
145
  ignore: { type: 'string' },
96
146
  'allow-provisional': { type: 'boolean', default: false },
147
+ restamp: { type: 'boolean', default: false },
97
148
  force: { type: 'boolean', default: false },
149
+ mcp: { type: 'boolean', default: false },
150
+ 'ci-evidence': { type: 'string' },
151
+ here: { type: 'boolean', default: false },
98
152
  verbose: { type: 'boolean', default: false },
99
153
  'runner-cmd': { type: 'string' },
154
+ python: { type: 'string' },
100
155
  runner: { type: 'string', default: 'auto' },
101
156
  'node-modules': { type: 'string' },
102
157
  'in-place': { type: 'boolean', default: false },
@@ -105,6 +160,9 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
105
160
  quiet: { type: 'boolean', default: false },
106
161
  json: { type: 'boolean', default: false },
107
162
  text: { type: 'boolean', default: false },
163
+ markdown: { type: 'boolean', default: false },
164
+ cost: { type: 'boolean', default: false },
165
+ progress: { type: 'string' },
108
166
  help: { type: 'boolean', short: 'h', default: false },
109
167
  version: { type: 'boolean', short: 'v', default: false },
110
168
  },
@@ -130,8 +188,8 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
130
188
  io.err(USAGE);
131
189
  return 3;
132
190
  }
133
- if (!['vitest', 'jest', 'auto'].includes(values.runner)) {
134
- io.err('--runner must be vitest, jest or auto');
191
+ if (![...RUNNER_NAMES, 'auto'].includes(values.runner)) {
192
+ io.err(`--runner must be one of ${RUNNER_NAMES.join(', ')} or auto`);
135
193
  return 3;
136
194
  }
137
195
  if (!['critical', 'high', 'medium', 'low'].includes(values.severity)) {
@@ -139,7 +197,7 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
139
197
  return 3;
140
198
  }
141
199
  try {
142
- const projectDir = command === 'scaffold' ? resolve('.') : resolve(dirArg ?? '.');
200
+ const projectDir = command === 'scaffold' || command === 'admit' ? resolve('.') : resolve(dirArg ?? '.');
143
201
  return await handler({ projectDir, file: dirArg, values, version: VERSION }, io);
144
202
  } catch (e) {
145
203
  if (e instanceof ClaimsError || e instanceof PreconditionError || e instanceof GitError || e instanceof SpecDocError) {
@@ -0,0 +1,116 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join, relative, resolve } from 'node:path';
3
+ import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
4
+ import { probe } from '../probe/probe.mjs';
5
+ import { writeSpecDoc } from '../evidence/writer.mjs';
6
+ import { resolveDefenders } from '../probe/runners/shared.mjs';
7
+ import { discoverDefenders } from '../probe/discover.mjs';
8
+ import { PreconditionError } from '../probe/worktree.mjs';
9
+ import { hintFor } from '../brief/brief.mjs';
10
+ import { formatVerdict, PROVISIONAL_WARNING } from '../render.mjs';
11
+ import { decideAdmission } from '../admit/admit.mjs';
12
+
13
+ /** The project is wherever the claims file is, walking up from the test file; else cwd. */
14
+ function findProjectDir(testAbs, claimsFlag) {
15
+ if (claimsFlag) return dirname(resolve(claimsFlag));
16
+ let dir = dirname(testAbs);
17
+ for (let hop = 0; hop < 20; hop++) {
18
+ if (existsSync(defaultClaimsPath(dir))) return dir;
19
+ const up = dirname(dir);
20
+ if (up === dir) break;
21
+ dir = up;
22
+ }
23
+ return resolve('.');
24
+ }
25
+
26
+ /**
27
+ * `testguard admit <test-file> --claim <ID>`: the two-gate rule as one verb.
28
+ * Sugar over `probe --claim <ID> --include-dirty`; no second engine, so the
29
+ * decision here can never disagree with the gate.
30
+ */
31
+ export async function admitCommand({ file, values, version }, io) {
32
+ if (!file || !values.claim) {
33
+ io.err('usage: testguard admit <test-file> --claim <ID> [--fault <FID>] [--confirm <n>] [--json]');
34
+ return 3;
35
+ }
36
+ const confirmRuns = Number(values.confirm);
37
+ const budgetMs = Number(values.budget);
38
+ if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
39
+ io.err('--confirm must be a positive integer and --budget at least 1000');
40
+ return 3;
41
+ }
42
+ const testAbs = resolve(file);
43
+ if (!existsSync(testAbs)) throw new PreconditionError(`no such file: ${file}`);
44
+ const projectDir = findProjectDir(testAbs, values.claims);
45
+ const testRel = relative(projectDir, testAbs);
46
+ if (testRel.startsWith('..')) throw new PreconditionError(`${file} is outside the project directory ${projectDir}`);
47
+
48
+ const all = loadClaims(values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir));
49
+ const claimId = values.claim.trim();
50
+ const claim = all.claims.find((c) => c.id === claimId);
51
+ if (!claim) throw new PreconditionError(`--claim: unknown claim id ${claimId}`);
52
+ const faults = values.fault ? claim.faults.filter((f) => f.id === values.fault) : claim.faults;
53
+ if (faults.length === 0) throw new PreconditionError(`--fault: ${claimId} has no fault ${values.fault}`);
54
+
55
+ // The named test must be a defender of the claim, declared or discovered;
56
+ // otherwise `killed` below would be someone else's test doing the work.
57
+ const declared = claim.defendedBy?.length ? resolveDefenders(projectDir, claim.defendedBy) : null;
58
+ const isDefender = declared ? declared.includes(testRel) : faults.some((f) => discoverDefenders(projectDir, f.file).includes(testRel));
59
+ if (!isDefender) {
60
+ io.err(declared
61
+ ? `${testRel} is not a defender of ${claimId}. Add it: "defendedBy": [${[...claim.defendedBy, testRel].map((d) => JSON.stringify(d)).join(', ')}] in testguard.claims.json, then run admit again.`
62
+ : `${testRel} does not import ${[...new Set(faults.map((f) => f.file))].join(' or ')}, so it cannot be discovered as a defender of ${claimId}. Import the module under test, or declare "defendedBy": ["${testRel}"] on the claim.`);
63
+ return 3;
64
+ }
65
+
66
+ const provisional = confirmRuns < 3;
67
+ if (provisional && !values.quiet) io.err(PROVISIONAL_WARNING(confirmRuns));
68
+ const command = `testguard probe --claim ${claimId} --include-dirty --confirm ${confirmRuns} --no-escalate`;
69
+ const evidence = await probe({
70
+ projectDir,
71
+ claims: { ...all, claims: [{ ...claim, faults }] },
72
+ confirmRuns,
73
+ budgetMs,
74
+ mode: 'worktree',
75
+ includeDirty: true,
76
+ only: [claimId],
77
+ escalate: false,
78
+ runnerCommand: values['runner-cmd'],
79
+ runnerName: values.runner,
80
+ nodeModules: values['node-modules'] ? resolve(values['node-modules']) : process.env.TESTGUARD_NODE_MODULES,
81
+ toolVersion: version,
82
+ 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,
83
+ });
84
+ if (process.stderr.isTTY && !values.quiet && !values.json) process.stderr.write('\r\x1b[K');
85
+ const outPath = values.out ? resolve(values.out) : join(projectDir, '.testguard', 'evidence-partial.json');
86
+ writeSpecDoc('evidence', outPath, evidence);
87
+
88
+ const decision = decideAdmission(evidence.records);
89
+ const result = {
90
+ admitted: decision.admitted,
91
+ provisional,
92
+ claim: claimId,
93
+ test: testRel,
94
+ faults: evidence.records.map((r) => ({ id: r.subject.id, verdict: r.verdict, ...(r.detail.reason ? { reason: r.detail.reason } : {}), hint: hintFor(r) })),
95
+ evidence: outPath,
96
+ command,
97
+ };
98
+ if (values.json) {
99
+ io.out(JSON.stringify(result, null, 2));
100
+ } else {
101
+ for (const r of evidence.records) io.out(` ${formatVerdict(r.verdict, provisional).padEnd(15)} ${claimId}/${r.subject.id} ${r.subject.description}${r.detail.reason ? ` [${r.detail.reason}]` : ''}`);
102
+ io.out('');
103
+ if (decision.admitted) {
104
+ io.out(provisional
105
+ ? `ADMITTED? — ${testRel} kills ${evidence.records.length === 1 ? 'the fault' : `all ${evidence.records.length} faults`} of ${claimId} at --confirm ${confirmRuns}. Provisional: confirm with --confirm 3 before committing.`
106
+ : `ADMITTED — ${testRel} passes on HEAD and fails on ${evidence.records.length === 1 ? 'the fault' : `all ${evidence.records.length} faults`} of ${claimId}, ${confirmRuns}/${confirmRuns}. Commit it.`);
107
+ } else {
108
+ const b = decision.blocking;
109
+ io.out(`NOT ADMITTED: ${b.verdict}${b.detail.reason ? ` (${b.detail.reason})` : ''} on ${claimId}/${b.subject.id}`);
110
+ io.out(` ${hintFor(b)}`);
111
+ }
112
+ io.out(`evidence: ${outPath} (partial; not the canonical evidence file)`);
113
+ }
114
+ if (provisional && !values.quiet && !values.json) io.err(PROVISIONAL_WARNING(confirmRuns));
115
+ return decision.admitted ? 0 : 1;
116
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import { readSpecDoc, writeSpecDoc } from '../evidence/writer.mjs';
4
- import { buildBaseline } from '../baseline/baseline.mjs';
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
7
 
@@ -16,15 +16,29 @@ export async function baselineCommand({ projectDir, values, version }, io) {
16
16
  io.err(`error: ${evPath} is provisional (confirmRuns ${evidence.run.confirmRuns} < 3); a baseline must be frozen from confirmed evidence. Re-run probe with --confirm 3, or pass --allow-provisional if you accept unconfirmed findings as the frozen contract.`);
17
17
  return 2;
18
18
  }
19
- const baseline = buildBaseline(evidence);
20
19
  const outPath = values.out ? resolve(values.out) : baselinePath(projectDir);
20
+ if (values.restamp) {
21
+ if (!existsSync(outPath)) {
22
+ io.err(`error: no baseline at ${outPath} to re-stamp; run \`testguard baseline\` first`);
23
+ return 2;
24
+ }
25
+ const r = restampBaseline(readSpecDoc('baseline', outPath), evidence);
26
+ if (!r.ok) {
27
+ io.err(`error: cannot re-stamp: ${r.reason}`);
28
+ return 2;
29
+ }
30
+ writeSpecDoc('baseline', outPath, r.baseline);
31
+ 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}`);
32
+ return 0;
33
+ }
34
+ const baseline = buildBaseline(evidence);
21
35
  writeSpecDoc('baseline', outPath, baseline);
22
36
  const n = Object.values(baseline.fingerprints).reduce((a, b) => a + b, 0);
23
37
  if (values.json) {
24
38
  io.out(JSON.stringify({ ...computeStatus({ projectDir, toolVersion: version }), baseline: { path: outPath, frozen: n } }, null, 2));
25
39
  return 0;
26
40
  }
27
- io.out(`baseline: ${n} unproven finding${n === 1 ? '' : 's'} frozen at ${baseline.head.slice(0, 12)}${baseline.dirty ? ' (working tree was dirty)' : ''}${evidence.run.provisional ? ' — FROM PROVISIONAL EVIDENCE (--allow-provisional)' : ''} → ${outPath}`);
41
+ 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}`);
28
42
  io.out('Commit this file; from now on only new findings gate. Ignore the regenerated ones — add to .gitignore:');
29
43
  io.out(' .testguard/evidence.json');
30
44
  io.out(' .testguard/brief.json');