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,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "urn:guard-spec:v1:status",
4
- "title": "Guard spec v1 \u2014 status",
3
+ "$id": "urn:claimspec:v1:status",
4
+ "title": "claimspec v1 \u2014 status",
5
5
  "description": "The single machine-readable answer to 'where is this project and what should happen next'. Every human rendering (CLI text, agent brief, skill instructions) derives from this document so they cannot disagree. Computed from the claims file, the evidence, the baseline and the current working tree; never from a cached verdict alone.",
6
6
  "type": "object",
7
7
  "required": [
@@ -20,10 +20,10 @@
20
20
  "const": 1
21
21
  },
22
22
  "tool": {
23
- "$ref": "urn:guard-spec:v1:common#/$defs/tool"
23
+ "$ref": "urn:claimspec:v1:common#/$defs/tool"
24
24
  },
25
25
  "generatedAt": {
26
- "$ref": "urn:guard-spec:v1:common#/$defs/isoDateTime"
26
+ "$ref": "urn:claimspec:v1:common#/$defs/isoDateTime"
27
27
  },
28
28
  "state": {
29
29
  "description": "no-claims: no claims file. unclaimed-changes: the change measured against a reference touches source files that carry no claim and no excusing ignore entry (only computed when a reference is known; it precedes every evidence state because the claim is written before more code). unprobed: claims but no confirmed evidence. evidence-stale: evidence exists but a claim, fault, target or defender changed since. provisional-only: only sub-N evidence exists. unproven: current evidence has findings not covered by the baseline. clean: every finding is killed or baselined.",
@@ -69,7 +69,7 @@
69
69
  },
70
70
  "file": {
71
71
  "description": "For claim (and for scaffold when a change is known): the first uncovered changed file the action is about.",
72
- "$ref": "urn:guard-spec:v1:common#/$defs/repoPath"
72
+ "$ref": "urn:claimspec:v1:common#/$defs/repoPath"
73
73
  },
74
74
  "target": {
75
75
  "description": "For write-test / review-fault-change: the finding the action is about.",
@@ -80,16 +80,16 @@
80
80
  ],
81
81
  "properties": {
82
82
  "claimId": {
83
- "$ref": "urn:guard-spec:v1:common#/$defs/identifier"
83
+ "$ref": "urn:claimspec:v1:common#/$defs/identifier"
84
84
  },
85
85
  "subjectId": {
86
- "$ref": "urn:guard-spec:v1:common#/$defs/identifier"
86
+ "$ref": "urn:claimspec:v1:common#/$defs/identifier"
87
87
  },
88
88
  "file": {
89
- "$ref": "urn:guard-spec:v1:common#/$defs/repoPath"
89
+ "$ref": "urn:claimspec:v1:common#/$defs/repoPath"
90
90
  },
91
91
  "verdict": {
92
- "$ref": "urn:guard-spec:v1:common#/$defs/verdict"
92
+ "$ref": "urn:claimspec:v1:common#/$defs/verdict"
93
93
  }
94
94
  },
95
95
  "additionalProperties": false
@@ -173,16 +173,16 @@
173
173
  ],
174
174
  "properties": {
175
175
  "claimId": {
176
- "$ref": "urn:guard-spec:v1:common#/$defs/identifier"
176
+ "$ref": "urn:claimspec:v1:common#/$defs/identifier"
177
177
  },
178
178
  "subjectId": {
179
- "$ref": "urn:guard-spec:v1:common#/$defs/identifier"
179
+ "$ref": "urn:claimspec:v1:common#/$defs/identifier"
180
180
  },
181
181
  "previousVerdict": {
182
- "$ref": "urn:guard-spec:v1:common#/$defs/verdict"
182
+ "$ref": "urn:claimspec:v1:common#/$defs/verdict"
183
183
  },
184
184
  "file": {
185
- "$ref": "urn:guard-spec:v1:common#/$defs/repoPath"
185
+ "$ref": "urn:claimspec:v1:common#/$defs/repoPath"
186
186
  }
187
187
  },
188
188
  "additionalProperties": false
@@ -204,26 +204,26 @@
204
204
  ],
