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
package/src/probe/probe.mjs
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
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 { isAbsolute, 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
6
|
import { discoverDefenders, discoverDefendersDetailed } from './discover.mjs';
|
|
7
7
|
import { classifyDefenders } from './mocks.mjs';
|
|
8
8
|
import { detectContention, contentionWarning } from './contention.mjs';
|
|
9
9
|
import { createScratch, inPlace, PreconditionError } from './worktree.mjs';
|
|
10
|
-
import { applyFault, locate } from './inject.mjs';
|
|
10
|
+
import { applyContent, applyFault, locate } from './inject.mjs';
|
|
11
11
|
import { resolveDefenders, parseCommandTemplate } from './runners/shared.mjs';
|
|
12
|
-
import { selectRunner, RUNNERS, OWNED_RUNNERS, partitionByRunner, mergeRuns } from './runners/index.mjs';
|
|
12
|
+
import { selectRunner, RUNNERS, OWNED_RUNNERS, partitionByRunner, mergeRuns, runnerLabel, runnerFor } from './runners/index.mjs';
|
|
13
13
|
import { classify, shouldStopEarly } from './classify.mjs';
|
|
14
14
|
import { blastRadius, rank } from './rank.mjs';
|
|
15
15
|
import { classifyIndependence } from './independence.mjs';
|
|
16
|
+
import { escalationStart, foldEscalationRun, escalationResult, flakeRate, killersFromRuns, subjectOf, isReusable } from './attribution.mjs';
|
|
16
17
|
import { hashFile, sha256 } from '../util/hash.mjs';
|
|
17
18
|
import { fingerprint } from '../../spec/lib/fingerprint.mjs';
|
|
18
19
|
|
|
19
|
-
|
|
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
|
+
}
|
|
20
25
|
|
|
21
26
|
/** realpath when the path still exists; the path itself otherwise. Never throws. */
|
|
22
27
|
function realpathSync(p) {
|
|
@@ -53,6 +58,7 @@ export async function probe({
|
|
|
53
58
|
runnerCommand,
|
|
54
59
|
runnerName = 'auto',
|
|
55
60
|
nodeModules,
|
|
61
|
+
python,
|
|
56
62
|
only,
|
|
57
63
|
includeDirty = false,
|
|
58
64
|
onStage = () => {},
|
|
@@ -125,12 +131,17 @@ export async function probe({
|
|
|
125
131
|
let runnerVersion;
|
|
126
132
|
let runnerSource;
|
|
127
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);
|
|
128
139
|
const runnersUsed = new Map(); // every runner that ran defenders, beyond the project runner
|
|
129
140
|
try {
|
|
130
141
|
const commandTemplate = runnerCommand ? parseCommandTemplate(runnerCommand) : undefined;
|
|
131
142
|
if (!commandTemplate) {
|
|
132
143
|
// An unresolvable runner is a precondition failure, not a flaky defender.
|
|
133
|
-
const sel = await selectRunner({ projectDir: iso.projectDir, name: runnerName });
|
|
144
|
+
const sel = await selectRunner({ projectDir: iso.projectDir, sourceDir: projectDir, python, name: runnerName });
|
|
134
145
|
if (sel.error) {
|
|
135
146
|
throw new PreconditionError(`test runner is not resolvable in the ${mode === 'worktree' ? 'scratch worktree' : 'project'} (${sel.error}). ` +
|
|
136
147
|
(mode === 'worktree' ? 'No usable node_modules was linked: pass --node-modules <path>, or run with --in-place.' : 'Install dependencies first.'));
|
|
@@ -138,30 +149,40 @@ export async function probe({
|
|
|
138
149
|
runner = sel.runner;
|
|
139
150
|
runnerVersion = sel.version;
|
|
140
151
|
runnerSource = sel.source;
|
|
152
|
+
if (sel.engine) engines.set(sel.runner, sel.engine);
|
|
141
153
|
}
|
|
142
|
-
runnersUsed.set(runner
|
|
154
|
+
runnersUsed.set(labelOf(runner), runnerVersion ?? readRunnerVersion(projectDir, runner.name));
|
|
143
155
|
// Files an owning runner (Playwright) claims run under it, whatever the
|
|
144
156
|
// project runner is; it must resolve before the first such defender runs.
|
|
145
157
|
const owned = OWNED_RUNNERS.filter((r) => r !== runner);
|
|
146
158
|
const ownedChecked = new Map();
|
|
147
159
|
const ensureOwned = async (r, file) => {
|
|
148
|
-
if (!ownedChecked.has(r)) ownedChecked.set(r, await r.check({ projectDir: iso.projectDir }));
|
|
160
|
+
if (!ownedChecked.has(r)) ownedChecked.set(r, await r.check({ projectDir: iso.projectDir, sourceDir: projectDir, python }));
|
|
149
161
|
const c = ownedChecked.get(r);
|
|
150
|
-
if (!c.ok) throw new PreconditionError(`${file} is a ${r.name} test (it lives under ${r.name}'s testDir) but ${r.name} is not resolvable
|
|
151
|
-
|
|
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);
|
|
152
165
|
};
|
|
153
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();
|
|
154
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?.();
|
|
155
174
|
const prior = previous && previous.run.confirmRuns === confirmRuns
|
|
156
175
|
? new Map(previous.records.map((r) => [`${r.claim.id}/${r.subject.id}`, r]))
|
|
157
176
|
: new Map();
|
|
158
|
-
|
|
159
|
-
|
|
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 });
|
|
160
181
|
const groups = partitionByRunner(iso.projectDir, files, runner);
|
|
161
182
|
const parts = [];
|
|
162
183
|
for (const [r, group] of groups) {
|
|
163
184
|
if (r !== runner) await ensureOwned(r, group[0]);
|
|
164
|
-
parts.push(await r.run({ projectDir: iso.projectDir, files: group, budgetMs, serial }));
|
|
185
|
+
parts.push(await r.run({ projectDir: iso.projectDir, sourceDir: projectDir, python, files: group, budgetMs, serial, targets }));
|
|
165
186
|
}
|
|
166
187
|
return mergeRuns(parts);
|
|
167
188
|
};
|
|
@@ -169,7 +190,7 @@ export async function probe({
|
|
|
169
190
|
const byRunner = (files) => {
|
|
170
191
|
const groups = partitionByRunner(iso.projectDir, files, runner);
|
|
171
192
|
if (groups.size <= 1) return undefined; // one runner ran them all, whichever it was
|
|
172
|
-
return Object.fromEntries([...groups].map(([r, group]) => [r
|
|
193
|
+
return Object.fromEntries([...groups].map(([r, group]) => [labelOf(r), group]));
|
|
173
194
|
};
|
|
174
195
|
|
|
175
196
|
for (const claim of claims.claims) {
|
|
@@ -183,14 +204,26 @@ export async function probe({
|
|
|
183
204
|
const mockInfo = declared ? classifyDefenders(iso.projectDir, fault.file, declared) : discoverDefendersDetailed(iso.projectDir, fault.file);
|
|
184
205
|
const defenders = declared ?? mockInfo.canDetect;
|
|
185
206
|
const stage = (name, i, n) => onStage({ claimId: claim.id, faultId: fault.id, stage: name, i, n });
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
}
|
|
194
227
|
records.push(record);
|
|
195
228
|
onProgress(record);
|
|
196
229
|
}
|
|
@@ -207,19 +240,111 @@ export async function probe({
|
|
|
207
240
|
startedAt,
|
|
208
241
|
finishedAt: new Date().toISOString(),
|
|
209
242
|
repo: { head, dirty: isDirty(root), ...(snapshot ? { snapshot } : {}), ...(ignoredDirty.length ? { ignoredDirty } : {}) },
|
|
210
|
-
runner: { name: runner
|
|
243
|
+
runner: { name: labelOf(runner), ...((runnerVersion ?? readRunnerVersion(projectDir, runner.name)) ? { version: runnerVersion ?? readRunnerVersion(projectDir, runner.name) } : {}) },
|
|
211
244
|
...(runnersUsed.size > 1 ? { runners: [...runnersUsed].map(([n, v]) => ({ name: n, ...(v ? { version: v } : {}) })) } : {}),
|
|
212
245
|
confirmRuns,
|
|
213
246
|
...(serial ? { serial: true } : {}),
|
|
214
247
|
...(contention.detected ? { contention } : {}),
|
|
215
248
|
...(confirmRuns < 3 ? { provisional: true } : {}),
|
|
216
|
-
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,
|
|
217
254
|
},
|
|
218
255
|
records,
|
|
219
256
|
};
|
|
220
257
|
}
|
|
221
258
|
|
|
222
|
-
|
|
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 }) {
|
|
223
348
|
const targetPath = join(iso.projectDir, fault.file);
|
|
224
349
|
const targetExists = existsSync(targetPath);
|
|
225
350
|
const inputs = {
|
|
@@ -227,14 +352,17 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, co
|
|
|
227
352
|
defenderHashes: Object.fromEntries(defenders.map((f) => [f, hashFile(join(iso.projectDir, f))])),
|
|
228
353
|
};
|
|
229
354
|
|
|
230
|
-
|
|
231
|
-
|
|
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 })) {
|
|
232
359
|
return { ...prior, reusedFrom: prior.reusedFrom ?? priorRunId };
|
|
233
360
|
}
|
|
234
361
|
|
|
235
362
|
const detail = { baselineRuns: [], probeRuns: [] };
|
|
236
363
|
const rawProbeRuns = []; // spec testRun + the runner's timeout count, which classify needs
|
|
237
364
|
let anchor = null;
|
|
365
|
+
let subjectReached; // the negative control's answer; undefined until it is worth asking
|
|
238
366
|
|
|
239
367
|
if (defenders.length > 0) {
|
|
240
368
|
anchor = targetExists ? locate(readFileSync(targetPath, 'utf8'), fault) : { status: 'file-missing', hits: 0, expected: fault.expectHits ?? 1 };
|
|
@@ -272,8 +400,8 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, co
|
|
|
272
400
|
// the tests running and disagreeing with themselves. A load error or a
|
|
273
401
|
// timeout is not flakiness — nothing was measured about stability — so
|
|
274
402
|
// neither is counted, on either side of the ratio.
|
|
275
|
-
const
|
|
276
|
-
if (
|
|
403
|
+
const rate = flakeRate(baseline.runs);
|
|
404
|
+
if (rate) detail.flakeRate = rate;
|
|
277
405
|
if (baseline.runs.some((r) => r.outcome === 'error')) {
|
|
278
406
|
// The defenders did not load. That is not flakiness and not a verdict
|
|
279
407
|
// about the fault; the claim cannot be probed until they do.
|
|
@@ -281,53 +409,65 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, co
|
|
|
281
409
|
}
|
|
282
410
|
|
|
283
411
|
if (detail.baselineRuns.every((r) => r.outcome === 'pass') && detail.baselineRuns.length === confirmRuns) {
|
|
284
|
-
const mutation = applyFault(iso.projectDir, fault);
|
|
412
|
+
const mutation = applyFault(iso.projectDir, fault, { inPlace: iso.mode === 'in-place' });
|
|
285
413
|
try {
|
|
286
414
|
const probeRuns = rawProbeRuns;
|
|
287
415
|
for (let i = 0; i < confirmRuns; i++) {
|
|
288
416
|
stage('probe', i + 1, confirmRuns);
|
|
289
|
-
const { run, timeouts, loadMessage, failedTests } = await runDefenders(defenders);
|
|
417
|
+
const { run, timeouts, loadMessage, failedTests, provenance } = await runDefenders(defenders, [fault.file]);
|
|
290
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 });
|
|
291
424
|
if (shouldStopEarly(probeRuns)) break;
|
|
292
425
|
}
|
|
293
426
|
detail.probeRuns = probeRuns.map(({ timeouts, loadMessage, failedTests, ...run }) => run);
|
|
294
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
|
+
}
|
|
295
440
|
|
|
296
441
|
// Escalation: does anything *undeclared* catch it? A single run cannot
|
|
297
442
|
// say — a flaky test elsewhere in the suite would take the credit — so
|
|
298
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.
|
|
299
446
|
const broader = allTests.filter((t) => !defenders.includes(t));
|
|
300
|
-
if (provisional.verdict === 'survived' && escalate && broader.length > 0) {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
let
|
|
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();
|
|
304
451
|
for (let i = 0; i < confirmRuns; i++) {
|
|
305
452
|
stage('escalation', i + 1, confirmRuns);
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
killers = killers === null ? new Set(failedTests) : new Set(failedTests.filter((t) => killers.has(t)));
|
|
309
|
-
// Attribution needs a test that failed in ALL N runs. Once the
|
|
310
|
-
// intersection is empty no further run can name a killer, so the
|
|
311
|
-
// remaining runs are wasted; stopping can only fail to NAME a
|
|
312
|
-
// killer, never upgrade a verdict, which is the pessimistic side.
|
|
313
|
-
if (killers.size === 0) { stoppedEarly = 'no-common-failure'; break; }
|
|
314
|
-
}
|
|
315
|
-
detail.escalated = true;
|
|
316
|
-
detail.escalationRuns = runs;
|
|
317
|
-
if (stoppedEarly) detail.escalationStoppedEarly = stoppedEarly;
|
|
318
|
-
if (killers.size > 0 && runs.length === confirmRuns && runs.every(isKill)) {
|
|
319
|
-
detail.reason = 'killed-by-undeclared-tests';
|
|
320
|
-
detail.undeclaredKillers = [...killers].sort();
|
|
453
|
+
state = foldEscalationRun(state, await runDefenders(allTests));
|
|
454
|
+
if (state.stoppedEarly) break;
|
|
321
455
|
}
|
|
456
|
+
Object.assign(detail, escalationResult(state, confirmRuns));
|
|
322
457
|
}
|
|
323
458
|
} finally {
|
|
324
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;
|
|
325
465
|
}
|
|
326
466
|
}
|
|
327
467
|
}
|
|
328
468
|
}
|
|
329
469
|
|
|
330
|
-
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 });
|
|
331
471
|
if (reason && !detail.reason) detail.reason = reason;
|
|
332
472
|
if (anchor && anchor.status !== 'ok' && anchor.status !== 'file-missing' && anchor.status !== 'defenders-failed-to-load') detail.anchor = { hits: anchor.hits, expected: anchor.expected };
|
|
333
473
|
|
|
@@ -338,14 +478,14 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, co
|
|
|
338
478
|
// already decided above, and ranking is the only consumer.
|
|
339
479
|
if (verdict === 'killed' && historyRef) {
|
|
340
480
|
// Only the tests that actually failed on the fault are its killers.
|
|
341
|
-
const killers =
|
|
481
|
+
const killers = killersFromRuns(rawProbeRuns);
|
|
342
482
|
detail.independence = classifyIndependence({ dir: historyDir, ref: historyRef, targetFile: toRepoPath(fault.file), defenders: (killers.length ? killers : defenders).map(toRepoPath) });
|
|
343
483
|
}
|
|
344
484
|
|
|
345
485
|
return {
|
|
346
486
|
fingerprint: fingerprint({ claimId: claim.id, subjectId: fault.id, file: fault.file, verdict }),
|
|
347
487
|
claim: { id: claim.id, statement: claim.statement, severity: claim.severity, source: claim.source, producedBy: claim.producedBy },
|
|
348
|
-
subject
|
|
488
|
+
subject,
|
|
349
489
|
verdict,
|
|
350
490
|
detail,
|
|
351
491
|
defenders: { requested: claim.defendedBy ?? [], resolved: defenders, nocover: defenders.length === 0, ...(discovered ? { discovered: true } : {}), ...(byRunner ? { byRunner } : {}), ...(mocking.length ? { mocking } : {}), ...(signals.length ? { signals } : {}) },
|
|
@@ -354,6 +494,49 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, co
|
|
|
354
494
|
};
|
|
355
495
|
}
|
|
356
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
|
+
|
|
357
540
|
function sameInputs(prior, inputs, requested, resolved) {
|
|
358
541
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
359
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';
|