testguard-cli 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +489 -0
  2. package/README.md +287 -34
  3. package/package.json +2 -2
  4. package/spec/GATE-SEMANTICS.md +210 -8
  5. package/spec/README.md +32 -11
  6. package/spec/lib/validate.mjs +125 -14
  7. package/spec/lib/wilson.mjs +112 -0
  8. package/spec/schemas/baseline.schema.json +7 -7
  9. package/spec/schemas/brief.schema.json +12 -12
  10. package/spec/schemas/calibration.schema.json +96 -26
  11. package/spec/schemas/claims.schema.json +129 -25
  12. package/spec/schemas/common.schema.json +127 -29
  13. package/spec/schemas/evidence.schema.json +175 -73
  14. package/spec/schemas/gate.schema.json +12 -12
  15. package/spec/schemas/ignore.schema.json +4 -4
  16. package/spec/schemas/replay.schema.json +10 -10
  17. package/spec/schemas/status.schema.json +25 -25
  18. package/src/cli.mjs +20 -5
  19. package/src/commands/claims.mjs +22 -2
  20. package/src/commands/probe.mjs +24 -4
  21. package/src/commands/replay.mjs +1 -1
  22. package/src/gate/changed.mjs +9 -2
  23. package/src/probe/attribution.mjs +123 -0
  24. package/src/probe/classify.mjs +18 -2
  25. package/src/probe/cost.mjs +205 -0
  26. package/src/probe/discover.mjs +14 -6
  27. package/src/probe/inject.mjs +41 -9
  28. package/src/probe/mocks.mjs +2 -0
  29. package/src/probe/probe.mjs +235 -52
  30. package/src/probe/progress.mjs +87 -0
  31. package/src/probe/pyimports.mjs +287 -0
  32. package/src/probe/rank.mjs +2 -0
  33. package/src/probe/runners/index.mjs +38 -11
  34. package/src/probe/runners/jest.mjs +13 -1
  35. package/src/probe/runners/playwright.mjs +9 -0
  36. package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
  37. package/src/probe/runners/python/_testguard_report.py +141 -0
  38. package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
  39. package/src/probe/runners/python.mjs +316 -0
  40. package/src/probe/runners/shared.mjs +10 -1
  41. package/src/probe/runners/vitest.mjs +13 -1
  42. package/src/replay/replay.mjs +50 -22
  43. package/src/scaffold/producers.python.mjs +209 -0
  44. package/src/scaffold/scaffold.mjs +86 -32
  45. package/src/util/glob.mjs +7 -1
@@ -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
- const isKill = (r) => r.outcome === 'fail' && r.assertionFailures > 0;
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.name, runnerVersion ?? readRunnerVersion(projectDir, runner.name));
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 in the ${mode === 'worktree' ? 'scratch worktree' : 'project'} (${c.message}).`);
151
- runnersUsed.set(r.name, c.version);
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
- const runDefenders = async (files) => {
159
- if (commandTemplate) return runner.run({ projectDir: iso.projectDir, files, budgetMs, commandTemplate, serial });
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.name, group]));
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 record = await probeOne({ claim, fault, defenders, discovered: declared === null, allTests, iso, confirmRuns, escalate, baselineCache, runDefenders, stage, prior: prior.get(`${claim.id}/${fault.id}`), priorRunId: previous?.run.id, byRunner: byRunner(defenders), mocking: mockInfo.mocking, signals: mockInfo.signals,
187
- historyRef: snapshot ?? (mode === 'worktree' ? head : 'HEAD'), historyDir: root,
188
- // A path from the runner is absolute inside the SCRATCH worktree, or
189
- // project-relative. Either way history is read from the real repository,
190
- // so both must land on a repo-root-relative path. The runner reports
191
- // realpaths (/private/var on macOS) while the worktree path may not be
192
- // one — realpath both sides or every comparison silently misses.
193
- toRepoPath: (p) => relative(root, join(projectDir, isAbsolute(p) ? relative(isoReal, realpathSync(p)) : p)) });
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.name, ...((runnerVersion ?? readRunnerVersion(projectDir, runner.name)) ? { version: runnerVersion ?? readRunnerVersion(projectDir, runner.name) } : {}) },
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
- async function probeOne({ claim, fault, defenders, discovered, allTests, iso, confirmRuns, escalate, baselineCache, runDefenders, stage, prior, priorRunId, byRunner, mocking = [], signals = [], historyRef, historyDir, toRepoPath }) {
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
- // Same source, same defenders, same N: the verdict cannot have changed.
231
- if (prior && sameInputs(prior, inputs, claim.defendedBy ?? [], defenders)) {
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 measured = baseline.runs.filter((r) => r.outcome === 'pass' || r.outcome === 'fail');
276
- if (measured.length) detail.flakeRate = { runs: measured.length, failures: measured.filter((r) => r.outcome === 'fail').length };
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
- const runs = [];
302
- let killers = null;
303
- let stoppedEarly;
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
- const { run, failedTests } = await runDefenders(allTests);
307
- runs.push(run);
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 = [...new Set(rawProbeRuns.flatMap((r) => r.failedTests ?? []).map((t) => t.split('::')[0]).filter(Boolean))];
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: { kind: 'fault', id: fault.id, description: fault.description, file: fault.file, faultClass: fault.faultClass, producedBy: fault.producedBy, contentHash: sha256(`${fault.find}\n${fault.replace}`) },
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';