testguard-cli 0.4.0 → 0.6.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 (51) hide show
  1. package/CHANGELOG.md +291 -0
  2. package/README.md +321 -27
  3. package/package.json +1 -1
  4. package/spec/GATE-SEMANTICS.md +109 -1
  5. package/spec/README.md +4 -2
  6. package/spec/lib/validate.mjs +75 -1
  7. package/spec/schemas/baseline.schema.json +8 -0
  8. package/spec/schemas/brief.schema.json +11 -1
  9. package/spec/schemas/common.schema.json +32 -1
  10. package/spec/schemas/evidence.schema.json +374 -63
  11. package/spec/schemas/gate.schema.json +52 -0
  12. package/spec/schemas/replay.schema.json +85 -0
  13. package/spec/schemas/status.schema.json +269 -45
  14. package/src/admit/admit.mjs +17 -0
  15. package/src/baseline/baseline.mjs +31 -2
  16. package/src/brief/brief.mjs +96 -8
  17. package/src/claims/removed.mjs +161 -0
  18. package/src/cli.mjs +69 -12
  19. package/src/commands/admit.mjs +116 -0
  20. package/src/commands/baseline.mjs +17 -3
  21. package/src/commands/brief.mjs +26 -12
  22. package/src/commands/claims.mjs +35 -6
  23. package/src/commands/gate.mjs +47 -0
  24. package/src/commands/init.mjs +19 -6
  25. package/src/commands/mcp.mjs +12 -0
  26. package/src/commands/probe.mjs +12 -3
  27. package/src/commands/replay.mjs +63 -0
  28. package/src/commands/status.mjs +4 -1
  29. package/src/gate/changed.mjs +222 -0
  30. package/src/git.mjs +5 -0
  31. package/src/init/init.mjs +182 -29
  32. package/src/init/templates/SKILL.md +21 -3
  33. package/src/mcp/server.mjs +104 -0
  34. package/src/mcp/tools.mjs +114 -0
  35. package/src/probe/contention.mjs +60 -0
  36. package/src/probe/discover.mjs +12 -3
  37. package/src/probe/independence.mjs +59 -0
  38. package/src/probe/mocks.mjs +120 -0
  39. package/src/probe/probe.mjs +126 -18
  40. package/src/probe/rank.mjs +73 -15
  41. package/src/probe/runners/index.mjs +49 -5
  42. package/src/probe/runners/jest.mjs +3 -3
  43. package/src/probe/runners/playwright.mjs +133 -0
  44. package/src/probe/runners/shared.mjs +70 -13
  45. package/src/probe/runners/vitest.mjs +4 -3
  46. package/src/render.mjs +2 -2
  47. package/src/replay/label.mjs +64 -0
  48. package/src/replay/replay.mjs +309 -0
  49. package/src/scaffold/producers.mjs +217 -6
  50. package/src/scaffold/scaffold.mjs +9 -3
  51. package/src/status/status.mjs +88 -7
@@ -7,6 +7,8 @@ import { hashFile, sha256 } from '../util/hash.mjs';
7
7
  import { resolveDefenders } from '../probe/runners/shared.mjs';
8
8
  import { discoverDefenders } from '../probe/discover.mjs';
9
9
  import { sortForReport } from '../render.mjs';
10
+ import { computeChangedGate, defaultIgnorePath } from '../gate/changed.mjs';
11
+ import { headSha, isAncestor } from '../git.mjs';
10
12
 
11
13
  export const faultContentHash = (fault) => sha256(`${fault.find}\n${fault.replace}`);
12
14
 
