canary-test-cli 7.0.0 → 7.2.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 (122) hide show
  1. package/agents/skills/README.md +308 -0
  2. package/agents/skills/canary:generate.md +49 -0
  3. package/agents/skills/canary:init.md +37 -0
  4. package/agents/skills/canary:migrate.md +66 -0
  5. package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
  6. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  7. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  11. package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
  12. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  14. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
  15. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  16. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  17. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  18. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  19. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  21. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  22. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  27. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  28. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  29. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
  30. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  31. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  32. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  37. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  38. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  42. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  44. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  45. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  46. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  52. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  53. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  54. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  55. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  56. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  57. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  58. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  59. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  60. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  61. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  62. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  63. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  64. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  65. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  66. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  67. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  68. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  69. package/agents/skills/lib/parse-args.mjs +214 -0
  70. package/dist/engine/analysis/cli.js +116 -54
  71. package/dist/engine/analysis/engine.js +34 -16
  72. package/dist/engine/analysis/reports.js +5 -4
  73. package/dist/engine/cli-commands.js +252 -106
  74. package/dist/engine/cli-common.js +15 -24
  75. package/dist/engine/cli.core.js +37 -11
  76. package/dist/engine/cli.js +2 -2
  77. package/dist/engine/company-knowledge-cli.js +2 -2
  78. package/dist/engine/core/adoption.js +408 -0
  79. package/dist/engine/core/framework-probes.js +7 -7
  80. package/dist/engine/core/fs-glob.js +2 -2
  81. package/dist/engine/core/gate-result.js +17 -0
  82. package/dist/engine/core/migrator.js +9 -17
  83. package/dist/engine/core/pattern-matcher.js +23 -5
  84. package/dist/engine/core/persona.js +421 -0
  85. package/dist/engine/core/promotion-verdict.js +261 -0
  86. package/dist/engine/core/quality-scorer.js +15 -2
  87. package/dist/engine/core/reporter.js +1 -9
  88. package/dist/engine/core/skill-dispatch.js +115 -0
  89. package/dist/engine/core/skill-examples.js +392 -0
  90. package/dist/engine/core/skill-registry.js +59 -4
  91. package/dist/engine/core/skill-surfaces.js +307 -0
  92. package/dist/engine/core/static-linter.js +310 -38
  93. package/dist/engine/core/test-files.js +77 -0
  94. package/dist/engine/core/ticket-updater.js +1 -7
  95. package/dist/engine/core/vacuity-scanner.js +726 -0
  96. package/dist/engine/core/workflow-discovery.js +2 -8
  97. package/dist/engine/core/workspace-detect.js +7 -6
  98. package/dist/engine/data/personas/registry.json +36 -0
  99. package/dist/engine/guardian/adjudication.js +5 -5
  100. package/dist/engine/guardian/analysis-emit.js +19 -28
  101. package/dist/engine/guardian/cli.js +150 -51
  102. package/dist/engine/guardian/coverage.js +1 -1
  103. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
  104. package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
  105. package/dist/engine/guardian/pr-check.js +322 -33
  106. package/dist/engine/guardian/pr-comment.js +4 -3
  107. package/dist/engine/history/cli.js +210 -6
  108. package/dist/engine/history/ndjson-store.js +9 -5
  109. package/dist/engine/history/record.js +34 -5
  110. package/dist/engine/history/run-recorder.js +165 -0
  111. package/dist/engine/history/schema.js +25 -7
  112. package/dist/engine/history/store.js +9 -0
  113. package/dist/engine/mcp-server.js +35 -13
  114. package/dist/engine/skills-cli.js +249 -23
  115. package/dist/engine/util/ensure-ascii.js +37 -0
  116. package/dist/engine/workflow-cli.js +6 -6
  117. package/dist/gate-result.d.ts +11 -0
  118. package/dist/gate-result.js +18 -0
  119. package/dist/reporters/testtracker.d.ts +1 -1
  120. package/dist/reporters/testtracker.js +1 -1
  121. package/dist/uninstall.js +12 -5
  122. package/package.json +3 -2
