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.
- package/CHANGELOG.md +628 -0
- package/README.md +536 -42
- package/package.json +2 -2
- package/spec/GATE-SEMANTICS.md +166 -2
- package/spec/README.md +29 -10
- package/spec/lib/validate.mjs +140 -8
- package/spec/schemas/baseline.schema.json +13 -5
- 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 +131 -29
- package/spec/schemas/evidence.schema.json +476 -69
- package/spec/schemas/gate.schema.json +12 -12
- package/spec/schemas/ignore.schema.json +4 -4
- package/spec/schemas/replay.schema.json +85 -0
- package/spec/schemas/status.schema.json +258 -56
- package/src/admit/admit.mjs +17 -0
- package/src/baseline/baseline.mjs +31 -2
- package/src/brief/brief.mjs +71 -7
- package/src/claims/removed.mjs +161 -0
- package/src/cli.mjs +68 -10
- package/src/commands/admit.mjs +116 -0
- package/src/commands/baseline.mjs +17 -3
- package/src/commands/brief.mjs +13 -7
- package/src/commands/claims.mjs +55 -6
- package/src/commands/init.mjs +19 -6
- package/src/commands/mcp.mjs +12 -0
- package/src/commands/probe.mjs +35 -7
- package/src/commands/replay.mjs +63 -0
- package/src/commands/status.mjs +3 -1
- package/src/gate/changed.mjs +9 -2
- package/src/git.mjs +5 -0
- package/src/init/init.mjs +182 -29
- package/src/init/templates/SKILL.md +3 -3
- package/src/mcp/server.mjs +104 -0
- package/src/mcp/tools.mjs +114 -0
- package/src/probe/attribution.mjs +123 -0
- package/src/probe/classify.mjs +18 -2
- package/src/probe/contention.mjs +60 -0
- package/src/probe/cost.mjs +145 -0
- package/src/probe/discover.mjs +20 -3
- package/src/probe/independence.mjs +59 -0
- package/src/probe/inject.mjs +41 -9
- package/src/probe/mocks.mjs +122 -0
- package/src/probe/probe.mjs +331 -40
- package/src/probe/progress.mjs +87 -0
- package/src/probe/pyimports.mjs +287 -0
- package/src/probe/rank.mjs +75 -15
- package/src/probe/runners/index.mjs +79 -8
- package/src/probe/runners/jest.mjs +15 -3
- package/src/probe/runners/playwright.mjs +142 -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 +80 -14
- package/src/probe/runners/vitest.mjs +16 -3
- package/src/render.mjs +2 -2
- package/src/replay/label.mjs +64 -0
- package/src/replay/replay.mjs +310 -0
- package/src/scaffold/producers.mjs +217 -6
- package/src/scaffold/producers.python.mjs +209 -0
- package/src/scaffold/scaffold.mjs +90 -30
- package/src/status/status.mjs +47 -6
- package/src/util/glob.mjs +7 -1
package/src/probe/probe.mjs
CHANGED
|
@@ -1,19 +1,36 @@
|
|
|
1
|
-
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, realpathSync as fsRealpathSync } from 'node:fs';
|
|
2
2
|
import { git } from '../git.mjs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
|
-
import { join, relative, resolve } from 'node:path';
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
5
|
import { repoRoot as gitRoot, headSha, isDirty, snapshotWorkingTree } from '../git.mjs';
|
|
6
|
-
import { discoverDefenders } from './discover.mjs';
|
|
6
|
+
import { discoverDefenders, discoverDefendersDetailed } from './discover.mjs';
|
|
7
|
+
import { classifyDefenders } from './mocks.mjs';
|
|
8
|
+
import { detectContention, contentionWarning } from './contention.mjs';
|
|
7
9
|
import { createScratch, inPlace, PreconditionError } from './worktree.mjs';
|
|
8
|
-
import { applyFault, locate } from './inject.mjs';
|
|
10
|
+
import { applyContent, applyFault, locate } from './inject.mjs';
|
|
9
11
|
import { resolveDefenders, parseCommandTemplate } from './runners/shared.mjs';
|
|
10
|
-
import { selectRunner, RUNNERS } from './runners/index.mjs';
|
|
12
|
+
import { selectRunner, RUNNERS, OWNED_RUNNERS, partitionByRunner, mergeRuns, runnerLabel, runnerFor } from './runners/index.mjs';
|
|
11
13
|
import { classify, shouldStopEarly } from './classify.mjs';
|
|
12
14
|
import { blastRadius, rank } from './rank.mjs';
|
|
15
|
+
import { classifyIndependence } from './independence.mjs';
|
|
16
|
+
import { escalationStart, foldEscalationRun, escalationResult, flakeRate, killersFromRuns, subjectOf, isReusable } from './attribution.mjs';
|
|
13
17
|
import { hashFile, sha256 } from '../util/hash.mjs';
|
|
14
18
|
import { fingerprint } from '../../spec/lib/fingerprint.mjs';
|
|
15
19
|
|
|
16
|
-
|
|
20
|
+
|
|
21
|
+
/** Is `child` the same file as `root`, or under it? Both must already be real paths. */
|
|
22
|
+
function isInside(root, child) {
|
|
23
|
+
return child === root || child.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** realpath when the path still exists; the path itself otherwise. Never throws. */
|
|
27
|
+
function realpathSync(p) {
|
|
28
|
+
try {
|
|
29
|
+
return fsRealpathSync(p);
|
|
30
|
+
} catch {
|
|
31
|
+
return p;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
17
34
|
|
|
18
35
|
function readRunnerVersion(projectDir, name = 'vitest') {
|
|
19
36
|
try {
|
|
@@ -33,10 +50,15 @@ export async function probe({
|
|
|
33
50
|
confirmRuns = 3,
|
|
34
51
|
mode = 'worktree',
|
|
35
52
|
ref = 'HEAD',
|
|
53
|
+
refExplicit = false,
|
|
54
|
+
ignoreDirty = false,
|
|
55
|
+
serial = false,
|
|
56
|
+
onWarn = () => {},
|
|
36
57
|
budgetMs = 120_000,
|
|
37
58
|
runnerCommand,
|
|
38
59
|
runnerName = 'auto',
|
|
39
60
|
nodeModules,
|
|
61
|
+
python,
|
|
40
62
|
only,
|
|
41
63
|
includeDirty = false,
|
|
42
64
|
onStage = () => {},
|
|
@@ -69,11 +91,17 @@ export async function probe({
|
|
|
69
91
|
|
|
70
92
|
// Worktree mode probes a commit, not the working tree. Uncommitted defender
|
|
71
93
|
// or target edits would be silently absent — the same survivors, no hint why.
|
|
94
|
+
// Refused for the IMPLICIT HEAD, which is the silent-mismatch trap. With an
|
|
95
|
+
// explicit --ref the user has named the commit they mean (a pre-fix probe
|
|
96
|
+
// in a post-mortem is the most valuable run there is); with --ignore-dirty
|
|
97
|
+
// they have said they know. Both proceed with a warning that names the
|
|
98
|
+
// files, and the evidence records what was ignored.
|
|
72
99
|
let snapshot;
|
|
100
|
+
let ignoredDirty = [];
|
|
73
101
|
if (mode === 'worktree') {
|
|
74
102
|
if (includeDirty) {
|
|
75
103
|
if (isDirty(root)) snapshot = snapshotWorkingTree(root);
|
|
76
|
-
} else
|
|
104
|
+
} else {
|
|
77
105
|
const watched = new Set(targets);
|
|
78
106
|
for (const claim of claims.claims) {
|
|
79
107
|
if (selected && !selected.has(claim.id)) continue;
|
|
@@ -82,42 +110,120 @@ export async function probe({
|
|
|
82
110
|
for (const g of claim.defendedBy ?? []) if (!g.includes('*')) watched.add(relative(root, join(projectDir, g)));
|
|
83
111
|
}
|
|
84
112
|
const dirty = git(['status', '--porcelain', '--', ...watched], root).split('\n').filter(Boolean).map((l) => l.replace(/^[ MADRCU?!]{1,2}\s+/, '').replace(/^.* -> /, ''));
|
|
113
|
+
if (dirty.length && !refExplicit && !ignoreDirty) {
|
|
114
|
+
throw new PreconditionError(`${dirty.length} defender/target file${dirty.length === 1 ? ' has' : 's have'} uncommitted changes (${dirty.join(', ')}); worktree mode probes HEAD (${head.slice(0, 7)}), so those changes would be silently ignored. Commit them, run with --include-dirty to probe the working tree, use --in-place, or --ignore-dirty if you mean HEAD as committed.`);
|
|
115
|
+
}
|
|
85
116
|
if (dirty.length) {
|
|
86
|
-
|
|
117
|
+
ignoredDirty = dirty.sort();
|
|
118
|
+
onWarn(`${dirty.length} defender/target file${dirty.length === 1 ? ' has' : 's have'} uncommitted changes (${dirty.join(', ')}); probing ${ref === 'HEAD' ? `HEAD (${head.slice(0, 7)})` : `${ref} (${head.slice(0, 7)})`} as committed — the working-tree versions are NOT what is being probed. Recorded in the evidence as repo.ignoredDirty.`);
|
|
87
119
|
}
|
|
88
120
|
}
|
|
89
121
|
}
|
|
122
|
+
// Contention: name it before the first run, and record it, so a later
|
|
123
|
+
// reader of a TIMEOUT or FLAKY-DEFENDER knows the machine was loaded.
|
|
124
|
+
const contention = detectContention();
|
|
125
|
+
if (contention.detected) onWarn(contentionWarning(contention));
|
|
126
|
+
|
|
90
127
|
const startedAt = new Date().toISOString();
|
|
91
128
|
const iso = mode === 'worktree' ? createScratch({ repoRoot: root, projectDir, ref: snapshot ?? ref, scratchBase, nodeModules }) : inPlace({ repoRoot: root, projectDir });
|
|
129
|
+
const isoReal = realpathSync(iso.projectDir);
|
|
92
130
|
const records = [];
|
|
93
131
|
let runnerVersion;
|
|
132
|
+
let runnerSource;
|
|
94
133
|
let runner = RUNNERS[runnerName === 'auto' ? 'vitest' : runnerName];
|
|
134
|
+
// A runner with more than one engine (Python: pytest or unittest) is recorded
|
|
135
|
+
// by the engine that actually ran, never by the module's name. TestGuard does
|
|
136
|
+
// not say pytest ran when the stdlib runner did.
|
|
137
|
+
const engines = new Map();
|
|
138
|
+
const labelOf = (r) => runnerLabel(r, engines);
|
|
139
|
+
const runnersUsed = new Map(); // every runner that ran defenders, beyond the project runner
|
|
95
140
|
try {
|
|
96
141
|
const commandTemplate = runnerCommand ? parseCommandTemplate(runnerCommand) : undefined;
|
|
97
142
|
if (!commandTemplate) {
|
|
98
143
|
// An unresolvable runner is a precondition failure, not a flaky defender.
|
|
99
|
-
const sel = await selectRunner({ projectDir: iso.projectDir, name: runnerName });
|
|
144
|
+
const sel = await selectRunner({ projectDir: iso.projectDir, sourceDir: projectDir, python, name: runnerName });
|
|
100
145
|
if (sel.error) {
|
|
101
146
|
throw new PreconditionError(`test runner is not resolvable in the ${mode === 'worktree' ? 'scratch worktree' : 'project'} (${sel.error}). ` +
|
|
102
147
|
(mode === 'worktree' ? 'No usable node_modules was linked: pass --node-modules <path>, or run with --in-place.' : 'Install dependencies first.'));
|
|
103
148
|
}
|
|
104
149
|
runner = sel.runner;
|
|
105
150
|
runnerVersion = sel.version;
|
|
151
|
+
runnerSource = sel.source;
|
|
152
|
+
if (sel.engine) engines.set(sel.runner, sel.engine);
|
|
106
153
|
}
|
|
107
|
-
|
|
154
|
+
runnersUsed.set(labelOf(runner), runnerVersion ?? readRunnerVersion(projectDir, runner.name));
|
|
155
|
+
// Files an owning runner (Playwright) claims run under it, whatever the
|
|
156
|
+
// project runner is; it must resolve before the first such defender runs.
|
|
157
|
+
const owned = OWNED_RUNNERS.filter((r) => r !== runner);
|
|
158
|
+
const ownedChecked = new Map();
|
|
159
|
+
const ensureOwned = async (r, file) => {
|
|
160
|
+
if (!ownedChecked.has(r)) ownedChecked.set(r, await r.check({ projectDir: iso.projectDir, sourceDir: projectDir, python }));
|
|
161
|
+
const c = ownedChecked.get(r);
|
|
162
|
+
if (!c.ok) throw new PreconditionError(`${file} is a ${r.name} test (${r.name === 'python' ? 'it is a .py file' : `it lives under ${r.name}'s testDir`}) but ${r.name} is not resolvable for the ${mode === 'worktree' ? 'scratch worktree' : 'project'} (${c.message}).`);
|
|
163
|
+
if (c.engine) engines.set(r, c.engine);
|
|
164
|
+
runnersUsed.set(labelOf(r), c.version);
|
|
165
|
+
};
|
|
166
|
+
const allTests = [...new Set([...runner.tests(iso.projectDir).filter((t) => !owned.some((r) => r.owns(iso.projectDir, t))), ...owned.flatMap((r) => r.tests(iso.projectDir))])].sort();
|
|
108
167
|
const baselineCache = new Map();
|
|
168
|
+
// The negative control is charged per (target file, defender set): two
|
|
169
|
+
// claims over the same file with the same defenders ask the same question.
|
|
170
|
+
const controlCache = new Map();
|
|
171
|
+
// The runner that will LOAD the subject, which is not always the project
|
|
172
|
+
// runner — a `.py` source file is Python's whatever the project runs.
|
|
173
|
+
const fatalEditFor = (file) => runnerFor(iso.projectDir, file, runner).fatalEdit?.();
|
|
109
174
|
const prior = previous && previous.run.confirmRuns === confirmRuns
|
|
110
175
|
? new Map(previous.records.map((r) => [`${r.claim.id}/${r.subject.id}`, r]))
|
|
111
176
|
: new Map();
|
|
112
|
-
|
|
177
|
+
// `targets` are the fault's files, passed so a runner that can tell which
|
|
178
|
+
// file the interpreter actually loaded (Python) reports it back.
|
|
179
|
+
const runDefenders = async (files, targets = []) => {
|
|
180
|
+
if (commandTemplate) return runner.run({ projectDir: iso.projectDir, sourceDir: projectDir, python, files, budgetMs, commandTemplate, serial, targets });
|
|
181
|
+
const groups = partitionByRunner(iso.projectDir, files, runner);
|
|
182
|
+
const parts = [];
|
|
183
|
+
for (const [r, group] of groups) {
|
|
184
|
+
if (r !== runner) await ensureOwned(r, group[0]);
|
|
185
|
+
parts.push(await r.run({ projectDir: iso.projectDir, sourceDir: projectDir, python, files: group, budgetMs, serial, targets }));
|
|
186
|
+
}
|
|
187
|
+
return mergeRuns(parts);
|
|
188
|
+
};
|
|
189
|
+
/** Which runner each defender runs under, when more than the project runner is involved. */
|
|
190
|
+
const byRunner = (files) => {
|
|
191
|
+
const groups = partitionByRunner(iso.projectDir, files, runner);
|
|
192
|
+
if (groups.size <= 1) return undefined; // one runner ran them all, whichever it was
|
|
193
|
+
return Object.fromEntries([...groups].map(([r, group]) => [labelOf(r), group]));
|
|
194
|
+
};
|
|
113
195
|
|
|
114
196
|
for (const claim of claims.claims) {
|
|
115
197
|
if (selected && !selected.has(claim.id)) continue;
|
|
116
198
|
const declared = claim.defendedBy?.length ? resolveDefenders(iso.projectDir, claim.defendedBy) : null;
|
|
117
199
|
for (const fault of claim.faults) {
|
|
118
|
-
|
|
200
|
+
// Mock-awareness: a discovered file that mocks the target is not a
|
|
201
|
+
// defender; a declared one that mocks it stays (the author named it)
|
|
202
|
+
// but is listed, because a declared defender that mocks the subject
|
|
203
|
+
// is a broken evidence chain the author should see.
|
|
204
|
+
const mockInfo = declared ? classifyDefenders(iso.projectDir, fault.file, declared) : discoverDefendersDetailed(iso.projectDir, fault.file);
|
|
205
|
+
const defenders = declared ?? mockInfo.canDetect;
|
|
119
206
|
const stage = (name, i, n) => onStage({ claimId: claim.id, faultId: fault.id, stage: name, i, n });
|
|
120
|
-
const
|
|
207
|
+
const common = { claim, fault, defenders, discovered: declared === null, byRunner: byRunner(defenders), mocking: mockInfo.mocking, signals: mockInfo.signals };
|
|
208
|
+
let record;
|
|
209
|
+
try {
|
|
210
|
+
record = await probeOne({ ...common, allTests, iso, isoReal, onWarn, confirmRuns, escalate, baselineCache, controlCache, fatalEditFor, runDefenders, stage, prior: prior.get(`${claim.id}/${fault.id}`), priorRunId: previous?.run.id,
|
|
211
|
+
historyRef: snapshot ?? (mode === 'worktree' ? head : 'HEAD'), historyDir: root,
|
|
212
|
+
// A path from the runner is absolute inside the SCRATCH worktree, or
|
|
213
|
+
// project-relative. Either way history is read from the real repository,
|
|
214
|
+
// so both must land on a repo-root-relative path. The runner reports
|
|
215
|
+
// realpaths (/private/var on macOS) while the worktree path may not be
|
|
216
|
+
// one — realpath both sides or every comparison silently misses.
|
|
217
|
+
toRepoPath: (p) => relative(root, join(projectDir, isAbsolute(p) ? relative(isoReal, realpathSync(p)) : p)) });
|
|
218
|
+
} catch (err) {
|
|
219
|
+
// A precondition failure is a statement about the whole run — the
|
|
220
|
+
// interpreter is loading a different copy of the source, the runner
|
|
221
|
+
// is not resolvable — and continuing would produce verdicts that are
|
|
222
|
+
// all false. Everything else is this one fault's problem.
|
|
223
|
+
if (err instanceof PreconditionError) throw err;
|
|
224
|
+
record = errorRecord({ ...common, error: err, inputs: safeInputs(iso.projectDir, fault, defenders) });
|
|
225
|
+
onWarn(`${claim.id}/${fault.id}: ${record.detail.message} — reported as unverifiable so the rest of the run still produces evidence.`);
|
|
226
|
+
}
|
|
121
227
|
records.push(record);
|
|
122
228
|
onProgress(record);
|
|
123
229
|
}
|
|
@@ -133,17 +239,112 @@ export async function probe({
|
|
|
133
239
|
id: `run-${startedAt.replace(/[-:.]/g, '').slice(0, 15)}`,
|
|
134
240
|
startedAt,
|
|
135
241
|
finishedAt: new Date().toISOString(),
|
|
136
|
-
repo: { head, dirty: isDirty(root), ...(snapshot ? { snapshot } : {}) },
|
|
137
|
-
runner: { name: runner
|
|
242
|
+
repo: { head, dirty: isDirty(root), ...(snapshot ? { snapshot } : {}), ...(ignoredDirty.length ? { ignoredDirty } : {}) },
|
|
243
|
+
runner: { name: labelOf(runner), ...((runnerVersion ?? readRunnerVersion(projectDir, runner.name)) ? { version: runnerVersion ?? readRunnerVersion(projectDir, runner.name) } : {}) },
|
|
244
|
+
...(runnersUsed.size > 1 ? { runners: [...runnersUsed].map(([n, v]) => ({ name: n, ...(v ? { version: v } : {}) })) } : {}),
|
|
138
245
|
confirmRuns,
|
|
246
|
+
...(serial ? { serial: true } : {}),
|
|
247
|
+
...(contention.detected ? { contention } : {}),
|
|
139
248
|
...(confirmRuns < 3 ? { provisional: true } : {}),
|
|
140
|
-
mode
|
|
249
|
+
// The isolation that was actually created, not the mode that was asked
|
|
250
|
+
// for. A record saying `worktree` while the probe edited the project in
|
|
251
|
+
// place would be a false statement about where the evidence came from,
|
|
252
|
+
// and nothing downstream could detect it.
|
|
253
|
+
mode: iso.mode,
|
|
141
254
|
},
|
|
142
255
|
records,
|
|
143
256
|
};
|
|
144
257
|
}
|
|
145
258
|
|
|
146
|
-
|
|
259
|
+
/**
|
|
260
|
+
* The record a fault gets when probing it threw.
|
|
261
|
+
*
|
|
262
|
+
* Before this existed, an unexpected error anywhere inside one fault's probe
|
|
263
|
+
* killed the process: no evidence file at all, every verdict already decided
|
|
264
|
+
* thrown away, and an exit code no caller can read against GATE-SEMANTICS. One
|
|
265
|
+
* bad fault should cost that fault and nothing else.
|
|
266
|
+
*
|
|
267
|
+
* `unverifiable` is the honest verdict — the claim could not be probed — and it
|
|
268
|
+
* gates, so the failure is loud rather than absorbed into a green run. Pure, so
|
|
269
|
+
* every branch is testable without provoking a crash.
|
|
270
|
+
*/
|
|
271
|
+
export function errorRecord({ claim, fault, defenders, discovered, byRunner, mocking = [], signals = [], error, inputs }) {
|
|
272
|
+
const message = String(error?.message ?? error).split('\n')[0].slice(0, 1024);
|
|
273
|
+
return {
|
|
274
|
+
fingerprint: fingerprint({ claimId: claim.id, subjectId: fault.id, file: fault.file, verdict: 'unverifiable' }),
|
|
275
|
+
claim: { id: claim.id, statement: claim.statement, severity: claim.severity, source: claim.source, producedBy: claim.producedBy },
|
|
276
|
+
subject: subjectOf(fault, sha256),
|
|
277
|
+
verdict: 'unverifiable',
|
|
278
|
+
detail: { baselineRuns: [], probeRuns: [], reason: 'probe-error', message },
|
|
279
|
+
defenders: { requested: claim.defendedBy ?? [], resolved: defenders, nocover: defenders.length === 0, ...(discovered ? { discovered: true } : {}), ...(byRunner ? { byRunner } : {}), ...(mocking.length ? { mocking } : {}), ...(signals.length ? { signals } : {}) },
|
|
280
|
+
inputs,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* `inputs` for a record built after something threw — which is exactly when a
|
|
286
|
+
* file may no longer be readable. Hashing must not be the second failure, so an
|
|
287
|
+
* unreadable file hashes as empty: never equal to a real file's hash, so the
|
|
288
|
+
* record can only ever fail a reuse check, never pass one by accident.
|
|
289
|
+
*/
|
|
290
|
+
function safeInputs(projectDir, fault, defenders) {
|
|
291
|
+
const h = (f) => {
|
|
292
|
+
try {
|
|
293
|
+
return hashFile(join(projectDir, f));
|
|
294
|
+
} catch {
|
|
295
|
+
return sha256('');
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
return { targetHash: h(fault.file), defenderHashes: Object.fromEntries(defenders.map((f) => [f, h(f)])) };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The negative control the green baseline cannot provide.
|
|
303
|
+
*
|
|
304
|
+
* A green baseline proves the defenders can PASS on unmodified source, which
|
|
305
|
+
* makes "the harness is broken, so everything looks killed" structurally
|
|
306
|
+
* impossible. It says nothing about the opposite direction: if the fault is
|
|
307
|
+
* applied to code the test process never executes, the baseline is green,
|
|
308
|
+
* every probe run is green, and every claim is reported SURVIVED. The output
|
|
309
|
+
* reads as a devastating audit finding and is entirely false, and nothing in
|
|
310
|
+
* the run contradicts it, because every individual check passed.
|
|
311
|
+
*
|
|
312
|
+
* So ask the question directly: replace the subject with something its loader
|
|
313
|
+
* cannot parse, and see whether the defenders go red. If they stay green they
|
|
314
|
+
* are not executing that file, and every verdict about it is meaningless.
|
|
315
|
+
*
|
|
316
|
+
* The predicate is "the run did not stay green", not "a test failed" — an
|
|
317
|
+
* unparseable module usually fails to LOAD, so the runner reports zero tests
|
|
318
|
+
* rather than a failure. Measured, not assumed.
|
|
319
|
+
*
|
|
320
|
+
* One run, not N: the defenders were already green N/N on this exact set, and
|
|
321
|
+
* neither answer here is the optimistic one — `survived` and `unverifiable`
|
|
322
|
+
* both gate.
|
|
323
|
+
*/
|
|
324
|
+
async function negativeControl({ file, defenders, iso, runDefenders, fatalEditFor, cache, stage }) {
|
|
325
|
+
const key = `${file}\n${defenders.join('\n')}`;
|
|
326
|
+
if (cache.has(key)) return cache.get(key);
|
|
327
|
+
const content = fatalEditFor(file);
|
|
328
|
+
// A runner that cannot say what "this cannot compile" looks like for its
|
|
329
|
+
// language does not get to guess. No control, no signal, verdict unchanged.
|
|
330
|
+
if (content == null) {
|
|
331
|
+
cache.set(key, undefined);
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
const control = applyContent(iso.projectDir, file, content, { inPlace: iso.mode === 'in-place' });
|
|
335
|
+
let run;
|
|
336
|
+
try {
|
|
337
|
+
stage('negative-control', 1, 1);
|
|
338
|
+
({ run } = await runDefenders(defenders, [file]));
|
|
339
|
+
} finally {
|
|
340
|
+
control.restore();
|
|
341
|
+
}
|
|
342
|
+
const reached = run.outcome !== 'pass';
|
|
343
|
+
cache.set(key, reached);
|
|
344
|
+
return reached;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function probeOne({ claim, fault, defenders, discovered, allTests, iso, isoReal, onWarn = () => {}, confirmRuns, escalate, baselineCache, controlCache, fatalEditFor, runDefenders, stage, prior, priorRunId, byRunner, mocking = [], signals = [], historyRef, historyDir, toRepoPath }) {
|
|
147
348
|
const targetPath = join(iso.projectDir, fault.file);
|
|
148
349
|
const targetExists = existsSync(targetPath);
|
|
149
350
|
const inputs = {
|
|
@@ -151,14 +352,17 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, co
|
|
|
151
352
|
defenderHashes: Object.fromEntries(defenders.map((f) => [f, hashFile(join(iso.projectDir, f))])),
|
|
152
353
|
};
|
|
153
354
|
|
|
154
|
-
|
|
155
|
-
|
|
355
|
+
const subject = subjectOf(fault, sha256);
|
|
356
|
+
|
|
357
|
+
// Same source, same defenders, same N, same fault: the verdict cannot have changed.
|
|
358
|
+
if (isReusable(prior, { inputs, requested: claim.defendedBy ?? [], resolved: defenders, contentHash: subject.contentHash })) {
|
|
156
359
|
return { ...prior, reusedFrom: prior.reusedFrom ?? priorRunId };
|
|
157
360
|
}
|
|
158
361
|
|
|
159
362
|
const detail = { baselineRuns: [], probeRuns: [] };
|
|
160
363
|
const rawProbeRuns = []; // spec testRun + the runner's timeout count, which classify needs
|
|
161
364
|
let anchor = null;
|
|
365
|
+
let subjectReached; // the negative control's answer; undefined until it is worth asking
|
|
162
366
|
|
|
163
367
|
if (defenders.length > 0) {
|
|
164
368
|
anchor = targetExists ? locate(readFileSync(targetPath, 'utf8'), fault) : { status: 'file-missing', hits: 0, expected: fault.expectHits ?? 1 };
|
|
@@ -177,10 +381,27 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, co
|
|
|
177
381
|
break;
|
|
178
382
|
}
|
|
179
383
|
}
|
|
384
|
+
// The decision stops at the first non-green run — nothing about this
|
|
385
|
+
// fault can be trusted after it. But "failed once in three" and "fails
|
|
386
|
+
// every time" are different problems for whoever has to fix the
|
|
387
|
+
// defender, and the only way to tell them apart is to finish the runs.
|
|
388
|
+
// Paid once per defender set, and only when it is already broken.
|
|
389
|
+
if (runs.length < confirmRuns && runs[runs.length - 1].outcome === 'fail') {
|
|
390
|
+
for (let i = runs.length; i < confirmRuns; i++) {
|
|
391
|
+
stage('baseline-flake', i + 1, confirmRuns);
|
|
392
|
+
runs.push((await runDefenders(defenders)).run);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
180
395
|
baselineCache.set(key, { runs, loadMessage });
|
|
181
396
|
}
|
|
182
397
|
const baseline = baselineCache.get(key);
|
|
183
398
|
detail.baselineRuns = baseline.runs;
|
|
399
|
+
// Observed instability of the defenders on UNMODIFIED source: `fail` is
|
|
400
|
+
// the tests running and disagreeing with themselves. A load error or a
|
|
401
|
+
// timeout is not flakiness — nothing was measured about stability — so
|
|
402
|
+
// neither is counted, on either side of the ratio.
|
|
403
|
+
const rate = flakeRate(baseline.runs);
|
|
404
|
+
if (rate) detail.flakeRate = rate;
|
|
184
405
|
if (baseline.runs.some((r) => r.outcome === 'error')) {
|
|
185
406
|
// The defenders did not load. That is not flakiness and not a verdict
|
|
186
407
|
// about the fault; the claim cannot be probed until they do.
|
|
@@ -188,64 +409,134 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, co
|
|
|
188
409
|
}
|
|
189
410
|
|
|
190
411
|
if (detail.baselineRuns.every((r) => r.outcome === 'pass') && detail.baselineRuns.length === confirmRuns) {
|
|
191
|
-
const mutation = applyFault(iso.projectDir, fault);
|
|
412
|
+
const mutation = applyFault(iso.projectDir, fault, { inPlace: iso.mode === 'in-place' });
|
|
192
413
|
try {
|
|
193
414
|
const probeRuns = rawProbeRuns;
|
|
194
415
|
for (let i = 0; i < confirmRuns; i++) {
|
|
195
416
|
stage('probe', i + 1, confirmRuns);
|
|
196
|
-
const { run, timeouts, loadMessage } = await runDefenders(defenders);
|
|
197
|
-
probeRuns.push({ ...run, timeouts, loadMessage });
|
|
417
|
+
const { run, timeouts, loadMessage, failedTests, provenance } = await runDefenders(defenders, [fault.file]);
|
|
418
|
+
probeRuns.push({ ...run, timeouts, loadMessage, failedTests });
|
|
419
|
+
// Only when the suite actually loaded. A run that failed to load
|
|
420
|
+
// explains an absent import by itself — saying the defenders never
|
|
421
|
+
// reached the file would be true and useless, and it would put the
|
|
422
|
+
// signal on every fault whose replacement does not parse.
|
|
423
|
+
if (i === 0 && run.outcome !== 'error') checkProvenance({ claim, fault, provenance, isoReal, detail, onWarn });
|
|
198
424
|
if (shouldStopEarly(probeRuns)) break;
|
|
199
425
|
}
|
|
200
|
-
detail.probeRuns = probeRuns.map(({ timeouts, loadMessage, ...run }) => run);
|
|
426
|
+
detail.probeRuns = probeRuns.map(({ timeouts, loadMessage, failedTests, ...run }) => run);
|
|
201
427
|
const provisional = classify({ defenders, anchor, baselineRuns: detail.baselineRuns, probeRuns, confirmRuns });
|
|
428
|
+
// `provisional` is deliberately computed BEFORE the control: the control is what turns a survivor into an unverifiable, so asking it first would be circular.
|
|
429
|
+
|
|
430
|
+
// Charge the negative control only where a false answer is expensive:
|
|
431
|
+
// on a survivor. A kill already proves the defenders reached the code,
|
|
432
|
+
// and paying for it on every fault would be waste.
|
|
433
|
+
if (provisional.verdict === 'survived') {
|
|
434
|
+
subjectReached = await negativeControl({ file: fault.file, defenders, iso, runDefenders, fatalEditFor, cache: controlCache, stage });
|
|
435
|
+
if (subjectReached !== undefined) detail.negativeControl = subjectReached ? 'reached' : 'not-reached';
|
|
436
|
+
if (subjectReached === false) {
|
|
437
|
+
onWarn(`${claim.id}/${fault.id}: the defenders stayed green with ${fault.file} replaced by something that cannot compile, so they never execute it. Every verdict about this file would be about their reach, not their assertions — reported as unverifiable rather than as a survivor.`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
202
440
|
|
|
203
441
|
// Escalation: does anything *undeclared* catch it? A single run cannot
|
|
204
442
|
// say — a flaky test elsewhere in the suite would take the credit — so
|
|
205
443
|
// a test is an undeclared killer only if it fails in all N runs.
|
|
444
|
+
// Skipped when the subject is not executed at all: nothing the wider
|
|
445
|
+
// suite does could be evidence about a file nobody loads.
|
|
206
446
|
const broader = allTests.filter((t) => !defenders.includes(t));
|
|
207
|
-
if (provisional.verdict === 'survived' && escalate && broader.length > 0) {
|
|
208
|
-
|
|
209
|
-
|
|
447
|
+
if (provisional.verdict === 'survived' && subjectReached !== false && escalate && broader.length > 0) {
|
|
448
|
+
// The rule lives in attribution.mjs as a pure fold; this loop only
|
|
449
|
+
// decides when to stop asking the runner.
|
|
450
|
+
let state = escalationStart();
|
|
210
451
|
for (let i = 0; i < confirmRuns; i++) {
|
|
211
452
|
stage('escalation', i + 1, confirmRuns);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
killers = killers === null ? new Set(failedTests) : new Set(failedTests.filter((t) => killers.has(t)));
|
|
215
|
-
if (killers.size === 0) break;
|
|
216
|
-
}
|
|
217
|
-
detail.escalated = true;
|
|
218
|
-
detail.escalationRuns = runs;
|
|
219
|
-
if (killers.size > 0 && runs.length === confirmRuns && runs.every(isKill)) {
|
|
220
|
-
detail.reason = 'killed-by-undeclared-tests';
|
|
221
|
-
detail.undeclaredKillers = [...killers].sort();
|
|
453
|
+
state = foldEscalationRun(state, await runDefenders(allTests));
|
|
454
|
+
if (state.stoppedEarly) break;
|
|
222
455
|
}
|
|
456
|
+
Object.assign(detail, escalationResult(state, confirmRuns));
|
|
223
457
|
}
|
|
224
458
|
} finally {
|
|
225
459
|
mutation.restore();
|
|
460
|
+
// The restore found nothing to put back. In worktree mode that is a
|
|
461
|
+
// disturbed run, not a failed one (see applyFault) — but a reader
|
|
462
|
+
// has to be able to tell, because it means something outside this
|
|
463
|
+
// process was deleting the tree the verdicts came from.
|
|
464
|
+
if (mutation.restoreSkipped) detail.restoreSkipped = mutation.restoreSkipped;
|
|
226
465
|
}
|
|
227
466
|
}
|
|
228
467
|
}
|
|
229
468
|
}
|
|
230
469
|
|
|
231
|
-
const { verdict, reason } = classify({ defenders, anchor, baselineRuns: detail.baselineRuns, probeRuns: rawProbeRuns, confirmRuns });
|
|
470
|
+
const { verdict, reason } = classify({ defenders, anchor, baselineRuns: detail.baselineRuns, probeRuns: rawProbeRuns, confirmRuns, subjectReached });
|
|
232
471
|
if (reason && !detail.reason) detail.reason = reason;
|
|
233
472
|
if (anchor && anchor.status !== 'ok' && anchor.status !== 'file-missing' && anchor.status !== 'defenders-failed-to-load') detail.anchor = { hits: anchor.hits, expected: anchor.expected };
|
|
234
473
|
|
|
235
474
|
const blast = targetExists ? blastRadius(iso.projectDir, fault.file) : 0;
|
|
236
475
|
|
|
476
|
+
// L3: a kill by a test written in the same change as the code it guards is
|
|
477
|
+
// weaker evidence than one written separately. Signal only — the verdict is
|
|
478
|
+
// already decided above, and ranking is the only consumer.
|
|
479
|
+
if (verdict === 'killed' && historyRef) {
|
|
480
|
+
// Only the tests that actually failed on the fault are its killers.
|
|
481
|
+
const killers = killersFromRuns(rawProbeRuns);
|
|
482
|
+
detail.independence = classifyIndependence({ dir: historyDir, ref: historyRef, targetFile: toRepoPath(fault.file), defenders: (killers.length ? killers : defenders).map(toRepoPath) });
|
|
483
|
+
}
|
|
484
|
+
|
|
237
485
|
return {
|
|
238
486
|
fingerprint: fingerprint({ claimId: claim.id, subjectId: fault.id, file: fault.file, verdict }),
|
|
239
487
|
claim: { id: claim.id, statement: claim.statement, severity: claim.severity, source: claim.source, producedBy: claim.producedBy },
|
|
240
|
-
subject
|
|
488
|
+
subject,
|
|
241
489
|
verdict,
|
|
242
490
|
detail,
|
|
243
|
-
defenders: { requested: claim.defendedBy ?? [], resolved: defenders, nocover: defenders.length === 0, ...(discovered ? { discovered: true } : {}) },
|
|
491
|
+
defenders: { requested: claim.defendedBy ?? [], resolved: defenders, nocover: defenders.length === 0, ...(discovered ? { discovered: true } : {}), ...(byRunner ? { byRunner } : {}), ...(mocking.length ? { mocking } : {}), ...(signals.length ? { signals } : {}) },
|
|
244
492
|
inputs,
|
|
245
|
-
rank: rank({ severity: claim.severity, sourceKind: claim.source.kind, blast }),
|
|
493
|
+
rank: rank({ severity: claim.severity, sourceKind: claim.source.kind, blast, independence: detail.independence?.class }),
|
|
246
494
|
};
|
|
247
495
|
}
|
|
248
496
|
|
|
497
|
+
/**
|
|
498
|
+
* The negative control the green baseline cannot provide.
|
|
499
|
+
*
|
|
500
|
+
* A clean baseline proves the harness is not reporting everything as broken —
|
|
501
|
+
* the failure mode where a misread runner flatters nothing. It says nothing
|
|
502
|
+
* about the opposite direction: a fault that the interpreter never executes.
|
|
503
|
+
* Then the baseline is green, every fault SURVIVES, and the report reads as a
|
|
504
|
+
* devastating audit finding while being entirely false.
|
|
505
|
+
*
|
|
506
|
+
* Node cannot reach that state from a scratch worktree, because Node loads
|
|
507
|
+
* files by path. Python can. `sys.path[0]` is the working directory, so an
|
|
508
|
+
* ordinary editable install — a `.pth` file appended to sys.path — loses to
|
|
509
|
+
* the tree being probed and is harmless. What wins is a **meta path finder**:
|
|
510
|
+
* a strict editable install registers one, and it is consulted before every
|
|
511
|
+
* path entry. Then the tests import the original file while TestGuard faults
|
|
512
|
+
* the copy, and the whole run is false in the one direction nothing else
|
|
513
|
+
* catches. Measured, not assumed: with the fault live the reporter is asked
|
|
514
|
+
* which file was actually loaded, and the answer is checked.
|
|
515
|
+
*
|
|
516
|
+
* Loaded from outside the tree being probed is never recoverable and never
|
|
517
|
+
* ambiguous: the whole run is refused. Never loaded at all is recorded and
|
|
518
|
+
* warned about but not refused — an import inside a branch the fault does not
|
|
519
|
+
* reach is legitimate, and refusing would be the tool substituting its
|
|
520
|
+
* judgement for the author's.
|
|
521
|
+
*/
|
|
522
|
+
export function checkProvenance({ claim, fault, provenance, isoReal, detail, onWarn }) {
|
|
523
|
+
if (!provenance || !(fault.file in provenance)) return;
|
|
524
|
+
const loaded = provenance[fault.file];
|
|
525
|
+
if (loaded === null) {
|
|
526
|
+
detail.targetNotImported = true;
|
|
527
|
+
onWarn(`${claim.id}/${fault.id}: the defenders never imported ${fault.file}, so nothing they do could detect a fault in it. Whatever the runs show is a statement about the defenders' reach, not about their assertions.`);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (isoReal && !isInside(isoReal, loaded)) {
|
|
531
|
+
throw new PreconditionError(
|
|
532
|
+
`${fault.file}: the fault was applied to ${join(isoReal, fault.file)}, but the interpreter imported ${loaded} instead. ` +
|
|
533
|
+
'Python loaded a different copy of this module, so nothing TestGuard changes can ever run and every claim would be reported as SURVIVED however good the tests are. ' +
|
|
534
|
+
'The usual cause is an install that registers an import hook ahead of sys.path — a strict editable install (`pip install -e . --config-settings editable_mode=strict`, and some build backends by default) — or a plain install that left a copy in site-packages. ' +
|
|
535
|
+
'Either reinstall the project against the tree being probed, or run with --in-place so the tree the install points at is the tree that is faulted.',
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
249
540
|
function sameInputs(prior, inputs, requested, resolved) {
|
|
250
541
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
251
542
|
return prior.inputs.targetHash === inputs.targetHash
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress, for readers that are not a terminal (issue #68).
|
|
3
|
+
*
|
|
4
|
+
* The stage line used to be written only when `process.stderr.isTTY`, with a
|
|
5
|
+
* carriage return and an erase-line so it rewrote itself in place. That is the
|
|
6
|
+
* right shape for a human at a terminal and the wrong shape everywhere else,
|
|
7
|
+
* and "everywhere else" is where a probe actually runs: CI, a redirected log,
|
|
8
|
+
* an agent harness. In all of those the tool printed **nothing** for twenty
|
|
9
|
+
* minutes, which is indistinguishable from being hung — and a gate you cannot
|
|
10
|
+
* tell from a hang is a gate people start killing.
|
|
11
|
+
*
|
|
12
|
+
* So the rewriting is a *rendering* choice, not a reason to withhold the
|
|
13
|
+
* information:
|
|
14
|
+
*
|
|
15
|
+
* tty `\r`-rewritten single line — unchanged, for a human at a terminal
|
|
16
|
+
* plain one line per stage, append-only — a redirected log, CI, an agent
|
|
17
|
+
* ndjson one JSON object per line — for a machine that parses progress
|
|
18
|
+
* none silence, for `--quiet` and `--json`
|
|
19
|
+
*
|
|
20
|
+
* `auto` picks `tty` when stderr is a terminal and `plain` when it is not.
|
|
21
|
+
* Progress always goes to **stderr**, so `--json` on stdout stays parseable.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Which rendering to use. `explicit` is `--progress`; it always wins. */
|
|
25
|
+
export function progressMode({ explicit, isTTY = false, quiet = false, json = false } = {}) {
|
|
26
|
+
if (explicit && explicit !== 'auto') return explicit;
|
|
27
|
+
// A silent mode is a choice about output, and an explicit --progress
|
|
28
|
+
// overrides it above; without one, --quiet and --json mean silence.
|
|
29
|
+
if (quiet || json) return 'none';
|
|
30
|
+
return isTTY ? 'tty' : 'plain';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The stream progress is written to. **Always stderr, never stdout.**
|
|
35
|
+
*
|
|
36
|
+
* stdout carries the result: with `--json` it is one document and nothing
|
|
37
|
+
* else, and every consumer pipes it somewhere that parses it. A progress line
|
|
38
|
+
* on stdout does not degrade that output, it destroys it. The choice is a
|
|
39
|
+
* function so it can be falsified without running a probe.
|
|
40
|
+
*/
|
|
41
|
+
export const progressStream = (streams) => streams.stderr;
|
|
42
|
+
|
|
43
|
+
export const PROGRESS_MODES = ['auto', 'tty', 'plain', 'ndjson', 'none'];
|
|
44
|
+
export const isProgressMode = (m) => PROGRESS_MODES.includes(m);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A stage reporter for the chosen mode, or `undefined` for `none` — the probe
|
|
48
|
+
* takes `undefined` to mean "do not report", so no mode needs a no-op branch
|
|
49
|
+
* inside the hot loop.
|
|
50
|
+
*
|
|
51
|
+
* `plain` prints every stage line, including each run of an N-run phase. That
|
|
52
|
+
* is the point: the thing a reader of a twenty-minute log wants to know is
|
|
53
|
+
* that run 2 of 3 started, which is exactly what the TTY line was already
|
|
54
|
+
* saying and throwing away.
|
|
55
|
+
*/
|
|
56
|
+
export function stageReporter(mode, write) {
|
|
57
|
+
if (mode === 'none') return undefined;
|
|
58
|
+
if (mode === 'ndjson') {
|
|
59
|
+
return ({ claimId, faultId, stage, i, n }) => write(JSON.stringify({ event: 'stage', claim: claimId, fault: faultId, stage, run: i, of: n, at: new Date().toISOString() }) + '\n');
|
|
60
|
+
}
|
|
61
|
+
if (mode === 'plain') {
|
|
62
|
+
return ({ claimId, faultId, stage, i, n }) => write(` … ${claimId}/${faultId} ${stage} ${i}/${n}\n`);
|
|
63
|
+
}
|
|
64
|
+
return ({ claimId, faultId, stage, i, n }) => write(`\r\x1b[K … ${claimId}/${faultId} ${stage} ${i}/${n}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What to write before a verdict line, so a rewritten TTY stage line does not
|
|
69
|
+
* end up with a verdict appended to its tail. Only `tty` needs it; every other
|
|
70
|
+
* mode is append-only and already on its own line.
|
|
71
|
+
*/
|
|
72
|
+
export const clearStageLine = (mode) => (mode === 'tty' ? '\r\x1b[K' : '');
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A per-record line for `ndjson`, so a machine watching the stream sees
|
|
76
|
+
* verdicts as they land rather than only at the end.
|
|
77
|
+
*/
|
|
78
|
+
export const recordEvent = (r) => JSON.stringify({
|
|
79
|
+
event: 'verdict',
|
|
80
|
+
claim: r.claim.id,
|
|
81
|
+
fault: r.subject.id,
|
|
82
|
+
verdict: r.verdict,
|
|
83
|
+
severity: r.claim.severity,
|
|
84
|
+
file: r.subject.file,
|
|
85
|
+
...(r.reusedFrom ? { reused: true } : {}),
|
|
86
|
+
at: new Date().toISOString(),
|
|
87
|
+
}) + '\n';
|