@@ -22,7 +24,13 @@ const P = (projectDir) => ({
22
24
  * Reads the claims file, evidence, baseline and the working tree; never
23
25
  * trusts a cached verdict whose inputs have changed.
24
26
  */
25
- export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt = new Date().toISOString(), paths = P(projectDir), max = 20 }) {
27
+ /**
28
+ * `evidence` overrides the project's own evidence file: a document taken on
29
+ * ANOTHER commit (CI's, fetched as an artifact) is legitimate input. Staleness
30
+ * is computed from the recorded input hashes exactly as for a local run, so a
31
+ * foreign document is trusted only for the faults whose inputs still match.
32
+ */
33
+ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt = new Date().toISOString(), paths = P(projectDir), max = 20, changedRef, includeDirty = false, evidence: evidenceOverride }) {
26
34
  const rel = (p) => relative(projectDir, p) || '.';
27
35
  const doc = {
28
36
  schemaVersion: 1,
@@ -35,9 +43,35 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
35
43
  stale: [],
36
44
  changedFaults: [],
37
45
  findings: [],
46
+ notes: [],
38
47
  paths: {},
39
48
  };
40
- if (!existsSync(paths.claims)) return doc;
49
+ // ── Claim coverage of the change, when a reference is known ──
50
+ // Computed before anything about evidence: a change that touches unclaimed
51
+ // code is the finding every field report shared, and TestGuard is silent
52
+ // about unclaimed code by construction. The claim is written first.
53
+ // The reference is the caller's: this function never reads the environment,
54
+ // so a library caller or a test in a temp directory is never surprised by CI.
55
+ const ref = changedRef;
56
+ let changes;
57
+ if (ref) {
58
+ const g = computeChangedGate({ projectDir, ref, includeDirty, toolVersion });
59
+ changes = { ref: g.ref, base: g.base, includeDirty: g.includeDirty, changed: g.changed, evaluated: g.evaluated, excluded: g.excluded.length, uncovered: g.uncovered, reliedOn: g.reliedOn, expired: g.expired };
60
+ doc.changes = changes;
61
+ if (existsSync(defaultIgnorePath(projectDir))) doc.paths.ignore = rel(defaultIgnorePath(projectDir));
62
+ }
63
+ const unclaimedWhy = () => {
64
+ const files = changes.uncovered.map((u) => u.file);
65
+ return `${files.length} changed file${files.length === 1 ? '' : 's'} since ${changes.ref} carr${files.length === 1 ? 'ies' : 'y'} no claim and no excusing ignore entry: ${files.slice(0, 3).join(', ')}${files.length > 3 ? ', …' : ''}. State the claim before writing more code — TestGuard is silent about unclaimed code by construction.`;
66
+ };
67
+
68
+ if (!existsSync(paths.claims)) {
69
+ if (changes?.uncovered.length) {
70
+ const u = changes.uncovered[0];
71
+ doc.next = { action: 'scaffold', command: `testguard scaffold ${u.file}`, why: `No testguard.claims.json in this project, and ${unclaimedWhy()}`, file: u.file };
72
+ }
73
+ return doc;
74
+ }
41
75
  doc.paths.claims = rel(paths.claims);
42
76
 
43
77
  const claims = loadClaims(paths.claims);
@@ -46,7 +80,15 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
46
80
  const faultIndex = new Map();
47
81
  for (const c of claims.claims) for (const f of c.faults) faultIndex.set(`${c.id}/${f.id}`, { claim: c, fault: f });
48
82
 
49
- const hasEvidence = existsSync(paths.evidence);
83
+ if (changes && changes.uncovered.length > 0) {
84
+ const u = changes.uncovered[0];
85
+ doc.state = 'unclaimed-changes';
86
+ doc.next = { action: 'claim', command: u.suggestion, why: unclaimedWhy(), file: u.file };
87
+ return doc;
88
+ }
89
+
90
+ const evidenceFile = evidenceOverride ?? paths.evidence;
91
+ const hasEvidence = existsSync(evidenceFile);
50
92
  const hasProvisional = existsSync(paths.provisional);
51
93
  if (hasProvisional) doc.paths.provisionalEvidence = rel(paths.provisional);
52
94
  if (!hasEvidence) {
@@ -60,11 +102,29 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
60
102
  }
61
103
  return doc;
62
104
  }
63
- doc.paths.evidence = rel(paths.evidence);
64
- const evidence = readSpecDoc('evidence', paths.evidence);
105
+ doc.paths.evidence = rel(evidenceFile);
106
+ doc.evidenceSource = evidenceOverride ? 'provided' : 'local';
107
+ const evidence = readSpecDoc('evidence', evidenceFile);
108
+ // A verdict is about a commit. When the evidence describes a different one,
109
+ // say both: "looks right and is not" is the failure mode this prevents.
110
+ doc.evidenceHead = evidence.run.repo.snapshot ?? evidence.run.repo.head;
111
+ const localHead = (() => { try { return headSha(projectDir); } catch { return null; } })();
112
+ if (localHead) doc.head = localHead;
113
+
65
114
  const baseline = existsSync(paths.baseline) ? readSpecDoc('baseline', paths.baseline) : undefined;
66
115
  if (baseline) doc.paths.baseline = rel(paths.baseline);
67
116
 
117
+ // ── Baseline provenance: informational, never a state. A baseline frozen
118
+ // from a snapshot or a dirty tree names the PARENT of the commit that carries
119
+ // its tests; once HEAD moved on, say so and name the way to re-stamp. ──
120
+ if (baseline?.head) {
121
+ const head = headSha(projectDir);
122
+ if (head && head !== baseline.head) {
123
+ if (baseline.snapshot || baseline.dirty) doc.notes.push(`baseline was frozen from a ${baseline.snapshot ? 'working-tree snapshot' : 'dirty tree'} at ${baseline.head.slice(0, 7)}; HEAD is ${head.slice(0, 7)} — it predates the commit that carries its tests. After a clean probe of HEAD reproduces the same fingerprints: testguard baseline --restamp`);
124
+ else if (!isAncestor(projectDir, baseline.head, head)) doc.notes.push(`baseline head ${baseline.head.slice(0, 7)} is not an ancestor of HEAD ${head.slice(0, 7)} (rewritten or foreign history); re-probe and re-baseline if the fingerprints changed`);
125
+ }
126
+ }
127
+
68
128
  // ── Staleness: does the evidence still describe this claims file and this tree? ──
69
129
  const probed = new Map(evidence.records.map((r) => [`${r.claim.id}/${r.subject.id}`, r]));
