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.
- package/CHANGELOG.md +291 -0
- package/README.md +321 -27
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +109 -1
- package/spec/README.md +4 -2
- package/spec/lib/validate.mjs +75 -1
- package/spec/schemas/baseline.schema.json +8 -0
- package/spec/schemas/brief.schema.json +11 -1
- package/spec/schemas/common.schema.json +32 -1
- package/spec/schemas/evidence.schema.json +374 -63
- package/spec/schemas/gate.schema.json +52 -0
- package/spec/schemas/replay.schema.json +85 -0
- package/spec/schemas/status.schema.json +269 -45
- package/src/admit/admit.mjs +17 -0
- package/src/baseline/baseline.mjs +31 -2
- package/src/brief/brief.mjs +96 -8
- package/src/claims/removed.mjs +161 -0
- package/src/cli.mjs +69 -12
- package/src/commands/admit.mjs +116 -0
- package/src/commands/baseline.mjs +17 -3
- package/src/commands/brief.mjs +26 -12
- package/src/commands/claims.mjs +35 -6
- package/src/commands/gate.mjs +47 -0
- package/src/commands/init.mjs +19 -6
- package/src/commands/mcp.mjs +12 -0
- package/src/commands/probe.mjs +12 -3
- package/src/commands/replay.mjs +63 -0
- package/src/commands/status.mjs +4 -1
- package/src/gate/changed.mjs +222 -0
- package/src/git.mjs +5 -0
- package/src/init/init.mjs +182 -29
- package/src/init/templates/SKILL.md +21 -3
- package/src/mcp/server.mjs +104 -0
- package/src/mcp/tools.mjs +114 -0
- package/src/probe/contention.mjs +60 -0
- package/src/probe/discover.mjs +12 -3
- package/src/probe/independence.mjs +59 -0
- package/src/probe/mocks.mjs +120 -0
- package/src/probe/probe.mjs +126 -18
- package/src/probe/rank.mjs +73 -15
- package/src/probe/runners/index.mjs +49 -5
- package/src/probe/runners/jest.mjs +3 -3
- package/src/probe/runners/playwright.mjs +133 -0
- package/src/probe/runners/shared.mjs +70 -13
- package/src/probe/runners/vitest.mjs +4 -3
- package/src/render.mjs +2 -2
- package/src/replay/label.mjs +64 -0
- package/src/replay/replay.mjs +309 -0
- package/src/scaffold/producers.mjs +217 -6
- package/src/scaffold/scaffold.mjs +9 -3
- package/src/status/status.mjs +88 -7
package/src/status/status.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
64
|
-
|
|
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
|
|
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 = [
|
|
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');
|