testguard-cli 0.6.0 → 0.8.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 (45) hide show
  1. package/CHANGELOG.md +489 -0
  2. package/README.md +287 -34
  3. package/package.json +2 -2
  4. package/spec/GATE-SEMANTICS.md +210 -8
  5. package/spec/README.md +32 -11
  6. package/spec/lib/validate.mjs +125 -14
  7. package/spec/lib/wilson.mjs +112 -0
  8. package/spec/schemas/baseline.schema.json +7 -7
  9. package/spec/schemas/brief.schema.json +12 -12
  10. package/spec/schemas/calibration.schema.json +96 -26
  11. package/spec/schemas/claims.schema.json +129 -25
  12. package/spec/schemas/common.schema.json +127 -29
  13. package/spec/schemas/evidence.schema.json +175 -73
  14. package/spec/schemas/gate.schema.json +12 -12
  15. package/spec/schemas/ignore.schema.json +4 -4
  16. package/spec/schemas/replay.schema.json +10 -10
  17. package/spec/schemas/status.schema.json +25 -25
  18. package/src/cli.mjs +20 -5
  19. package/src/commands/claims.mjs +22 -2
  20. package/src/commands/probe.mjs +24 -4
  21. package/src/commands/replay.mjs +1 -1
  22. package/src/gate/changed.mjs +9 -2
  23. package/src/probe/attribution.mjs +123 -0
  24. package/src/probe/classify.mjs +18 -2
  25. package/src/probe/cost.mjs +205 -0
  26. package/src/probe/discover.mjs +14 -6
  27. package/src/probe/inject.mjs +41 -9
  28. package/src/probe/mocks.mjs +2 -0
  29. package/src/probe/probe.mjs +235 -52
  30. package/src/probe/progress.mjs +87 -0
  31. package/src/probe/pyimports.mjs +287 -0
  32. package/src/probe/rank.mjs +2 -0
  33. package/src/probe/runners/index.mjs +38 -11
  34. package/src/probe/runners/jest.mjs +13 -1
  35. package/src/probe/runners/playwright.mjs +9 -0
  36. package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
  37. package/src/probe/runners/python/_testguard_report.py +141 -0
  38. package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
  39. package/src/probe/runners/python.mjs +316 -0
  40. package/src/probe/runners/shared.mjs +10 -1
  41. package/src/probe/runners/vitest.mjs +13 -1
  42. package/src/replay/replay.mjs +50 -22
  43. package/src/scaffold/producers.python.mjs +209 -0
  44. package/src/scaffold/scaffold.mjs +86 -32
  45. package/src/util/glob.mjs +7 -1