70
130
  for (const key of faultIndex.keys()) if (!probed.has(key)) doc.stale.push(`fault ${key} has never been probed`);
@@ -91,6 +151,14 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
91
151
  const counts = {};
92
152
  for (const r of evidence.records) counts[r.verdict] = (counts[r.verdict] ?? 0) + 1;
93
153
  doc.counts.byVerdict = counts;
154
+ // L3 independence and observed flakiness, as numbers rather than adjectives.
155
+ const kills = evidence.records.filter((r) => r.verdict === 'killed');
156
+ if (kills.length) doc.counts.killedCoAuthored = kills.filter((r) => r.detail.independence?.class === 'co-authored').length;
157
+ const flaky = evidence.records.filter((r) => r.detail.flakeRate?.failures > 0);
158
+ if (flaky.length) {
159
+ doc.counts.flakyDefenderSets = new Set(flaky.map((r) => r.defenders.resolved.join('\n'))).size;
160
+ doc.counts.worstFlakeRate = Math.max(...flaky.map((r) => r.detail.flakeRate.failures / r.detail.flakeRate.runs));
161
+ }
94
162
  doc.counts.new = g.new.length + g.belowFloor.length;
95
163
  doc.counts.baselined = g.baselined.length;
96
164
  const ranked = sortForReport([...g.new, ...g.belowFloor, ...g.baselined]);
@@ -137,7 +205,7 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
137
205
  action: fixClaim ? 'probe' : 'write-test',
138
206
  command: fixClaim
139
207
  ? `edit ${top.claimId}/${top.subjectId} in testguard.claims.json, then: testguard probe --claim ${top.claimId} --include-dirty`
140
- : `write a test in ${where} that fails on ${top.claimId}/${top.subjectId} and passes on HEAD, then: testguard probe --claim ${top.claimId} --include-dirty`,
208
+ : `write a test in ${where} that fails on ${top.claimId}/${top.subjectId} and passes on HEAD, then: testguard admit ${defenders[0] ?? '<that test file>'} --claim ${top.claimId}`,
141
209
  why: whyByVerdict[top.verdict] ?? `${top.claimId}/${top.subjectId} is ${top.verdict}.`,
142
210
  target: { claimId: top.claimId, subjectId: top.subjectId, ...(top.file ? { file: top.file } : {}), verdict: top.verdict },
143
211
  };
@@ -154,9 +222,22 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
154
222
  }
155
223
 
156
224
  export function renderStatus(doc) {
157
- const lines = [`state: ${doc.state}${doc.provisional ? ' (provisional)' : ''} — ${doc.counts.claims} claims / ${doc.counts.faults} faults` + (doc.counts.byVerdict ? `; ${Object.entries(doc.counts.byVerdict).map(([k, v]) => `${v} ${k}`).join(', ')}; ${doc.counts.new ?? 0} new, ${doc.counts.baselined ?? 0} baselined` : '')];
225
+ const lines = [];
226
+ if (doc.evidenceSource === 'provided') {
227
+ lines.push(`evidence: ${doc.paths.evidence} (provided)` + (doc.evidenceHead ? ` describes ${doc.evidenceHead.slice(0, 7)}` : '') + (doc.head && doc.evidenceHead && doc.head !== doc.evidenceHead ? `; this tree is ${doc.head.slice(0, 7)}` : ''));
228
+ }
229
+ lines.push(`state: ${doc.state}${doc.provisional ? ' (provisional)' : ''} — ${doc.counts.claims} claims / ${doc.counts.faults} faults` + (doc.counts.byVerdict ? `; ${Object.entries(doc.counts.byVerdict).map(([k, v]) => `${v} ${k}`).join(', ')}; ${doc.counts.new ?? 0} new, ${doc.counts.baselined ?? 0} baselined` : '')
230
+ );
231
+ if (doc.changes) {
232
+ const c = doc.changes;
233
+ lines.push(`changes: ${c.changed} file${c.changed === 1 ? '' : 's'} since ${c.ref}; ${c.evaluated} evaluated, ${c.excluded} excluded, ${c.uncovered.length} unclaimed`);
234
+ for (const u of c.uncovered) lines.push(`UNCLAIMED ${u.file} (${u.kind}) → ${u.suggestion}`);
235
+ for (const r of c.reliedOn) lines.push(`excused ${r.files.join(', ')} by ignore "${r.pattern}": ${r.reason}`);
236
+ for (const e of c.expired) lines.push(`EXPIRED ignore "${e.pattern}" no longer excuses ${e.files.join(', ')}`);
237
+ }
158
238
  for (const c of doc.changedFaults) lines.push(`CHANGED ${c.claimId}/${c.subjectId} edited since it ${c.previousVerdict} (${c.file})`);
159
239
  for (const s of doc.stale.slice(0, 8)) lines.push(`stale ${s}`);
240
+ for (const n of doc.notes ?? []) lines.push(`note ${n}`);
160
241
  lines.push(`next: [${doc.next.action}] ${doc.next.command}`);
161
242
  lines.push(`why: ${doc.next.why}`);
162
243
  return lines.join('\n');