205
205
  "properties": {
206
206
  "fingerprint": {
207
- "$ref": "urn:guard-spec:v1:common#/$defs/sha256"
207
+ "$ref": "urn:claimspec:v1:common#/$defs/sha256"
208
208
  },
209
209
  "claimId": {
210
- "$ref": "urn:guard-spec:v1:common#/$defs/identifier"
210
+ "$ref": "urn:claimspec:v1:common#/$defs/identifier"
211
211
  },
212
212
  "subjectId": {
213
- "$ref": "urn:guard-spec:v1:common#/$defs/identifier"
213
+ "$ref": "urn:claimspec:v1:common#/$defs/identifier"
214
214
  },
215
215
  "verdict": {
216
- "$ref": "urn:guard-spec:v1:common#/$defs/verdict"
216
+ "$ref": "urn:claimspec:v1:common#/$defs/verdict"
217
217
  },
218
218
  "reason": {
219
219
  "type": "string",
220
220
  "maxLength": 256
221
221
  },
222
222
  "severity": {
223
- "$ref": "urn:guard-spec:v1:common#/$defs/severity"
223
+ "$ref": "urn:claimspec:v1:common#/$defs/severity"
224
224
  },
225
225
  "file": {
226
- "$ref": "urn:guard-spec:v1:common#/$defs/repoPath"
226
+ "$ref": "urn:claimspec:v1:common#/$defs/repoPath"
227
227
  },
228
228
  "rank": {
229
229
  "type": "number"
@@ -255,7 +255,7 @@
255
255
  "maxLength": 256
256
256
  },
257
257
  "base": {
258
- "$ref": "urn:guard-spec:v1:common#/$defs/gitSha"
258
+ "$ref": "urn:claimspec:v1:common#/$defs/gitSha"
259
259
  },
260
260
  "includeDirty": {
261
261
  "type": "boolean"
@@ -275,19 +275,19 @@
275
275
  "uncovered": {
276
276
  "type": "array",
277
277
  "items": {
278
- "$ref": "urn:guard-spec:v1:common#/$defs/uncoveredFile"
278
+ "$ref": "urn:claimspec:v1:common#/$defs/uncoveredFile"
279
279
  }
280
280
  },
281
281
  "reliedOn": {
282
282
  "type": "array",
283
283
  "items": {
284
- "$ref": "urn:guard-spec:v1:common#/$defs/ignoreReliance"
284
+ "$ref": "urn:claimspec:v1:common#/$defs/ignoreReliance"
285
285
  }
286
286
  },
287
287
  "expired": {
288
288
  "type": "array",
289
289
  "items": {
290
- "$ref": "urn:guard-spec:v1:common#/$defs/ignoreReliance"
290
+ "$ref": "urn:claimspec:v1:common#/$defs/ignoreReliance"
291
291
  }
292
292
  }
293
293
  },
@@ -323,11 +323,11 @@
323
323
  },
324
324
  "evidenceHead": {
325
325
  "description": "The commit the evidence describes (its snapshot when the working tree was probed). Present whenever evidence was read.",
326
- "$ref": "urn:guard-spec:v1:common#/$defs/gitSha"
326
+ "$ref": "urn:claimspec:v1:common#/$defs/gitSha"
327
327
  },
328
328
  "head": {
329
329
  "description": "HEAD of the working tree the status was computed in, when resolvable. Differs from evidenceHead when the evidence came from elsewhere.",
330
- "$ref": "urn:guard-spec:v1:common#/$defs/gitSha"
330
+ "$ref": "urn:claimspec:v1:common#/$defs/gitSha"
331
331
  }
332
332
  },
333
333
  "additionalProperties": false