@@ -0,0 +1,205 @@
1
+ /**
2
+ * What a probe costs, derived from evidence that already exists (issue #67).
3
+ *
4
+ * A probe's wall clock is not a function of how many claims there are. It is
5
+ * `(baseline runs + faults × confirm runs) × the cost of the claim's defender
6
+ * SET`, per claim. One slow acceptance test named as a defender by five claims
7
+ * is therefore paid for thirty times, and nothing in the output says so — the
8
+ * only symptom is that the gate takes twenty-four minutes and nobody knows
9
+ * which line to blame.
10
+ *
11
+ * Every number here is read back out of `durationMs` on the runs the probe
12
+ * already recorded. Nothing re-measures, nothing spawns, nothing guesses: a
13
+ * cost report costs the price of reading one JSON file.
14
+ *
15
+ * ATTRIBUTION, HONESTLY. Runs execute a claim's whole defender set together,
16
+ * so the runner never says how much of a run belonged to which file. Per-file
17
+ * `ms` is therefore the time of every run that *included* that file: an upper
18
+ * bound on what removing it could save, and, when a claim names several
19
+ * defenders, a number that overlaps with its siblings. The per-file figures do
20
+ * not sum to the total, by construction. `totalMs` is the only additive one.
21
+ */
22
+
23
+ /** Every run a record paid for, in the order the probe made them. */
24
+ export const runsOf = (record) => [
25
+ ...(record.detail?.baselineRuns ?? []),
26
+ ...(record.detail?.probeRuns ?? []),
27
+ ...(record.detail?.escalationRuns ?? []),
28
+ ];
29
+
30
+ /**
31
+ * What one record cost. A reused record reports the duration of the runs it
32
+ * reused, which is the right number for planning — it is what the record costs
33
+ * whenever reuse does not apply — and `reused` marks it so a reader does not
34
+ * read it as wall clock just spent.
35
+ */
36
+ export function recordCost(record) {
37
+ const runs = runsOf(record);
38
+ return {
39
+ ms: runs.reduce((a, r) => a + (r.durationMs ?? 0), 0),
40
+ runs: runs.length,
41
+ reused: Boolean(record.reusedFrom),
42
+ };
43
+ }
44
+
45
+ /** Per claim: what its faults cost in total, most expensive first. */
46
+ export function claimCosts(records) {
47
+ const by = new Map();
48
+ for (const r of records) {
49
+ const c = recordCost(r);
50
+ const e = by.get(r.claim.id) ?? { claimId: r.claim.id, ms: 0, runs: 0, faults: 0, reused: 0, defenders: new Set() };
51
+ e.ms += c.ms;
52
+ e.runs += c.runs;
53
+ e.faults += 1;
54
+ if (c.reused) e.reused += 1;
55
+ for (const d of r.defenders?.resolved ?? []) e.defenders.add(d);
56
+ by.set(r.claim.id, e);
57
+ }
58
+ return [...by.values()]
59
+ .map((e) => ({ ...e, defenders: [...e.defenders].sort() }))
60
+ .sort((a, b) => b.ms - a.ms || a.claimId.localeCompare(b.claimId));
61
+ }
62
+
63
+ /**
64
+ * Per defender file: the time of every run that included it, and the claims
65
+ * that named it. See the attribution note above — this is a ceiling, not a
66
+ * share, and the figures overlap.
67
+ */
68
+ export function defenderCosts(records) {
69
+ const by = new Map();
70
+ for (const r of records) {
71
+ const c = recordCost(r);
72
+ for (const file of r.defenders?.resolved ?? []) {
73
+ const e = by.get(file) ?? { file, ms: 0, runs: 0, claims: new Set() };
74
+ e.ms += c.ms;
75
+ e.runs += c.runs;
76
+ e.claims.add(r.claim.id);
77
+ by.set(file, e);
78
+ }
79
+ }
80
+ return [...by.values()]
81
+ .map((e) => ({ ...e, claims: [...e.claims].sort() }))
82
+ .sort((a, b) => b.ms - a.ms || a.file.localeCompare(b.file));
83
+ }
84
+
85
+ /**
86
+ * The whole cost model for one evidence document.
87
+ *
88
+ * `sharedDefenders` is the actionable part: a file named by more than one
89
+ * claim, whose cost is multiplied by every claim that names it. Splitting the
90
+ * behaviour it defends into a unit test that runs in milliseconds is what
91
+ * turns a twenty-four-minute gate into a one-minute one, and until a report
92
+ * names the file nobody knows which test to split.
93
+ */
94
+ export function costReport(records) {
95
+ const claims = claimCosts(records);
96
+ const defenders = defenderCosts(records);
97
+ return {
98
+ totalMs: claims.reduce((a, c) => a + c.ms, 0),
99
+ totalRuns: claims.reduce((a, c) => a + c.runs, 0),
100
+ reusedRecords: records.filter((r) => r.reusedFrom).length,
101
+ records: records.length,
102
+ claims,
103
+ defenders,
104
+ sharedDefenders: defenders.filter((d) => d.claims.length > 1),
105
+ };
106
+ }
107
+
108
+ const secs = (ms) => (ms >= 10_000 ? `${Math.round(ms / 1000)}s` : ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`);
109
+ /** Exported so a caller can format a duration the same way the report does. */
110
+ export const formatMs = secs;
111
+
112
+ /**
113
+ * The cost report as text. `limit` caps each list; the totals always describe
114
+ * everything, so a truncated list never misrepresents the whole.
115
+ */
116
+ export function renderCost(report, { limit = 10 } = {}) {
117
+ const out = [];
118
+ if (!report.records) return 'no evidence records — run `testguard probe` first; cost is read back from the runs it records.';
119
+ out.push(`${report.records} fault record${report.records === 1 ? '' : 's'} cost ${secs(report.totalMs)} across ${report.totalRuns} defender runs${report.reusedRecords ? ` (${report.reusedRecords} reused; their runs are what they cost when reuse does not apply)` : ''}.`);
120
+ out.push('');
121
+ out.push('most expensive claims');
122
+ for (const c of report.claims.slice(0, limit)) {
123
+ out.push(` ${secs(c.ms).padStart(7)} ${c.claimId.padEnd(34)} ${c.faults} fault${c.faults === 1 ? ' ' : 's'} · ${c.runs} runs${c.reused ? ` · ${c.reused} reused` : ''}`);
124
+ }
125
+ if (report.claims.length > limit) out.push(` … ${report.claims.length - limit} more`);
126
+ out.push('');
127
+ out.push('defender files, by the time of the runs that included them');
128
+ out.push(' (a claim runs its whole defender set at once, so these overlap and do not sum to the total)');
129
+ for (const d of report.defenders.slice(0, limit)) {
130
+ out.push(` ${secs(d.ms).padStart(7)} ${d.file.padEnd(44)} ${d.claims.length} claim${d.claims.length === 1 ? '' : 's'}`);
131
+ }
132
+ if (report.defenders.length > limit) out.push(` … ${report.defenders.length - limit} more`);
133
+ if (report.sharedDefenders.length) {
134
+ out.push('');
135
+ out.push('shared defenders — each claim pays the file\'s full cost again');
136
+ for (const d of report.sharedDefenders.slice(0, limit)) {
137
+ out.push(` ${secs(d.ms).padStart(7)} ${d.file}`);
138
+ out.push(` named by ${d.claims.join(', ')}`);
139
+ }
140
+ out.push('');
141
+ out.push(' To make a probe cheaper, split the behaviour a shared defender proves into a');
142
+ out.push(' test that does not need the expensive setup, and point the claim at that.');
143
+ }
144
+ return out.join('\n');
145
+ }
146
+
147
+ /**
148
+ * Is this probe within its cost budget?
149
+ *
150
+ * The gate went from 9.6 minutes to 23.6 across one merged pull request, and
151
+ * nothing said a word — `--cost` had been printing the number into every CI log
152
+ * since the previous optimisation. A measurement nothing gates on is not a
153
+ * check, which is the premise this whole tool rests on, applied to itself.
154
+ *
155
+ * Budget the TOTAL, never the per-fault average. The regression that prompted
156
+ * this raised the fault count from 59 to 91 while per-fault cost barely moved,
157
+ * so an average would have reported everything fine while the gate tripled.
158
+ * Growth is legitimate; the point is that someone signs for it in a diff.
159
+ *
160
+ * `previousMs` is optional and only ever informational: "804s, budget 900s" is
161
+ * a much weaker signal than "804s, was 780s", and the prior total is already in
162
+ * the evidence CI restores for verdict reuse.
163
+ *
164
+ * Pure. Reads a report, returns a decision; the caller owns exit codes and I/O.
165
+ */
166
+ export function checkCostBudget(report, { budgetSeconds, previousMs, worst = 5 } = {}) {
167
+ if (typeof budgetSeconds !== 'number' || !Number.isFinite(budgetSeconds) || budgetSeconds <= 0) {
168
+ throw new TypeError('checkCostBudget: budgetSeconds must be a positive number');
169
+ }
170
+ const budgetMs = budgetSeconds * 1000;
171
+ const totalMs = report.totalMs ?? 0;
172
+ return {
173
+ ok: totalMs <= budgetMs,
174
+ totalMs,
175
+ budgetMs,
176
+ overByMs: Math.max(0, totalMs - budgetMs),
177
+ headroomMs: Math.max(0, budgetMs - totalMs),
178
+ ...(typeof previousMs === 'number' ? { previousMs, deltaMs: totalMs - previousMs } : {}),
179
+ // Named so a failure opens with which claims to look at, not just a number.
180
+ // claimCosts emits `claimId`; reading `id` here printed "undefined" for every
181
+ // claim the first time the budget fired, and the test had fed the wished-for shape.
182
+ worst: (report.claims ?? []).slice(0, worst).map((c) => ({ id: c.claimId, ms: c.ms, runs: c.runs })),
183
+ };
184
+ }
185
+
186
+ /** The budget decision as text, for a CI log that someone reads only when it fails. */
187
+ export function renderCostBudget(d) {
188
+ const out = [];
189
+ const delta = typeof d.deltaMs === 'number'
190
+ ? ` (${d.deltaMs >= 0 ? '+' : ''}${secs(Math.abs(d.deltaMs))} against the previous run's ${secs(d.previousMs)})`
191
+ : '';
192
+ out.push(d.ok
193
+ ? `cost ${secs(d.totalMs)} of a ${secs(d.budgetMs)} budget — ${secs(d.headroomMs)} to spare${delta}.`
194
+ : `COST BUDGET EXCEEDED: ${secs(d.totalMs)} against a ${secs(d.budgetMs)} budget, over by ${secs(d.overByMs)}${delta}.`);
195
+ if (!d.ok && d.worst.length) {
196
+ out.push('');
197
+ out.push('most expensive claims');
198
+ for (const c of d.worst) out.push(` ${secs(c.ms).padStart(7)} ${c.id} (${c.runs} runs)`);
199
+ out.push('');
200
+ out.push('Either move a claim onto a defender that does not need the expensive setup —');
201
+ out.push('re-probing afterwards, never assuming — or raise the budget in a commit that');
202
+ out.push('says why. Growth is allowed; going unnoticed is not.');
203
+ }
204
+ return out.join('\n');
205
+ }
@@ -1,18 +1,26 @@
1
1
  import { join } from 'node:path';
2
2
  import { listTestFiles } from './runners/shared.mjs';
3
+ import { testGlobs as PY_TEST_GLOBS } from './runners/python.mjs';
3
4
  import { fileImports } from './rank.mjs';
5
+ import { pyFileImports } from './pyimports.mjs';
4
6
  import { classifyDefenders } from './mocks.mjs';
5
7
 
8
+ /** A target's language decides how its importers are found. Nothing else about it matters here. */
9
+ export const isPython = (rel) => rel.endsWith('.py');
10
+
6
11
  /**
7
12
  * When a claim declares no defenders: the test files that import the fault's
8
- * target file — directly, by relative path or through a resolved alias — and
9
- * do NOT mock it. A file that mocks the target cannot detect any fault in it;
10
- * counting it would make `nocover` under-report and waste probe runs on files
11
- * that cannot fail. `nocover` therefore means exactly "no test file imports
12
- * this source without mocking it".
13
+ * target file — directly, by relative path or through a resolved alias in
14
+ * JavaScript, by module name in Python — and do NOT replace it wholesale. A
15
+ * file that mocks the target cannot detect any fault in it; counting it would
16
+ * make `nocover` under-report and waste probe runs on files that cannot fail.
17
+ * `nocover` therefore means exactly "no test file imports this source without
18
+ * mocking it".
13
19
  */
14
20
  export function discoverDefendersDetailed(projectDir, targetRel) {
15
- const importing = listTestFiles(projectDir).filter((t) => fileImports(projectDir, join(projectDir, t), targetRel));
21
+ const importing = isPython(targetRel)
22
+ ? listTestFiles(projectDir, PY_TEST_GLOBS).filter((t) => pyFileImports(projectDir, join(projectDir, t), targetRel, t))
23
+ : listTestFiles(projectDir).filter((t) => fileImports(projectDir, join(projectDir, t), targetRel));
16
24
  return { importing, ...classifyDefenders(projectDir, targetRel, importing) };
17
25
  }
18
26
 
@@ -63,24 +63,56 @@ export function mutate(source, fault) {
63
63
  }
64
64
 
65
65
  /**
66
- * Apply a fault to a file on disk. Returns a handle whose `restore()` puts the
67
- * original back; restore is idempotent and also runs on process exit/signal.
66
+ * Replace a file's contents on disk. Returns a handle whose `restore()` puts
67
+ * the original back; restore is idempotent and also runs on process
68
+ * exit/signal. `content` is the new text, or a function of the original.
69
+ *
70
+ * `inPlace` decides what a *missing* target means at restore time, and the two
71
+ * modes are opposites. In worktree mode the mutation only ever existed inside a
72
+ * scratch worktree that is discarded at the end of the run, so a target that has
73
+ * vanished is already restored in every sense that matters — a parallel session
74
+ * pruning its own leaked worktrees is enough to cause it, and throwing there
75
+ * converts a harmless cleanup race into a probe that dies with no evidence at
76
+ * all. In `--in-place` mode the same condition means the user's own file is
77
+ * gone, and silence would be the worst possible answer.
68
78
  */
69
- export function applyFault(projectDir, fault) {
79
+ export function applyContent(projectDir, file, content, { inPlace = false } = {}) {
70
80
  installHandlers();
71
- const path = join(projectDir, fault.file);
81
+ const path = join(projectDir, file);
72
82
  const original = readFileSync(path, 'utf8');
73
- const anchor = locate(original, fault);
74
- if (anchor.status !== 'ok') return { anchor, applied: false, restore: () => {} };
75
83
 
76
84
  let restored = false;
85
+ const handle = { applied: true, restore: null };
77
86
  const restore = () => {
78
87
  if (restored) return;
79
88
  restored = true;
80
89
  live.delete(restore);
81
- writeFileSync(path, original);
90
+ try {
91
+ writeFileSync(path, original);
92
+ } catch (err) {
93
+ // Only a target that is no longer there. A permission failure or a
94
+ // directory where a file should be can still mean a mutated file left on
95
+ // disk, and those must stay loud in either mode.
96
+ if (inPlace || err.code !== 'ENOENT') throw err;
97
+ handle.restoreSkipped = 'target-missing';
98
+ }
82
99
  };
100
+ handle.restore = restore;
83
101
  live.add(restore);
84
- writeFileSync(path, mutate(original, fault));
85
- return { anchor, applied: true, restore };
102
+ writeFileSync(path, typeof content === 'function' ? content(original) : content);
103
+ return handle;
104
+ }
105
+
106
+ /**
107
+ * Apply a fault to a file on disk. Returns a handle whose `restore()` puts the
108
+ * original back; `applied` is false and nothing is written when the anchor does
109
+ * not hit exactly as declared.
110
+ */
111
+ export function applyFault(projectDir, fault, opts = {}) {
112
+ const anchor = locate(readFileSync(join(projectDir, fault.file), 'utf8'), fault);
113
+ if (anchor.status !== 'ok') return { anchor, applied: false, restore: () => {} };
114
+ // The same object, never a spread: `restoreSkipped` is set on it later.
115
+ const handle = applyContent(projectDir, fault.file, (src) => mutate(src, fault), opts);
116
+ handle.anchor = anchor;
117
+ return handle;
86
118
  }
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { specifierResolvesTo } from './rank.mjs';
4
+ import { classifyPythonDefenders } from './pyimports.mjs';
4
5
 
5
6
  /**
6
7
  * Mock-awareness for defenders (issues #20, #21).
@@ -104,6 +105,7 @@ function matchesTargetAnyImport(source, matchesTarget) {
104
105
  * the annotation is recorded (`unasserted-annotated`) — silenced, never hidden.
105
106
  */
106
107
  export function classifyDefenders(projectDir, targetRel, candidates) {
108
+ if (targetRel.endsWith('.py')) return classifyPythonDefenders(projectDir, targetRel, candidates);
107
109
  const canDetect = [];
108
110
  const mocking = [];
109
111
  const signals = [];