testguard-cli 0.6.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.
- package/CHANGELOG.md +373 -0
- package/README.md +277 -27
- package/package.json +2 -2
- package/spec/GATE-SEMANTICS.md +112 -5
- package/spec/README.md +28 -10
- package/spec/lib/validate.mjs +84 -8
- package/spec/schemas/baseline.schema.json +7 -7
- package/spec/schemas/brief.schema.json +12 -12
- package/spec/schemas/calibration.schema.json +5 -5
- package/spec/schemas/claims.schema.json +122 -25
- package/spec/schemas/common.schema.json +127 -29
- package/spec/schemas/evidence.schema.json +169 -73
- package/spec/schemas/gate.schema.json +12 -12
- package/spec/schemas/ignore.schema.json +4 -4
- package/spec/schemas/replay.schema.json +10 -10
- package/spec/schemas/status.schema.json +25 -25
- package/src/cli.mjs +20 -5
- package/src/commands/claims.mjs +22 -2
- package/src/commands/probe.mjs +24 -4
- package/src/gate/changed.mjs +9 -2
- package/src/probe/attribution.mjs +123 -0
- package/src/probe/classify.mjs +18 -2
- package/src/probe/cost.mjs +145 -0
- package/src/probe/discover.mjs +14 -6
- package/src/probe/inject.mjs +41 -9
- package/src/probe/mocks.mjs +2 -0
- package/src/probe/probe.mjs +235 -52
- package/src/probe/progress.mjs +87 -0
- package/src/probe/pyimports.mjs +287 -0
- package/src/probe/rank.mjs +2 -0
- package/src/probe/runners/index.mjs +38 -11
- package/src/probe/runners/jest.mjs +13 -1
- package/src/probe/runners/playwright.mjs +9 -0
- package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
- package/src/probe/runners/python/_testguard_report.py +141 -0
- package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
- package/src/probe/runners/python.mjs +316 -0
- package/src/probe/runners/shared.mjs +10 -1
- package/src/probe/runners/vitest.mjs +13 -1
- package/src/replay/replay.mjs +3 -2
- package/src/scaffold/producers.python.mjs +209 -0
- package/src/scaffold/scaffold.mjs +86 -32
- package/src/util/glob.mjs +7 -1
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
}
|
package/src/probe/discover.mjs
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* this source without
|
|
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 =
|
|
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
|
|
package/src/probe/inject.mjs
CHANGED
|
@@ -63,24 +63,56 @@ export function mutate(source, fault) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
*
|
|
67
|
-
* original back; restore is idempotent and also runs on process
|
|
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
|
|
79
|
+
export function applyContent(projectDir, file, content, { inPlace = false } = {}) {
|
|
70
80
|
installHandlers();
|
|
71
|
-
const path = join(projectDir,
|
|
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
|
-
|
|
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,
|
|
85
|
-
return
|
|
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
|
}
|
package/src/probe/mocks.mjs
CHANGED
|
@@ -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 = [];
|