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.
Files changed (65) hide show
  1. package/CHANGELOG.md +628 -0
  2. package/README.md +536 -42
  3. package/package.json +2 -2
  4. package/spec/GATE-SEMANTICS.md +166 -2
  5. package/spec/README.md +29 -10
  6. package/spec/lib/validate.mjs +140 -8
  7. package/spec/schemas/baseline.schema.json +13 -5
  8. package/spec/schemas/brief.schema.json +12 -12
  9. package/spec/schemas/calibration.schema.json +5 -5
  10. package/spec/schemas/claims.schema.json +122 -25
  11. package/spec/schemas/common.schema.json +131 -29
  12. package/spec/schemas/evidence.schema.json +476 -69
  13. package/spec/schemas/gate.schema.json +12 -12
  14. package/spec/schemas/ignore.schema.json +4 -4
  15. package/spec/schemas/replay.schema.json +85 -0
  16. package/spec/schemas/status.schema.json +258 -56
  17. package/src/admit/admit.mjs +17 -0
  18. package/src/baseline/baseline.mjs +31 -2
  19. package/src/brief/brief.mjs +71 -7
  20. package/src/claims/removed.mjs +161 -0
  21. package/src/cli.mjs +68 -10
  22. package/src/commands/admit.mjs +116 -0
  23. package/src/commands/baseline.mjs +17 -3
  24. package/src/commands/brief.mjs +13 -7
  25. package/src/commands/claims.mjs +55 -6
  26. package/src/commands/init.mjs +19 -6
  27. package/src/commands/mcp.mjs +12 -0
  28. package/src/commands/probe.mjs +35 -7
  29. package/src/commands/replay.mjs +63 -0
  30. package/src/commands/status.mjs +3 -1
  31. package/src/gate/changed.mjs +9 -2
  32. package/src/git.mjs +5 -0
  33. package/src/init/init.mjs +182 -29
  34. package/src/init/templates/SKILL.md +3 -3
  35. package/src/mcp/server.mjs +104 -0
  36. package/src/mcp/tools.mjs +114 -0
  37. package/src/probe/attribution.mjs +123 -0
  38. package/src/probe/classify.mjs +18 -2
  39. package/src/probe/contention.mjs +60 -0
  40. package/src/probe/cost.mjs +145 -0
  41. package/src/probe/discover.mjs +20 -3
  42. package/src/probe/independence.mjs +59 -0
  43. package/src/probe/inject.mjs +41 -9
  44. package/src/probe/mocks.mjs +122 -0
  45. package/src/probe/probe.mjs +331 -40
  46. package/src/probe/progress.mjs +87 -0
  47. package/src/probe/pyimports.mjs +287 -0
  48. package/src/probe/rank.mjs +75 -15
  49. package/src/probe/runners/index.mjs +79 -8
  50. package/src/probe/runners/jest.mjs +15 -3
  51. package/src/probe/runners/playwright.mjs +142 -0
  52. package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
  53. package/src/probe/runners/python/_testguard_report.py +141 -0
  54. package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
  55. package/src/probe/runners/python.mjs +316 -0
  56. package/src/probe/runners/shared.mjs +80 -14
  57. package/src/probe/runners/vitest.mjs +16 -3
  58. package/src/render.mjs +2 -2
  59. package/src/replay/label.mjs +64 -0
  60. package/src/replay/replay.mjs +310 -0
  61. package/src/scaffold/producers.mjs +217 -6
  62. package/src/scaffold/producers.python.mjs +209 -0
  63. package/src/scaffold/scaffold.mjs +90 -30
  64. package/src/status/status.mjs +47 -6
  65. package/src/util/glob.mjs +7 -1
@@ -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
- 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
+ }
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 if (ref === 'HEAD') {
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
- 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, or use --in-place.`);
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
- const allTests = runner.tests(iso.projectDir);
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
- const runDefenders = (files) => runner.run({ projectDir: iso.projectDir, files, budgetMs, commandTemplate });
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
- const defenders = declared ?? discoverDefenders(iso.projectDir, fault.file);
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 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 });
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.name, ...((runnerVersion ?? readRunnerVersion(projectDir, runner.name)) ? { version: runnerVersion ?? readRunnerVersion(projectDir, runner.name) } : {}) },
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
- async function probeOne({ claim, fault, defenders, discovered, allTests, iso, confirmRuns, escalate, baselineCache, runDefenders, stage, prior, priorRunId }) {
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
- // Same source, same defenders, same N: the verdict cannot have changed.
155
- 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 })) {
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
- const runs = [];
209
- let killers = null;
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
- const { run, failedTests } = await runDefenders(allTests);
213
- runs.push(run);
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: { 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,
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';