package/src/cli.mjs CHANGED
@@ -4,6 +4,7 @@ import { join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { ClaimsError } from './claims/load.mjs';
6
6
  import { PreconditionError } from './probe/worktree.mjs';
7
+ import { RUNNER_NAMES } from './probe/runners/index.mjs';
7
8
  import { GitError } from './git.mjs';
8
9
  import { SpecDocError } from './evidence/writer.mjs';
9
10
  import { probeCommand } from './commands/probe.mjs';
@@ -48,12 +49,22 @@ probe
48
49
  --include-dirty probe the working tree (a snapshot commit) instead of HEAD; uncommitted tests count
49
50
  (without it, a dirty defender/target with the implicit HEAD is refused: the silent-mismatch trap)
50
51
  --verbose also print each killed fault (default: only unproven ones, plus a count)
52
+ --cost (probe, claims) report what the defenders cost, per claim and per defender file
53
+ --progress <mode> auto|tty|plain|ndjson|none. auto rewrites one line at a terminal and prints
54
+ append-only lines everywhere else, so a redirected log and CI see progress
55
+ instead of twenty silent minutes. Always on stderr; ndjson also streams verdicts
51
56
  --confirm below 3 is PROVISIONAL: verdicts print with "?", evidence goes to .testguard/evidence-provisional.json
52
- --runner <name> vitest | jest | playwright | auto (default: auto — first of vitest, jest that resolves; a defender under
53
- playwright's testDir always runs under playwright, whatever the project runner)
57
+ --runner <name> vitest | jest | playwright | python | pytest | unittest | auto
58
+ (default: auto — first of vitest, jest, python that resolves. A defender under playwright's testDir
59
+ always runs under playwright, and a .py defender always under python, whatever the project runner.
60
+ python picks pytest when the interpreter can import it and stdlib unittest otherwise; pytest and
61
+ unittest pin that choice and fail rather than fall back. The engine that ran is what the evidence records.)
54
62
  --runner-cmd "<cmd>" custom runner; must contain {files} and {out}, e.g. "pnpm vitest run {files} --reporter=json --outputFile={out}"
55
63
  --node-modules <dir> node_modules to link into the scratch worktree (or TESTGUARD_NODE_MODULES)
56
- --serial run one test file at a time (vitest --no-file-parallelism, jest --runInBand, playwright --workers=1);
64
+ --python <path> Python interpreter for .py defenders (or TESTGUARD_PYTHON; default: $VIRTUAL_ENV, then the project's
65
+ .venv/venv, then python3 on PATH). Resolved against your working tree, never the scratch worktree.
66
+ --serial run one test file at a time (vitest --no-file-parallelism, jest --runInBand, playwright --workers=1,
67
+ pytest -p no:xdist);
57
68
  use it when another test runner is already running — probe warns and records the contention either way
58
69
  --in-place mutate the working tree instead of a scratch worktree
59
70
  --no-escalate do not re-run survivors against the whole suite
@@ -93,6 +104,7 @@ init --force (replace an existing skill file) --here (keep the agent laye
93
104
  --mcp print the MCP server config for Claude Code, Cursor and Codex (printed, never written: a harness config is yours)
94
105
  every command accepts --json; probe/baseline emit the status document plus their own result
95
106
  claims --json
107
+ --cost what the last probe spent, per claim and per defender file, read back from the recorded run durations. Names the files more than one claim pays for, which is where a slow gate comes from
96
108
  --since <ref> report every claim and fault that existed at <ref> and does not now; a claim entry in testguard.ignore.json (with a reason) excuses one. exit 1 on any unexcused removal
97
109
  baseline --evidence <path> --out <path> --allow-provisional (freeze unconfirmed evidence; normally refused)
98
110
  --restamp move head to the commit of a later CLEAN probe that reproduced the same fingerprints
@@ -139,6 +151,7 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
139
151
  here: { type: 'boolean', default: false },
140
152
  verbose: { type: 'boolean', default: false },
141
153
  'runner-cmd': { type: 'string' },
154
+ python: { type: 'string' },
142
155
  runner: { type: 'string', default: 'auto' },
143
156
  'node-modules': { type: 'string' },
144
157
  'in-place': { type: 'boolean', default: false },
@@ -148,6 +161,8 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
148
161
  json: { type: 'boolean', default: false },
149
162
  text: { type: 'boolean', default: false },
150
163
  markdown: { type: 'boolean', default: false },
164
+ cost: { type: 'boolean', default: false },
165
+ progress: { type: 'string' },
151
166
  help: { type: 'boolean', short: 'h', default: false },
152
167
  version: { type: 'boolean', short: 'v', default: false },
153
168
  },
@@ -173,8 +188,8 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
173
188
  io.err(USAGE);
174
189
  return 3;
175
190
  }
176
- if (!['vitest', 'jest', 'playwright', 'auto'].includes(values.runner)) {
177
- io.err('--runner must be vitest, jest, playwright or auto');
191
+ if (![...RUNNER_NAMES, 'auto'].includes(values.runner)) {
192
+ io.err(`--runner must be one of ${RUNNER_NAMES.join(', ')} or auto`);
178
193
  return 3;
179
194
  }
180
195
  if (!['critical', 'high', 'medium', 'low'].includes(values.severity)) {
@@ -6,6 +6,9 @@ import { discoverDefendersDetailed } from '../probe/discover.mjs';
6
6
  import { classifyDefenders } from '../probe/mocks.mjs';
7
7
  import { computeRemovedClaims, renderRemoved } from '../claims/removed.mjs';
8
8
  import { evidencePath } from './probe.mjs';
9
+ import { costReport, renderCost } from '../probe/cost.mjs';
10
+ import { readSpecDoc } from '../evidence/writer.mjs';
11
+ import { existsSync } from 'node:fs';
9
12
 
10
13
  export async function claimsCommand({ projectDir, values, version }, io) {
11
14
  const path = values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir);
@@ -19,8 +22,16 @@ export async function claimsCommand({ projectDir, values, version }, io) {
19
22
  ? computeRemovedClaims({ projectDir, ref: values.since, claimsPath: path, current: claims, ignorePath: values.ignore ? resolve(values.ignore) : undefined, evidencePath: evidencePath(projectDir), toolVersion: version })
20
23
  : undefined;
21
24
 
25
+ // Cost is read back out of evidence the probe already wrote; it never runs a test.
26
+ let cost;
27
+ if (values.cost) {
28
+ const ev = values.evidence ? resolve(values.evidence) : evidencePath(projectDir);
29
+ cost = existsSync(ev) ? costReport(readSpecDoc('evidence', ev).records) : undefined;
30
+ if (!cost && !values.json) io.err(`no evidence at ${ev} — run \`testguard probe\` first; cost is derived from the run durations it records`);
31
+ }
32
+
22
33
  if (values.json) {
23
- io.out(JSON.stringify({ path, claims, annotations, drift, ...(removed ? { removed } : {}) }, null, 2));
34
+ io.out(JSON.stringify({ path, claims, annotations, drift, ...(removed ? { removed } : {}), ...(cost ? { cost } : {}) }, null, 2));
24
35
  } else {
25
36
  const annotated = new Set(drift.annotated);
26
37
  io.out(`${claims.claims.length} claims in ${path} — ${annotated.size} carry a @claim annotation in source (test files are not scanned)`);
@@ -44,7 +55,12 @@ export async function claimsCommand({ projectDir, values, version }, io) {
44
55
  }
45
56
  if (signalLines.length) io.out('');
46
57
  for (const s of signalLines) {
47
- if (s.signal === 'mocked-never-asserted') io.out(`MOCKED-NEVER-ASSERTED ${s.file} mocks ${s.target} and never asserts on it (${s.claim}) — assert on the mocked call, drive the real module, or annotate the mock \`// unasserted: <why>\``);
58
+ // The annotation's comment marker is the probed file's, not JavaScript's.
59
+ const marker = s.file.endsWith('.py') ? '#' : '//';
60
+ if (s.signal === 'mocked-never-asserted') io.out(`MOCKED-NEVER-ASSERTED ${s.file} mocks ${s.target} and never asserts on it (${s.claim}) — assert on the mocked call, drive the real module, or annotate the mock \`${marker} unasserted: <why>\``);
61
+ // An attribute patch does not remove a defender; saying it "mocks" the
62
+ // module would tell the author the opposite of what happened.
63
+ else if (s.signal === 'target-attribute-patched') io.out(`attribute-patched ${s.file} defends ${s.target} but replaces ${s.reason} (${s.claim}) — a fault in those attributes is where it is least likely to notice`);
48
64
  else io.out(`unasserted (annotated) ${s.file} mocks ${s.target}: ${s.reason} (${s.claim})`);
49
65
  }
50
66
  if (drift.undeclared.length || drift.stale.length) io.out('');
@@ -54,6 +70,10 @@ export async function claimsCommand({ projectDir, values, version }, io) {
54
70
  io.out('');
55
71
  io.out(renderRemoved(removed));
56
72
  }
73
+ if (cost) {
74
+ io.out('');
75
+ io.out(renderCost(cost));
76
+ }
57
77
  }
58
78
  return drift.undeclared.length || drift.stale.length || (removed?.removed.length ?? 0) > 0 ? 1 : 0;
59
79
  }
@@ -7,6 +7,8 @@ import { gate } from '../baseline/baseline.mjs';
7
7
  import { renderRecord, renderSummary, sortForReport, PROVISIONAL_WARNING } from '../render.mjs';
8
8
  import { computeStatus } from '../status/status.mjs';
9
9
  import { resolveChangedRef, withChangedRef } from '../gate/changed.mjs';
10
+ import { costReport, renderCost } from '../probe/cost.mjs';
11
+ import { progressMode, stageReporter, clearStageLine, recordEvent, isProgressMode, progressStream } from '../probe/progress.mjs';
10
12
  export const provisionalEvidencePath = (projectDir) => join(projectDir, '.testguard', 'evidence-provisional.json');
11
13
 
12
14
  export const evidencePath = (projectDir) => join(projectDir, '.testguard', 'evidence.json');
@@ -19,6 +21,14 @@ export async function probeCommand({ projectDir, values, version }, io) {
19
21
  io.err('--confirm must be a positive integer and --budget at least 1000');
20
22
  return 3;
21
23
  }
24
+ if (values.progress && !isProgressMode(values.progress)) {
25
+ io.err(`--progress must be one of auto, tty, plain, ndjson, none (got ${values.progress})`);
26
+ return 3;
27
+ }
28
+ // Progress always goes to stderr, so --json keeps stdout parseable.
29
+ const progress = progressMode({ explicit: values.progress, isTTY: process.stderr.isTTY, quiet: values.quiet, json: values.json });
30
+ const progressTo = progressStream(process);
31
+ const stageOut = stageReporter(progress, (s) => progressTo.write(s));
22
32
  const claims = loadClaims(values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir));
23
33
  if (claims.claims.length === 0) {
24
34
  io.err('claims file declares no claims; nothing to verify');
@@ -50,13 +60,18 @@ export async function probeCommand({ projectDir, values, version }, io) {
50
60
  runnerCommand: values['runner-cmd'],
51
61
  runnerName: values.runner,
52
62
  nodeModules: values['node-modules'] ? resolve(values['node-modules']) : process.env.TESTGUARD_NODE_MODULES,
63
+ python: values.python ? resolve(values.python) : undefined,
53
64
  only,
54
65
  escalate: !values['no-escalate'],
55
66
  toolVersion: version,
56
67
  includeDirty: values['include-dirty'],
57
- onStage: !values.quiet && !values.json && process.stderr.isTTY ? ({ claimId, faultId, stage, i, n }) => process.stderr.write(`\r\x1b[K … ${claimId}/${faultId} ${stage} ${i}/${n}`) : undefined,
58
- onProgress: values.quiet || values.json ? undefined : (r) => {
59
- if (process.stderr.isTTY) process.stderr.write('\r\x1b[K');
68
+ onStage: stageOut,
69
+ onProgress: (r) => {
70
+ // ndjson streams verdicts as they land; a watcher sees them without
71
+ // waiting for the document at the end.
72
+ if (progress === 'ndjson') progressTo.write(recordEvent(r));
73
+ if (values.quiet || values.json) return;
74
+ progressTo.write(clearStageLine(progress));
60
75
  // The discovery note is a property of the claim, not of each fault: say it once.
61
76
  const firstOfClaim = !discoveryNoted.has(r.claim.id);
62
77
  if (r.defenders.discovered) discoveryNoted.add(r.claim.id);
@@ -68,7 +83,7 @@ export async function probeCommand({ projectDir, values, version }, io) {
68
83
  const g = gate(evidence.records, baseline, { severityFloor: values.severity });
69
84
  if (values.json) {
70
85
  const status = withChangedRef(resolveChangedRef({ explicit: values.changed }), (changedRef) => computeStatus({ projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'], evidence: outPath }), io.err);
71
- io.out(JSON.stringify({ ...status, run: { id: evidence.run.id, evidence: outPath, provisional, records: evidence.records.length, newSinceBaseline: g.new.length, exitCode: g.new.length > 0 ? 1 : 0 } }, null, 2));
86
+ io.out(JSON.stringify({ ...status, run: { id: evidence.run.id, evidence: outPath, provisional, records: evidence.records.length, newSinceBaseline: g.new.length, exitCode: g.new.length > 0 ? 1 : 0 }, ...(values.cost ? { cost: costReport(evidence.records) } : {}) }, null, 2));
72
87
  return g.new.length > 0 ? 1 : 0;
73
88
  }
74
89
  if (!values.quiet && baseline) {
@@ -82,6 +97,11 @@ export async function probeCommand({ projectDir, values, version }, io) {
82
97
  if (killed) io.out(` ${killed} killed (not listed; --verbose to see them)`);
83
98
  }
84
99
  io.out(renderSummary(evidence.records, evidence.run) + (baseline ? ` ${g.new.length} new since baseline, ${g.baselined.length} baselined.` : ' No baseline.'));
100
+ if (values.cost) {
101
+ io.out('');
102
+ io.out(renderCost(costReport(evidence.records)));
103
+ io.out('');
104
+ }
85
105
  io.out(`evidence: ${outPath}${only ? ` (partial: --claim ${only.join(',')}; not the canonical evidence file)` : provisional ? ' (provisional; not the canonical evidence file)' : ''}`);
86
106
  if (provisional && !values.quiet) io.err(PROVISIONAL_WARNING(confirmRuns));
87
107
  return g.new.length > 0 ? 1 : 0;
@@ -50,7 +50,7 @@ export async function replayCommand({ projectDir, values, version }, io) {
50
50
  io.out(renderReplay(doc));
51
51
  io.out('');
52
52
  for (const [cls, b] of Object.entries(calibration.buckets).sort((a, b2) => b2[1].n - a[1].n)) {
53
- io.out(` ${cls.padEnd(22)} blind ${String(b.positives).padStart(3)}/${String(b.n).padEnd(3)} p=${b.p.toFixed(2)} ci [${b.ci[0].toFixed(2)}, ${b.ci[1].toFixed(2)}]`);
53
+ io.out(` ${cls.padEnd(22)} missed ${String(b.positives).padStart(3)}/${String(b.n).padEnd(3)} p=${b.p === null ? ' n/a' : b.p.toFixed(2)} ci [${b.ci[0].toFixed(2)}, ${b.ci[1].toFixed(2)}]`);
54
54
  }
55
55
  if (Object.keys(calibration.buckets).length === 0) io.out(' no measurable bug in this range: nothing to calibrate from yet.');
56
56
  io.out('');
@@ -5,6 +5,7 @@ import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
5
5
  import { readSpecDoc } from '../evidence/writer.mjs';
6
6
  import { resolveDefenders } from '../probe/runners/shared.mjs';
7
7
  import { discoverDefenders } from '../probe/discover.mjs';
8
+ import { IS_PY_TEST } from '../probe/pyimports.mjs';
8
9
  import { globToRegExp } from '../util/glob.mjs';
9
10
 
10
11
  /**
@@ -20,8 +21,14 @@ import { globToRegExp } from '../util/glob.mjs';
20
21
  * the smallest unit the rest of the contract already understands.
21
22
  */
22
23
 
23
- export const SOURCE_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx']);
24
+ // `.py` belongs here for the same reason every other extension does: a file
25
+ // the gate does not recognise as source is excluded as `non-source`, which
26
+ // means a changed Python file could never be reported as uncovered and the
27
+ // gate would pass while saying nothing.
28
+ export const SOURCE_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx', '.py']);
24
29
  const TEST_RE = /\.(test|spec)\.[cm]?[jt]sx?$/;
30
+ // Python names tests by file, not by a dotted suffix: `test_foo.py`, `foo_test.py`.
31
+ const isTestFile = (f) => TEST_RE.test(f) || IS_PY_TEST.test(f);
25
32
 
26
33
  /** Files that are source by extension but never carry claims. Printed by --explain. */
27
34
  export const DEFAULT_EXCLUDES = Object.freeze([
@@ -163,7 +170,7 @@ export function computeChangedGate({ projectDir, ref, includeDirty = false, excl
163
170
  const dx = defaultExcludes.find((x) => x.re.test(file));
164
171
  if (dx) { excluded.push({ file, by: `default:${dx.glob}` }); continue; }
165
172
 
166
- const isTest = TEST_RE.test(file);
173
+ const isTest = isTestFile(file);
167
174
  if (faultFiles.has(file)) { covered.push({ file, by: 'fault', claimIds: faultFiles.get(file) }); continue; }
168
175
  if (isTest && defenderFiles.has(file)) { covered.push({ file, by: 'defender', claimIds: defenderFiles.get(file) }); continue; }
169
176
  const active = pathEntries.find((p) => !p.expired && p.re.test(file));
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The decisions escalation and flake measurement make, as pure functions.
3
+ *
4
+ * These used to live inline in `probeOne`, which meant the only thing that
5
+ * could falsify them was a full fixture probe — a 50-second acceptance test
6
+ * standing in for a rule you can state in three lines. `classify()` has always
7
+ * been pure for this reason: the order of its checks *is* the spec, and every
8
+ * branch is unit-testable without a runner. Same principle here.
9
+ *
10
+ * Nothing in this file runs a test, touches the filesystem, or knows what a
11
+ * worktree is. Everything it needs is passed in.
12
+ */
13
+
14
+ const isKill = (r) => r.outcome === 'fail' && (r.assertionFailures ?? 0) > 0;
15
+
16
+ /**
17
+ * Fold one escalation run into the running attribution state.
18
+ *
19
+ * A test is an undeclared killer only if it fails in **every** run: a single
20
+ * run cannot say, because a test that is flaky elsewhere in the suite would
21
+ * take the credit. So the killer set is an intersection, seeded by the first
22
+ * run rather than by the empty set.
23
+ *
24
+ * Once the intersection is empty no further run can name a killer, so the
25
+ * caller may stop early. Stopping can only fail to *name* a killer, never
26
+ * upgrade a verdict, which is the pessimistic side and therefore the safe one.
27
+ */
28
+ export function foldEscalationRun(state, { run, failedTests }) {
29
+ const runs = [...state.runs, run];
30
+ const killers = state.killers === null ? new Set(failedTests) : new Set([...state.killers].filter((t) => failedTests.includes(t)));
31
+ return { runs, killers, stoppedEarly: killers.size === 0 ? 'no-common-failure' : undefined };
32
+ }
33
+
34
+ /** The initial attribution state, before any escalation run. */
35
+ export const escalationStart = () => ({ runs: [], killers: null, stoppedEarly: undefined });
36
+
37
+ /**
38
+ * What escalation concluded. `undeclaredKillers` is named only when a test
39
+ * failed in all N runs AND every run was a genuine kill (an assertion
40
+ * failure). A suite that errored or timed out proves nothing about the fault,
41
+ * so it never earns an attribution.
42
+ */
43
+ export function escalationResult(state, confirmRuns) {
44
+ const complete = state.runs.length === confirmRuns;
45
+ const attributable = state.killers !== null && state.killers.size > 0 && complete && state.runs.every(isKill);
46
+ return {
47
+ escalated: true,
48
+ escalationRuns: state.runs,
49
+ ...(state.stoppedEarly ? { escalationStoppedEarly: state.stoppedEarly } : {}),
50
+ ...(attributable ? { reason: 'killed-by-undeclared-tests', undeclaredKillers: [...state.killers].sort() } : {}),
51
+ };
52
+ }
53
+
54
+ /**
55
+ * The defenders' flake rate on unmodified source.
56
+ *
57
+ * Only runs that **actually ran** carry information. A load error or a timeout
58
+ * measures the environment, not the suite's stability, and counting either as
59
+ * a "failure" would invent flakiness that was never observed. Returns
60
+ * `undefined` when nothing was measurable, so the caller omits the field
61
+ * rather than recording a rate of zero it cannot support.
62
+ */
63
+ export function flakeRate(runs) {
64
+ const measured = runs.filter((r) => r.outcome === 'pass' || r.outcome === 'fail');
65
+ if (!measured.length) return undefined;
66
+ return { runs: measured.length, failures: measured.filter((r) => r.outcome === 'fail').length };
67
+ }
68
+
69
+ /**
70
+ * The tests that actually failed on the fault, by file. Only these are its
71
+ * killers; everything else in a run is noise for attribution purposes.
72
+ */
73
+ export function killersFromRuns(probeRuns) {
74
+ return [...new Set(probeRuns.flatMap((r) => r.failedTests ?? []).map((t) => t.split('::')[0]).filter(Boolean))];
75
+ }
76
+
77
+ /**
78
+ * The `subject` of an evidence record. `contentHash` is what makes a fault edit
79
+ * visible: weakening a fault after it survived changes this hash, and `status`
80
+ * reports the change with the verdict the fault used to have. It is deliberately
81
+ * NOT part of the fingerprint — repairing a rotted anchor must not churn the
82
+ * baseline — which is exactly why it has to be recorded separately.
83
+ */
84
+ export function subjectOf(fault, sha256) {
85
+ return {
86
+ kind: 'fault',
87
+ id: fault.id,
88
+ description: fault.description,
89
+ file: fault.file,
90
+ faultClass: fault.faultClass,
91
+ producedBy: fault.producedBy,
92
+ contentHash: sha256(`${fault.find}\n${fault.replace}`),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * May a prior record stand in for this one, or must the fault be probed again?
98
+ *
99
+ * Same source, same defenders, same N — and the SAME FAULT. The last is not a
100
+ * refinement: a verdict is an answer about one specific `find`/`replace`, and
101
+ * once those change the old answer is not an answer to the new question.
102
+ * Without it, weakening a fault that survived keeps its old verdict, and
103
+ * repairing a rotted anchor keeps `unverifiable` — the evidence reporting a
104
+ * result that was never measured against the fault the file now contains.
105
+ * `status` flags the changed hash afterwards, but by then the evidence has
106
+ * already said something untrue.
107
+ *
108
+ * A prior that predates `contentHash` cannot be compared, so it is re-probed.
109
+ * The cost of re-measuring is a run; the cost of the other direction is a
110
+ * verdict nobody measured.
111
+ */
112
+ export function isReusable(prior, current) {
113
+ if (!prior || !prior.subject?.contentHash) return false;
114
+ // A record that exists because the probe threw is a statement about the run,
115
+ // not about the code. Reusing it would make one disturbed run permanent.
116
+ if (prior.detail?.reason === 'probe-error') return false;
117
+ const same = (x, y) => JSON.stringify(x) === JSON.stringify(y);
118
+ return prior.inputs.targetHash === current.inputs.targetHash
119
+ && same(prior.inputs.defenderHashes, current.inputs.defenderHashes)
120
+ && same(prior.defenders.requested, current.requested)
121
+ && same(prior.defenders.resolved, current.resolved)
122
+ && prior.subject.contentHash === current.contentHash;
123
+ }
@@ -6,8 +6,13 @@
6
6
  * encodes GATE-SEMANTICS.md: nothing to run → nocover; nothing to apply →
7
7
  * unverifiable; defenders not green → flaky-defender; then, and only then,
8
8
  * does the fault's own result count.
9
+ *
10
+ * `subjectReached` is the negative control's answer and is consulted at one
11
+ * point only: a would-be `survived`. It is never asked about a kill — a kill
12
+ * already proves the defenders reached the code — and `undefined` (the control
13
+ * was not run) never changes a verdict.
9
14
  */
10
- export function classify({ defenders, anchor, baselineRuns, probeRuns, confirmRuns }) {
15
+ export function classify({ defenders, anchor, baselineRuns, probeRuns, confirmRuns, subjectReached }) {
11
16
  if (defenders.length === 0) return { verdict: 'nocover' };
12
17
  if (anchor && anchor.status !== 'ok') return { verdict: 'unverifiable', reason: anchor.status };
13
18
  if (baselineRuns.length === 0 || baselineRuns.some((r) => r.outcome !== 'pass')) {
@@ -24,7 +29,18 @@ export function classify({ defenders, anchor, baselineRuns, probeRuns, confirmRu
24
29
  const killedRuns = probeRuns.filter((r) => r.outcome === 'fail' && r.assertionFailures > 0);
25
30
  const passedRuns = probeRuns.filter((r) => r.outcome === 'pass');
26
31
  if (probeRuns.length === confirmRuns && killedRuns.length === confirmRuns) return { verdict: 'killed' };
27
- if (probeRuns.length === confirmRuns && passedRuns.length === confirmRuns) return { verdict: 'survived' };
32
+ if (probeRuns.length === confirmRuns && passedRuns.length === confirmRuns) {
33
+ // The negative control. A green baseline proves the defenders can PASS; it
34
+ // says nothing about whether they can fail because of THIS file. If the
35
+ // subject was replaced with something that cannot compile and the
36
+ // defenders still went green, they never execute it — and then `survived`
37
+ // is a statement about their reach, not their assertions, and reads as a
38
+ // devastating audit finding while being entirely false.
39
+ // `undefined` means the control was not run, which is not evidence either
40
+ // way and leaves the verdict alone.
41
+ if (subjectReached === false) return { verdict: 'unverifiable', reason: 'subject-not-executed' };
42
+ return { verdict: 'survived' };
43
+ }
28
44
  // Some runs killed, some passed: the defenders' response to this fault is
29
45
  // nondeterministic. Reporting it as killed would be the optimistic bias
30
46
  // the research warned about; reporting survived would be a lie the other way.