@@ -0,0 +1,572 @@
1
+ // Tier-2 dynamic confirmer for canary-savant (Phase 2): baseline -> shuffle ->
2
+ // classify, pytest-first, with honest degradation when a shuffle plugin is
3
+ // absent.
4
+ //
5
+ // Determinism here means the *tool* is reproducible per seed, not that outcomes
6
+ // are (finding non-determinism is the point). Every run prints the seed and an
7
+ // exact reproduce command. Tier-0 in the real sense: no LLM, no network, no
8
+ // secrets -- savant only ever shells out to the project's own pytest.
9
+ //
10
+ // Isolation + polluter bisect (naming the culprit, not just the victim) are
11
+ // Phase 3 and are deliberately absent here.
12
+
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { spawnSync } from 'node:child_process';
17
+
18
+ // JUnit artifacts go to the OS temp dir, never the user's repo root.
19
+ const TMP_JUNIT = path.join(os.tmpdir(), 'savant-junit.xml');
20
+ const TMP_POLLUTER = path.join(os.tmpdir(), 'savant-polluter.xml');
21
+
22
+ const FAILED = new Set(['failed', 'error']);
23
+
24
+ /**
25
+ * Parse a pytest JUnit XML string into { "classname::name": outcome }.
26
+ * Outcome is one of passed | failed | error | skipped. JUnit is machine-
27
+ * generated and shallow, so a scan over <testcase> elements is sufficient and
28
+ * keeps the skill dependency-free.
29
+ * @param {string} xml
30
+ * @returns {Record<string,string>}
31
+ */
32
+ export function parseJunitXml(xml) {
33
+ const outcomes = {};
34
+ const caseRe = /<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase>)/g;
35
+ let m;
36
+ while ((m = caseRe.exec(xml)) !== null) {
37
+ const attrs = m[1];
38
+ const inner = m[3] || '';
39
+ const cls = /\bclassname="([^"]*)"/.exec(attrs)?.[1] ?? '';
40
+ const name = /\bname="([^"]*)"/.exec(attrs)?.[1] ?? '';
41
+ let outcome = 'passed';
42
+ if (inner.includes('<failure')) outcome = 'failed';
43
+ else if (inner.includes('<error')) outcome = 'error';
44
+ else if (inner.includes('<skipped')) outcome = 'skipped';
45
+ outcomes[`${cls}::${name}`] = outcome;
46
+ }
47
+ return outcomes;
48
+ }
49
+
50
+ /** Default probe: can the project's python import the given plugin module? */
51
+ function defaultProbe(moduleName) {
52
+ const res = spawnSync(
53
+ 'python3',
54
+ [
55
+ '-c',
56
+ `import importlib.util,sys; sys.exit(0 if importlib.util.find_spec(${JSON.stringify(moduleName)}) else 1)`,
57
+ ],
58
+ { stdio: 'ignore' },
59
+ );
60
+ return res.status === 0;
61
+ }
62
+
63
+ /**
64
+ * Return the shuffle plugin to drive, preferring pytest-randomly, or null.
65
+ * @param {(name: string) => boolean} [probe]
66
+ * @returns {string|null}
67
+ */
68
+ export function detectShufflePlugin(probe = defaultProbe) {
69
+ if (probe('randomly')) return 'pytest-randomly';
70
+ if (probe('random_order')) return 'pytest-random-order';
71
+ return null;
72
+ }
73
+
74
+ const pytestBase = (paths, junitPath) => [
75
+ 'python3',
76
+ '-m',
77
+ 'pytest',
78
+ ...paths,
79
+ '--tb=no',
80
+ '-q',
81
+ `--junitxml=${junitPath}`,
82
+ ];
83
+
84
+ /** In-order baseline command; forces randomly off when that plugin is active. */
85
+ export function buildBaselineCmd(paths, junitPath, plugin) {
86
+ const cmd = pytestBase(paths, junitPath);
87
+ if (plugin === 'pytest-randomly') cmd.push('-p', 'no:randomly');
88
+ return cmd;
89
+ }
90
+
91
+ /** Shuffle command with a pinned seed, using the framework's own plugin. */
92
+ export function buildShuffleCmd(paths, junitPath, seed, plugin) {
93
+ const cmd = pytestBase(paths, junitPath);
94
+ if (plugin === 'pytest-random-order') {
95
+ cmd.push('--random-order', `--random-order-seed=${seed}`);
96
+ } else {
97
+ cmd.push('-p', 'randomly', `--randomly-seed=${seed}`);
98
+ }
99
+ return cmd;
100
+ }
101
+
102
+ /**
103
+ * Detect the test framework for a target: pytest or vitest. File extensions win;
104
+ * a bare directory falls back to config-file markers (injectable `exists`).
105
+ * Both fs probes are injectable so tests stay hermetic: `readdir` guards the
106
+ * directory scan (step 2) and `exists` the config-marker probe (step 3).
107
+ * @param {string[]} paths
108
+ * @param {{exists?: (f: string) => boolean,
109
+ * readdir?: (p: string) => string[]}} [options]
110
+ * @returns {'pytest'|'vitest'|null}
111
+ */
112
+ export function detectFramework(paths, options = {}) {
113
+ const isPy = (p) => p.endsWith('.py');
114
+ const isJsTest = (p) => /\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs)$/.test(p);
115
+ const isJs = (p) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(p);
116
+
117
+ // 1) An explicitly named file's extension wins.
118
+ if (paths.some(isPy)) return 'pytest';
119
+ if (paths.some(isJs)) return 'vitest';
120
+
121
+ // 2) Otherwise scan the target's own contents (NOT cwd, which may hold an
122
+ // unrelated config in a monorepo/this test harness).
123
+ const readdir = options.readdir || defaultReaddir;
124
+ for (const p of paths) {
125
+ const files = readdir(p);
126
+ if (files.some(isPy)) return 'pytest';
127
+ if (files.some(isJsTest)) return 'vitest';
128
+ }
129
+
130
+ // 3) Last resort: config-file markers.
131
+ const exists = options.exists || ((f) => fs.existsSync(f));
132
+ if (
133
+ ['vitest.config.ts', 'vitest.config.js', 'vitest.config.mjs'].some(exists)
134
+ ) {
135
+ return 'vitest';
136
+ }
137
+ if (
138
+ ['pytest.ini', 'pyproject.toml', 'conftest.py', 'setup.cfg'].some(exists)
139
+ ) {
140
+ return 'pytest';
141
+ }
142
+ return null;
143
+ }
144
+
145
+ // Shallow-recursive file listing of a directory (bounded; skips vendor dirs).
146
+ // Returns [] for a non-directory or on error.
147
+ function defaultReaddir(root, depth = 3) {
148
+ let entries;
149
+ try {
150
+ entries = fs.readdirSync(root, { withFileTypes: true });
151
+ } catch {
152
+ return [];
153
+ }
154
+ const out = [];
155
+ for (const e of entries) {
156
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
157
+ const full = `${root}/${e.name}`;
158
+ if (e.isFile()) out.push(full);
159
+ else if (e.isDirectory() && depth > 0)
160
+ out.push(...defaultReaddir(full, depth - 1));
161
+ }
162
+ return out;
163
+ }
164
+
165
+ const vitestBase = (paths, junitPath) => [
166
+ 'npx',
167
+ 'vitest',
168
+ 'run',
169
+ ...paths,
170
+ '--reporter=junit',
171
+ `--outputFile=${junitPath}`,
172
+ ];
173
+
174
+ /** In-order vitest baseline (declared order; no shuffle). */
175
+ export function buildVitestBaselineCmd(paths, junitPath) {
176
+ return vitestBase(paths, junitPath);
177
+ }
178
+
179
+ /** Shuffled vitest run with a pinned seed (built-in, no plugin needed). */
180
+ export function buildVitestShuffleCmd(paths, junitPath, seed) {
181
+ return [
182
+ ...vitestBase(paths, junitPath),
183
+ '--sequence.shuffle',
184
+ `--sequence.seed=${seed}`,
185
+ ];
186
+ }
187
+
188
+ /**
189
+ * Classify tests green-in-baseline by their two same-seed shuffled reruns.
190
+ * Fail in both -> order-dependent (reproducible under that order). Disagree
191
+ * -> nondeterministic (flaky, not order). Passed in both -> clean.
192
+ * @returns {{victim: string, classification: string}[]}
193
+ */
194
+ export function classify(baseline, shuffle1, shuffle2, seed) {
195
+ void seed;
196
+ const findings = [];
197
+ for (const [test, outcome] of Object.entries(baseline)) {
198
+ if (outcome !== 'passed') continue; // red-in-baseline is not an order bug
199
+ const f1 = FAILED.has(shuffle1[test]);
200
+ const f2 = FAILED.has(shuffle2[test]);
201
+ if (f1 && f2)
202
+ findings.push({ victim: test, classification: 'order-dependent' });
203
+ else if (f1 !== f2)
204
+ findings.push({ victim: test, classification: 'nondeterministic' });
205
+ }
206
+ return findings;
207
+ }
208
+
209
+ /** Run one pytest invocation and parse its JUnit report into outcomes. */
210
+ export function runPytestSuite(cmd, junitPath) {
211
+ spawnSync(cmd[0], cmd.slice(1), { stdio: 'ignore' });
212
+ let xml;
213
+ try {
214
+ xml = fs.readFileSync(junitPath, 'utf8');
215
+ } catch {
216
+ return {};
217
+ }
218
+ return parseJunitXml(xml);
219
+ }
220
+
221
+ /**
222
+ * @typedef {Object} ConfirmResult
223
+ * @property {string} status 'ok' | 'no_plugin' | 'baseline_red' | 'unknown_framework'
224
+ * @property {number} seed
225
+ * @property {'pytest'|'vitest'|null} [framework]
226
+ * @property {string|null} plugin
227
+ * @property {{victim: string, classification: string}[]} victims
228
+ * @property {{victim: string, classification: string}[]} nondeterministic
229
+ * @property {string[]} baselineFailures
230
+ * @property {string} reproduce
231
+ * @property {string[]} [order]
232
+ * @property {string} [message]
233
+ */
234
+
235
+ /**
236
+ * Orchestrate the dynamic confirmation.
237
+ * @param {string[]} paths
238
+ * @param {{seed: number, plugin?: string|null,
239
+ * framework?: 'pytest'|'vitest',
240
+ * runSuite?: (cmd: string[]) => Record<string,string>,
241
+ * probe?: (name: string) => boolean}} [options]
242
+ * @returns {ConfirmResult}
243
+ */
244
+ export function confirm(paths, options = {}) {
245
+ const { seed, runSuite, probe } = options;
246
+ // Passing an explicit plugin (Phase 2/3 call sites) implies pytest; otherwise
247
+ // detect from the paths. The test cwd owns a vitest.config, so relying on
248
+ // detection alone there would misroute pytest cases.
249
+ const framework =
250
+ options.framework ??
251
+ (options.plugin !== undefined ? 'pytest' : detectFramework(paths));
252
+
253
+ const base = {
254
+ status: 'ok',
255
+ seed,
256
+ framework,
257
+ plugin: null,
258
+ victims: [],
259
+ nondeterministic: [],
260
+ baselineFailures: [],
261
+ reproduce: '',
262
+ };
263
+
264
+ if (framework !== 'pytest' && framework !== 'vitest') {
265
+ return {
266
+ ...base,
267
+ status: 'unknown_framework',
268
+ message:
269
+ 'Could not detect pytest or vitest for the given paths. ' +
270
+ 'Pass the framework explicitly to run Tier 2.',
271
+ };
272
+ }
273
+
274
+ // A temp path is only needed by the real runner; the injected stub ignores it.
275
+ const junit = TMP_JUNIT;
276
+ const run = runSuite || ((cmd) => runPytestSuite(cmd, junit));
277
+
278
+ let baselineCmd;
279
+ let shuffleCmd;
280
+ if (framework === 'vitest') {
281
+ // vitest shuffle is built in - no plugin gate.
282
+ baselineCmd = buildVitestBaselineCmd(paths, junit);
283
+ shuffleCmd = buildVitestShuffleCmd(paths, junit, seed);
284
+ } else {
285
+ const plugin =
286
+ options.plugin !== undefined
287
+ ? options.plugin
288
+ : detectShufflePlugin(probe);
289
+ base.plugin = plugin;
290
+ if (!plugin) {
291
+ return {
292
+ ...base,
293
+ status: 'no_plugin',
294
+ message:
295
+ 'Tier 2 needs a pytest shuffle plugin. Install pytest-randomly (or ' +
296
+ 'pytest-random-order) to enable order-dependence confirmation.',
297
+ };
298
+ }
299
+ baselineCmd = buildBaselineCmd(paths, junit, plugin);
300
+ shuffleCmd = buildShuffleCmd(paths, junit, seed, plugin);
301
+ }
302
+
303
+ const baseline = run(baselineCmd);
304
+ const baselineFailures = Object.entries(baseline)
305
+ .filter(([, o]) => FAILED.has(o))
306
+ .map(([t]) => t);
307
+ if (baselineFailures.length) {
308
+ return {
309
+ ...base,
310
+ status: 'baseline_red',
311
+ baselineFailures,
312
+ message:
313
+ 'The suite is not green in declared order; fix that first. ' +
314
+ 'Order-dependence is undefined over an already-failing suite.',
315
+ };
316
+ }
317
+
318
+ const s1 = run(shuffleCmd);
319
+ const s2 = run(shuffleCmd);
320
+ const findings = classify(baseline, s1, s2, seed);
321
+
322
+ return {
323
+ ...base,
324
+ victims: findings.filter((f) => f.classification === 'order-dependent'),
325
+ nondeterministic: findings.filter(
326
+ (f) => f.classification === 'nondeterministic',
327
+ ),
328
+ reproduce: shuffleCmd.join(' '),
329
+ // Shuffled execution order (JUnit testcase order under the seed). Phase 3
330
+ // uses this to derive each victim's prefix for polluter bisection.
331
+ order: Object.keys(s1),
332
+ };
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Phase 3: isolation re-run + polluter bisect (name the culprit, not just the
337
+ // victim). Algorithms are pure; the subprocess seams live in realPolluterSeams.
338
+ // ---------------------------------------------------------------------------
339
+
340
+ /** True when the victim passed every alone-run - i.e. the leak is external. */
341
+ export function isolationConfirms(outcomes) {
342
+ return outcomes.length > 0 && outcomes.every((o) => o === 'passed');
343
+ }
344
+
345
+ /**
346
+ * Binary-search the ordered prefix for the single test whose presence flips
347
+ * the victim from pass to fail. `reproduces(subset)` returns whether the victim
348
+ * fails when exactly `subset` (in order) runs before it.
349
+ *
350
+ * Assumes near-monotonic reproduction (a single polluter). When that does not
351
+ * hold, the invariant check fails and we report the smallest reproducing prefix
352
+ * with `polluter: null, exhausted: true` rather than blame the wrong test.
353
+ *
354
+ * @param {string[]} prefix ordered test ids that ran before the victim
355
+ * @param {(subset: string[]) => boolean} reproduces
356
+ * @param {{maxSteps?: number}} [options]
357
+ */
358
+ export function bisectPolluter(prefix, reproduces, options = {}) {
359
+ const n = prefix.length;
360
+ const maxSteps = options.maxSteps ?? Math.ceil(Math.log2(Math.max(2, n))) + 2;
361
+ let steps = 0;
362
+ const rep = (len) => {
363
+ steps += 1;
364
+ return reproduces(prefix.slice(0, len));
365
+ };
366
+ // Lower-bound search: smallest L in [1..n] whose prefix reproduces.
367
+ let lo = 1;
368
+ let hi = n;
369
+ while (lo < hi && steps < maxSteps) {
370
+ const mid = (lo + hi) >> 1;
371
+ if (rep(mid)) hi = mid;
372
+ else lo = mid + 1;
373
+ }
374
+ const minimalPrefix = prefix.slice(0, lo);
375
+ const atLo = rep(lo);
376
+ const below = lo > 1 ? rep(lo - 1) : false;
377
+ if (atLo && !below) {
378
+ return { polluter: prefix[lo - 1], minimalPrefix, steps, exhausted: false };
379
+ }
380
+ return { polluter: null, minimalPrefix, steps, exhausted: true };
381
+ }
382
+
383
+ /** Run one test alone, in-order, capturing its outcome. */
384
+ export function buildIsolateCmd(victimNodeId, junitPath) {
385
+ return [
386
+ 'python3',
387
+ '-m',
388
+ 'pytest',
389
+ victimNodeId,
390
+ '-p',
391
+ 'no:randomly',
392
+ '--tb=no',
393
+ '-q',
394
+ `--junitxml=${junitPath}`,
395
+ ];
396
+ }
397
+
398
+ /** Run an explicit ordered set of node ids in the given order (no shuffle). */
399
+ export function buildOrderedSubsetCmd(nodeIds, junitPath) {
400
+ return [
401
+ 'python3',
402
+ '-m',
403
+ 'pytest',
404
+ ...nodeIds,
405
+ '-p',
406
+ 'no:randomly',
407
+ '--tb=no',
408
+ '-q',
409
+ `--junitxml=${junitPath}`,
410
+ ];
411
+ }
412
+
413
+ /**
414
+ * @typedef {Object} EnrichedVictim
415
+ * @property {string} victim
416
+ * @property {string} classification
417
+ * @property {boolean} [passesAlone]
418
+ * @property {string|null} [polluter]
419
+ * @property {string} [note]
420
+ * @property {string[]} [minimalPrefix]
421
+ * @property {number} [bisectSteps]
422
+ * @property {boolean} [exhausted]
423
+ * @property {string} [reproduce]
424
+ */
425
+
426
+ /**
427
+ * Enrich each order-dependent victim with an isolation verdict and, when it
428
+ * passes alone, the bisected polluter.
429
+ * @param {{victim: string, classification: string}[]} victims
430
+ * @param {string[]} order shuffled execution order (from confirm().order)
431
+ * @param {{runVictimAlone: (v: string) => string,
432
+ * reproducesInPrefix: (prefix: string[], v: string) => boolean,
433
+ * isolateRepeats?: number, bisectMaxSteps?: number}} seams
434
+ * @returns {EnrichedVictim[]}
435
+ */
436
+ export function locatePolluters(victims, order, seams) {
437
+ const { runVictimAlone, reproducesInPrefix } = seams;
438
+ const isolateRepeats = seams.isolateRepeats ?? 3;
439
+ return victims.map((v) => {
440
+ const idx = order.indexOf(v.victim);
441
+ const prefix = idx > 0 ? order.slice(0, idx) : [];
442
+ const alone = Array.from({ length: isolateRepeats }, () =>
443
+ runVictimAlone(v.victim),
444
+ );
445
+ if (!isolationConfirms(alone)) {
446
+ return {
447
+ ...v,
448
+ passesAlone: false,
449
+ polluter: null,
450
+ note: 'fails in isolation - the test is broken or flaky on its own, not an order-leak victim',
451
+ };
452
+ }
453
+ if (prefix.length === 0) {
454
+ return {
455
+ ...v,
456
+ passesAlone: true,
457
+ polluter: null,
458
+ note: 'ran first under this seed - no earlier test to blame',
459
+ };
460
+ }
461
+ const b = bisectPolluter(
462
+ prefix,
463
+ (subset) => reproducesInPrefix(subset, v.victim),
464
+ { maxSteps: seams.bisectMaxSteps },
465
+ );
466
+ return {
467
+ ...v,
468
+ passesAlone: true,
469
+ polluter: b.polluter,
470
+ minimalPrefix: b.minimalPrefix,
471
+ bisectSteps: b.steps,
472
+ exhausted: b.exhausted,
473
+ reproduce: b.polluter
474
+ ? `python3 -m pytest -p no:randomly ${b.polluter} ${v.victim}`
475
+ : undefined,
476
+ };
477
+ });
478
+ }
479
+
480
+ /**
481
+ * Convert a pytest node id (`path/to/file.py::Class::name`) to the JUnit key
482
+ * pytest reports for it (`path.to.file.Class::name`). Deterministic, so a map
483
+ * built from `--collect-only` correctly translates a canonical (report) id back
484
+ * to a runnable node id - including class-based layouts (the Phase 3 gap).
485
+ * @param {string} nodeId
486
+ */
487
+ export function nodeIdToKey(nodeId) {
488
+ const parts = nodeId.split('::');
489
+ const file = parts[0];
490
+ const rest = parts.slice(1);
491
+ const name = rest[rest.length - 1];
492
+ const classSegs = rest.slice(0, -1);
493
+ const module = file.replace(/\.py$/, '').replace(/\//g, '.');
494
+ return `${[module, ...classSegs].join('.')}::${name}`;
495
+ }
496
+
497
+ /**
498
+ * Map each canonical JUnit key to its runnable pytest node id.
499
+ * @param {string[]} nodeIds
500
+ * @returns {Record<string, string>}
501
+ */
502
+ export function buildNodeIdMap(nodeIds) {
503
+ const map = {};
504
+ for (const id of nodeIds) map[nodeIdToKey(id)] = id;
505
+ return map;
506
+ }
507
+
508
+ /** Run pytest --collect-only and return the authoritative node ids. */
509
+ export function collectPytestNodeIds(paths) {
510
+ const res = spawnSync(
511
+ 'python3',
512
+ [
513
+ '-m',
514
+ 'pytest',
515
+ ...paths,
516
+ '--collect-only',
517
+ '-q',
518
+ '-p',
519
+ 'no:cacheprovider',
520
+ ],
521
+ { encoding: 'utf8' },
522
+ );
523
+ return (res.stdout || '')
524
+ .split(/\r?\n/)
525
+ .map((l) => l.trim())
526
+ .filter((l) => l.includes('::') && !l.includes(' '));
527
+ }
528
+
529
+ // Fallback when a node id is absent from the collect map (function-level: the
530
+ // dotted classname is the module path, so dots -> slashes + `.py` recovers it).
531
+ function canonicalToNodeId(id) {
532
+ const i = id.lastIndexOf('::');
533
+ if (i < 0) return id;
534
+ return `${id.slice(0, i).replace(/\./g, '/')}.py::${id.slice(i + 2)}`;
535
+ }
536
+
537
+ function outcomeFor(outcomes, id) {
538
+ if (id in outcomes) return outcomes[id];
539
+ const name = id.slice(id.lastIndexOf('::') + 2);
540
+ const key = Object.keys(outcomes).find((k) => k.endsWith(`::${name}`));
541
+ return key ? outcomes[key] : undefined;
542
+ }
543
+
544
+ /**
545
+ * Real subprocess seams for locatePolluters (pytest; needs no shuffle plugin).
546
+ * A node-id map from `--collect-only` translates canonical report ids to
547
+ * runnable node ids, so class-based layouts re-run correctly.
548
+ * @param {string[]} [paths] target paths (enables the collect-only node-id map)
549
+ * @param {string} [junitPath]
550
+ */
551
+ export function realPolluterSeams(paths = [], junitPath = TMP_POLLUTER) {
552
+ const map = paths.length ? buildNodeIdMap(collectPytestNodeIds(paths)) : {};
553
+ const toNodeId = (id) => map[id] || canonicalToNodeId(id);
554
+ return {
555
+ isolateRepeats: 3,
556
+ runVictimAlone: (victim) => {
557
+ const out = runPytestSuite(
558
+ buildIsolateCmd(toNodeId(victim), junitPath),
559
+ junitPath,
560
+ );
561
+ return outcomeFor(out, victim);
562
+ },
563
+ reproducesInPrefix: (prefix, victim) => {
564
+ const ids = [...prefix, victim].map(toNodeId);
565
+ const out = runPytestSuite(
566
+ buildOrderedSubsetCmd(ids, junitPath),
567
+ junitPath,
568
+ );
569
+ return FAILED.has(outcomeFor(out, victim));
570
+ },
571
+ };
572
+ }