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,274 @@
1
+ #!/usr/bin/env node
2
+ // canary-savant -- order-dependence and isolation detector (Tier-1 static scan).
3
+ //
4
+ // Phase 1 ships the always-on static "suspect" tier: an AST-lite scan that
5
+ // flags the shared-state smells that predict order-dependent tests -- module-
6
+ // level mutables written by tests, setup without teardown, mutated process
7
+ // singletons, order-coupled names -- with no test execution. The opt-in
8
+ // dynamic confirmer (--confirm) lands in a later phase.
9
+ //
10
+ // <paths> files or directories to scan (default: the current directory).
11
+ // --json emit machine-readable findings instead of human text.
12
+ // --strict exit 1 when there are findings (default is advisory: exit 0).
13
+ //
14
+ // Tier-0 in the real sense -- no LLM, no network, no secrets, no dependency on
15
+ // any other skill.
16
+ //
17
+ // Invoked via `canary skills run canary-savant -- [paths] [--json] [--strict]`.
18
+
19
+ import fs from 'node:fs';
20
+ import { scanPaths, toJson } from './scanner.mjs';
21
+ import { confirm, locatePolluters, realPolluterSeams } from './runner.mjs';
22
+ import { RULES } from './rules.mjs';
23
+ import {
24
+ createParser,
25
+ formatUsageError,
26
+ EXIT_USAGE,
27
+ } from '../../../lib/parse-args.mjs';
28
+
29
+ export const SCHEMA_VERSION = 1;
30
+
31
+ // --- no-silent-abstention (#508 D2, skill-CLI convention half) ---------------
32
+ //
33
+ // Skill CLIs are deliberately self-contained -- no engine import, no shared
34
+ // module -- so they cannot call `gateOutcome`. They honour the doctrine by
35
+ // CONVENTION instead, emitting the same greppable line the engine helper does.
36
+ // The skill-layer conformance registry (agents/skills/test/gate-conformance.
37
+ // test.ts) is what holds them to it: a row whose fixture collapses the
38
+ // denominator and asserts the loud outcome.
39
+ //
40
+ // U+26A0 / U+2014 are written as escapes so this source stays ASCII, matching
41
+ // ts/src/core/gate-result.ts.
42
+ const ABSTAINED_LINE =
43
+ '\u{26A0} Abstained \u{2014} verified zero items; this is not a pass.';
44
+
45
+ const PREFIX = 'canary-savant:';
46
+
47
+ // The rules block is GENERATED from RULES, never hand-typed, so a new rule
48
+ // appears in --help the moment it is registered.
49
+ const USAGE =
50
+ 'usage: canary-savant [-h] [--json] [--strict] [--confirm] [--seed N] [--]\n' +
51
+ ' [path ...]\n' +
52
+ '\n' +
53
+ 'Order-dependence and isolation detector: flags the shared-state smells that\n' +
54
+ 'predict order-dependent tests, and optionally confirms them dynamically.\n' +
55
+ '\n' +
56
+ 'positional arguments:\n' +
57
+ ' path files or directories to scan (default: the current directory)\n' +
58
+ '\n' +
59
+ 'options:\n' +
60
+ ' -h, --help show this help message and exit\n' +
61
+ ' --json emit machine-readable findings instead of human text\n' +
62
+ ' --strict exit 1 when there are findings (default is advisory: exit 0)\n' +
63
+ ' --confirm run the opt-in Tier-2 dynamic confirmer (executes the suite)\n' +
64
+ ' --seed N shuffle seed for --confirm (default: random)\n' +
65
+ '\n' +
66
+ 'rules:\n' +
67
+ RULES.map((r) => ` ${r.ruleId} (${r.severity})`).join('\n');
68
+
69
+ function summary(result) {
70
+ const bySeverity = {};
71
+ for (const f of result.findings) {
72
+ bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
73
+ }
74
+ return {
75
+ files_scanned: result.filesScanned,
76
+ findings: result.findings.length,
77
+ by_severity: bySeverity,
78
+ suppressed: result.suppressed ?? 0,
79
+ };
80
+ }
81
+
82
+ // A trailing "N suppressed" note keeps inline-ignored lines visible but out of
83
+ // the actionable total - the pattern canary-blackhawk (#393) and the
84
+ // PR-guardian sticky comment use.
85
+ function suppressedNote(result) {
86
+ const n = result.suppressed ?? 0;
87
+ return n ? `\n${n} suppressed (inline savant-ignore).` : '';
88
+ }
89
+
90
+ function renderText(result) {
91
+ const count = result.findings.length;
92
+ const files = result.filesScanned;
93
+ const fp = files === 1 ? '' : 's';
94
+ // #508: zero suspects over zero scanned files is an ABSENT result, not a
95
+ // clean one. Findings outrank abstention, so this is the no-findings path.
96
+ if (!count && !files) {
97
+ return (
98
+ `${ABSTAINED_LINE} No file matched the given paths, so there is ` +
99
+ 'nothing to report. Point at a directory that holds test files, or ' +
100
+ 'pass a file directly.'
101
+ );
102
+ }
103
+ if (!count) {
104
+ return (
105
+ `No order-dependence suspects (${files} file${fp} scanned).` +
106
+ suppressedNote(result)
107
+ );
108
+ }
109
+ const sp = count === 1 ? '' : 's';
110
+ const lines = [
111
+ `${count} order-dependence suspect${sp} in ${files} file${fp}:`,
112
+ '',
113
+ ];
114
+ for (const f of result.findings) {
115
+ lines.push(` ${f.file}:${f.line} [${f.severity}] ${f.ruleId}`);
116
+ lines.push(` ${f.snippet}`);
117
+ lines.push(` why: ${f.why}`);
118
+ }
119
+ lines.push('');
120
+ lines.push(
121
+ 'Advisory by default. Re-run with --strict to fail the step on findings.',
122
+ );
123
+ return lines.join('\n') + suppressedNote(result);
124
+ }
125
+
126
+ /**
127
+ * savant takes paths, so it gets the `--` end-of-options terminator and treats
128
+ * a lone `-` as a positional, as argparse does.
129
+ *
130
+ * `--seed` is the flag that made the shared int type worth having: a bad value
131
+ * used to decay to `Math.floor(Math.random() * 1e6)` at exit 0, silently
132
+ * randomizing the one flag whose entire purpose is reproducibility. The shared
133
+ * parser rejects a non-integer AND an integer past the safe range (#479).
134
+ */
135
+ export const CLI_SPEC = {
136
+ prog: 'canary-savant',
137
+ booleans: {
138
+ '--json': 'json',
139
+ '--strict': 'strict',
140
+ '--confirm': 'confirm',
141
+ },
142
+ values: { '--seed': { key: 'seed', type: 'int' } },
143
+ positionals: { key: 'paths', defaults: ['.'] },
144
+ };
145
+
146
+ const parseArgs = createParser(CLI_SPEC);
147
+
148
+ export function renderConfirm(dyn) {
149
+ if (
150
+ dyn.status === 'no_plugin' ||
151
+ dyn.status === 'baseline_red' ||
152
+ dyn.status === 'unknown_framework'
153
+ ) {
154
+ return `\nTier 2 (dynamic): skipped - ${dyn.message}`;
155
+ }
156
+ const lines = [`\nTier 2 (dynamic): seed ${dyn.seed}`];
157
+ if (!dyn.victims.length) {
158
+ lines.push(' No order-dependence confirmed under this seed.');
159
+ } else {
160
+ let namedAny = false;
161
+ for (const v of dyn.victims) {
162
+ lines.push(` order-dependent: ${v.victim}`);
163
+ if (v.polluter) {
164
+ lines.push(` polluted by: ${v.polluter}`);
165
+ if (v.reproduce) lines.push(` reproduce: ${v.reproduce}`);
166
+ namedAny = true;
167
+ } else if (v.note) {
168
+ lines.push(` ${v.note}`);
169
+ } else if (v.exhausted) {
170
+ lines.push(
171
+ ` no single culprit isolated; smallest reproducing prefix: ` +
172
+ `${v.minimalPrefix?.length ?? '?'} test(s)`,
173
+ );
174
+ }
175
+ }
176
+ if (!namedAny && dyn.reproduce) {
177
+ lines.push(` reproduce: ${dyn.reproduce}`);
178
+ }
179
+ if (dyn.framework === 'vitest') {
180
+ lines.push(
181
+ ' (vitest: victims detected; polluter bisect is pytest-only)',
182
+ );
183
+ }
184
+ }
185
+ if (dyn.nondeterministic.length) {
186
+ lines.push(
187
+ ` (${dyn.nondeterministic.length} nondeterministic flake(s) - not order, handed off)`,
188
+ );
189
+ }
190
+ return lines.join('\n');
191
+ }
192
+
193
+ export function main(argv = []) {
194
+ const { positionals: paths, opts, help, error } = parseArgs(argv);
195
+
196
+ // Usage and parse errors resolve before any filesystem work, so `--help`
197
+ // never reports a missing path and a typo never half-runs a scan.
198
+ if (help) {
199
+ console.log(USAGE);
200
+ return 0;
201
+ }
202
+ if (error) {
203
+ console.error(formatUsageError(CLI_SPEC.prog, error));
204
+ return EXIT_USAGE;
205
+ }
206
+
207
+ for (const entry of paths) {
208
+ if (!fs.existsSync(entry)) {
209
+ console.error(`${PREFIX} path not found: ${entry}`);
210
+ return 1;
211
+ }
212
+ }
213
+
214
+ const result = scanPaths(paths);
215
+
216
+ let dyn;
217
+ if (opts.confirm) {
218
+ const seed = Number.isFinite(opts.seed)
219
+ ? opts.seed
220
+ : Math.floor(Math.random() * 1e6);
221
+ dyn = confirm(paths, { seed });
222
+ // Phase 3: name the polluter behind each confirmed victim. pytest only -
223
+ // vitest has no CLI-driven ordered per-test execution, so it gets victim
224
+ // detection (Phase 2) but not polluter bisection.
225
+ if (
226
+ dyn.status === 'ok' &&
227
+ dyn.victims.length &&
228
+ dyn.framework === 'pytest'
229
+ ) {
230
+ dyn.victims = locatePolluters(
231
+ dyn.victims,
232
+ dyn.order,
233
+ realPolluterSeams(paths),
234
+ );
235
+ }
236
+ }
237
+
238
+ if (opts.json) {
239
+ const payload = {
240
+ schema_version: SCHEMA_VERSION,
241
+ findings: result.findings.map(toJson),
242
+ summary: summary(result),
243
+ };
244
+ if (dyn) {
245
+ payload.dynamic = {
246
+ status: dyn.status,
247
+ seed: dyn.seed,
248
+ victims: dyn.victims,
249
+ nondeterministic: dyn.nondeterministic,
250
+ reproduce: dyn.reproduce,
251
+ ...(dyn.message ? { message: dyn.message } : {}),
252
+ };
253
+ }
254
+ console.log(JSON.stringify(payload, null, 2));
255
+ } else {
256
+ console.log(renderText(result) + (dyn ? renderConfirm(dyn) : ''));
257
+ }
258
+
259
+ const hasViolation =
260
+ result.findings.length > 0 || (dyn?.victims.length ?? 0) > 0;
261
+ // Advisory by default (D3); --strict inherits EXIT_ABSTAINED (3) on a
262
+ // collapsed denominator, distinct from 1 ("found something real").
263
+ if (opts.strict && !result.filesScanned) return 3;
264
+ return opts.strict && hasViolation ? 1 : 0;
265
+ }
266
+
267
+ // Direct execution (the skill runner execs this file via its shebang).
268
+ //
269
+ // `process.exitCode`, not `process.exit()`: a large `--json` payload exceeds
270
+ // the pipe buffer, and `process.exit` tears the process down mid-write, leaving
271
+ // truncated JSON that still exits 0 (#791).
272
+ if (import.meta.url === `file://${process.argv[1]}`) {
273
+ process.exitCode = main(process.argv.slice(2));
274
+ }
@@ -0,0 +1,274 @@
1
+ // SV003 restoration analysis: does the file restore the global it mutates?
2
+ //
3
+ // SV003's `why` asserts persistence ("the change persists across tests").
4
+ // Dogfooding (#493) showed 37 of 51 self-scan findings were in files using
5
+ // the textbook save-in-beforeEach / restore-in-afterEach pattern -- the rule
6
+ // was asserting a consequence it never checked. This module supplies the
7
+ // check; when it finds restoration, the scanner suppresses the finding.
8
+ //
9
+ // Chosen heuristic -- deliberately conservative, because a false skip hides
10
+ // real pollution while a false flag is merely advisory noise. A mutation of
11
+ // family F (process.env / os.environ / sys.modules) with key K is considered
12
+ // restored only when there is POSITIVE evidence:
13
+ //
14
+ // 1. Snapshot write-back: the mutation's own RHS is a plain identifier (or
15
+ // one index/property off it) that some line saves FROM the same family
16
+ // (`saved[v] = process.env[v]`, `origCI = process.env.CI`,
17
+ // `old = os.environ.copy()`). That line IS the restore, not a pollution.
18
+ // 2. Teardown restore: inside a teardown region, the same family is
19
+ // restored -- assigned or deleted with key K, or with a computed key /
20
+ // update() / clear() / Object.assign(family, ...) which restores the
21
+ // whole family (the save-restore loop pattern).
22
+ //
23
+ // Teardown regions: JS afterEach/afterAll call bodies (paren-balanced,
24
+ // string-aware, capped at 50 lines if unclosed -- an unbalanced count must
25
+ // not swallow the file into "teardown"); Python teardown_*/tearDown* def
26
+ // bodies, code after a fixture `yield` (indentation-scoped), and addCleanup
27
+ // lines. Detection is language-agnostic like the rest of the scanner; a JS
28
+ // generator `yield` could open a phantom region, but a same-family restore
29
+ // idiom inside one is overwhelmingly teardown-intent anyway.
30
+ //
31
+ // Known non-suppressors, on purpose:
32
+ // - vi.stubEnv/vi.unstubAllEnvs and monkeypatch only undo their OWN
33
+ // mutations, never a direct `process.env.X = ...` / `os.environ[...] =`,
34
+ // so their presence must not launder a direct mutation. (Mutations made
35
+ // THROUGH them never match SV003's assign patterns in the first place.)
36
+ // - `addCleanup(os.environ.pop, 'K')` (function-reference form) is not
37
+ // recognized; only idioms where the restore call is spelled out are. A
38
+ // missed restore is a false flag, the safe direction.
39
+
40
+ import { SINGLETON_FAMILIES } from './rules.mjs';
41
+ import {
42
+ stringLiteralRanges,
43
+ inStringLiteral,
44
+ execOutsideStrings,
45
+ } from './string-literals.mjs';
46
+
47
+ // An unclosed JS teardown region (unbalanced parens, e.g. a regex literal
48
+ // confusing the counter) extends at most this many lines past its opener.
49
+ const REGION_CAP_LINES = 50;
50
+
51
+ const splitLines = (text) => text.split(/\r\n|\r|\n/);
52
+
53
+ /** Every match of `pattern` in `line` whose start index is code. */
54
+ function execAllOutsideStrings(pattern, line, ranges) {
55
+ const flags = pattern.flags.includes('g')
56
+ ? pattern.flags
57
+ : `${pattern.flags}g`;
58
+ const re = new RegExp(pattern.source, flags);
59
+ const matches = [];
60
+ let match;
61
+ while ((match = re.exec(line)) !== null) {
62
+ if (!inStringLiteral(ranges, match.index)) matches.push(match);
63
+ if (re.lastIndex === match.index) re.lastIndex += 1;
64
+ }
65
+ return matches;
66
+ }
67
+
68
+ /** 'FOO' from a quoted bracket expression; null for a computed key. */
69
+ function literalKey(expr) {
70
+ if (expr == null) return null;
71
+ const m = /^\s*(['"`])(.*)\1\s*$/.exec(expr);
72
+ return m ? m[2] : null;
73
+ }
74
+
75
+ /** Key from an assign/delete match: dot-property group or bracket literal. */
76
+ function keyOf(match) {
77
+ // process.env has (dotKey, bracketExpr); the Python families have a single
78
+ // bracket/arg group. A dot-property is always a literal key.
79
+ if (match.length > 2) return match[1] ?? literalKey(match[2]);
80
+ return literalKey(match[1]);
81
+ }
82
+
83
+ /**
84
+ * Classify the singleton mutation on `line`, if any.
85
+ * @param {string} line
86
+ * @param {Array<[number, number]>} ranges string ranges for `line`
87
+ * @returns {{family: string, key: string|null, rhs: string}|null}
88
+ */
89
+ export function classifyMutation(line, ranges) {
90
+ for (const family of SINGLETON_FAMILIES) {
91
+ const match = execOutsideStrings(family.assign, line, ranges);
92
+ if (!match) continue;
93
+ return {
94
+ family: family.id,
95
+ key: keyOf(match),
96
+ rhs: line.slice(match.index + match[0].length),
97
+ };
98
+ }
99
+ return null;
100
+ }
101
+
102
+ // The mutation's RHS must be a bare identifier plus at most one index or
103
+ // property -- `saved[v]`, `origCI`, `snap.CI` -- for write-back detection.
104
+ const RHS_IDENT = /^\s*([A-Za-z_$][\w$]*)\s*(?:\[[^\]]*\]|\.\w+)?\s*[;,]?\s*$/;
105
+
106
+ const RE_META = /[.*+?^${}()|[\]\\]/g;
107
+ const escapeRe = (s) => s.replace(RE_META, '\\$&');
108
+
109
+ /**
110
+ * True when the mutation is a write-back from a snapshot of the same family:
111
+ * its RHS reads a variable that some line saved from the family via a PURE
112
+ * snapshot (`x = process.env[...]`, `x = os.environ.copy()`,
113
+ * `x = dict(os.environ)`, `x = { ...process.env }`). Expressions
114
+ * (`x = process.env.A + '/y'`) are not snapshots and do not count.
115
+ * @param {{family: string, rhs: string}} mutation from classifyMutation
116
+ * @param {string[]} lines the file's lines
117
+ * @returns {boolean}
118
+ */
119
+ export function isSnapshotWriteBack(mutation, lines) {
120
+ const rhs = RHS_IDENT.exec(mutation.rhs);
121
+ if (!rhs) return false;
122
+ const family = SINGLETON_FAMILIES.find((f) => f.id === mutation.family);
123
+ const saveEvidence = new RegExp(
124
+ `(?:^|[^.\\w$])${escapeRe(rhs[1])}\\s*(?:\\[[^\\]]*\\]|\\.\\w+)?\\s*=\\s*` +
125
+ `(?:\\{\\s*\\.\\.\\.\\s*${family.token}\\s*\\}` +
126
+ `|dict\\(\\s*${family.token}\\s*\\)` +
127
+ `|${family.token}(?:\\s*\\[[^\\]]*\\]|\\.\\w+\\([^)]*\\)|\\.\\w+)?` +
128
+ `)\\s*[;,)]?\\s*$`,
129
+ );
130
+ return lines.some((l) => saveEvidence.test(l));
131
+ }
132
+
133
+ // --- Teardown-region collection ----------------------------------------------
134
+
135
+ const JS_TEARDOWN_TOKEN = /\b(?:afterEach|afterAll)\s*\(/;
136
+ const PY_TEARDOWN_DEF = /^(\s*)def\s+(?:teardown\w*|tearDown\w*)\s*\(/;
137
+ const PY_YIELD = /^(\s*)yield\b/;
138
+ // #733: an in-test `finally` restore is TIGHTER than an afterEach -- the
139
+ // window in which the global is dirty is the try block, not the whole test --
140
+ // yet only framework hooks counted, so the better idiom was the flagged one.
141
+ const JS_FINALLY = /\bfinally\s*\{/;
142
+ const PY_FINALLY = /^(\s*)finally\s*:/;
143
+
144
+ const indentOf = (line) => /^\s*/.exec(line)[0].length;
145
+ const isBlank = (line) => line.trim() === '';
146
+
147
+ /** JS: afterEach/afterAll call bodies, paren-balanced and string-aware. */
148
+ function collectJsRegions(lines, rangesByLine, region) {
149
+ lines.forEach((line, i) => {
150
+ const token = execOutsideStrings(JS_TEARDOWN_TOKEN, line, rangesByLine[i]);
151
+ if (!token) return;
152
+ let depth = 0;
153
+ let col = token.index + token[0].length - 1; // the opening paren
154
+ for (let j = i; j < lines.length && j <= i + REGION_CAP_LINES; j += 1) {
155
+ region.add(j);
156
+ const text = lines[j];
157
+ const start = j === i ? col : 0;
158
+ for (let k = start; k < text.length; k += 1) {
159
+ if (inStringLiteral(rangesByLine[j], k)) continue;
160
+ if (text[k] === '(') depth += 1;
161
+ else if (text[k] === ')') {
162
+ depth -= 1;
163
+ if (depth === 0) return;
164
+ }
165
+ }
166
+ }
167
+ });
168
+ }
169
+
170
+ /** JS: `finally { ... }` bodies, brace-balanced and string-aware (#733). */
171
+ function collectJsFinallyRegions(lines, rangesByLine, region) {
172
+ lines.forEach((line, i) => {
173
+ const token = execOutsideStrings(JS_FINALLY, line, rangesByLine[i]);
174
+ if (!token) return;
175
+ let depth = 0;
176
+ // Count from the finally's own `{`, never the line start -- the common
177
+ // spelling `} finally {` opens with the TRY block's closing brace.
178
+ let col = token.index + token[0].length - 1;
179
+ for (let j = i; j < lines.length && j <= i + REGION_CAP_LINES; j += 1) {
180
+ region.add(j);
181
+ const text = lines[j];
182
+ const start = j === i ? col : 0;
183
+ for (let k = start; k < text.length; k += 1) {
184
+ if (inStringLiteral(rangesByLine[j], k)) continue;
185
+ if (text[k] === '{') depth += 1;
186
+ else if (text[k] === '}') {
187
+ depth -= 1;
188
+ if (depth === 0) return;
189
+ }
190
+ }
191
+ }
192
+ });
193
+ }
194
+
195
+ /** Python: teardown def bodies, post-yield code, addCleanup lines. */
196
+ function collectPyRegions(lines, rangesByLine, region) {
197
+ lines.forEach((line, i) => {
198
+ const def = PY_TEARDOWN_DEF.exec(line);
199
+ if (def) {
200
+ const indent = def[1].length;
201
+ for (let j = i + 1; j < lines.length; j += 1) {
202
+ if (!isBlank(lines[j]) && indentOf(lines[j]) <= indent) break;
203
+ region.add(j);
204
+ }
205
+ }
206
+ const yielded = PY_YIELD.exec(line);
207
+ if (yielded) {
208
+ const indent = yielded[1].length;
209
+ for (let j = i + 1; j < lines.length; j += 1) {
210
+ if (!isBlank(lines[j]) && indentOf(lines[j]) < indent) break;
211
+ region.add(j);
212
+ }
213
+ }
214
+ // `finally:` is indentation-scoped, like the def and yield regions (#733).
215
+ const fin = PY_FINALLY.exec(line);
216
+ if (fin) {
217
+ const indent = fin[1].length;
218
+ for (let j = i + 1; j < lines.length; j += 1) {
219
+ if (!isBlank(lines[j]) && indentOf(lines[j]) <= indent) break;
220
+ region.add(j);
221
+ }
222
+ }
223
+ if (execOutsideStrings(/\baddCleanup\b/, line, rangesByLine[i])) {
224
+ region.add(i);
225
+ }
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Analyze which globals the file restores in teardown.
231
+ * @param {string} text file contents
232
+ * @returns {{restores: (family: string, key: string|null) => boolean}}
233
+ */
234
+ export function analyzeRestoration(text) {
235
+ const lines = splitLines(text);
236
+ const rangesByLine = lines.map((l) => stringLiteralRanges(l));
237
+ const region = new Set();
238
+ collectJsRegions(lines, rangesByLine, region);
239
+ collectJsFinallyRegions(lines, rangesByLine, region);
240
+ collectPyRegions(lines, rangesByLine, region);
241
+
242
+ const restoresAll = new Set();
243
+ const restoredKeys = new Map(); // family id -> Set<key>
244
+ const record = (familyId, key) => {
245
+ if (key == null) {
246
+ restoresAll.add(familyId);
247
+ return;
248
+ }
249
+ if (!restoredKeys.has(familyId)) restoredKeys.set(familyId, new Set());
250
+ restoredKeys.get(familyId).add(key);
251
+ };
252
+
253
+ for (const i of region) {
254
+ const line = lines[i];
255
+ const ranges = rangesByLine[i];
256
+ for (const family of SINGLETON_FAMILIES) {
257
+ for (const pattern of [family.assign, ...family.deletes]) {
258
+ for (const match of execAllOutsideStrings(pattern, line, ranges)) {
259
+ record(family.id, keyOf(match));
260
+ }
261
+ }
262
+ for (const pattern of family.restoreAll) {
263
+ if (execOutsideStrings(pattern, line, ranges)) record(family.id, null);
264
+ }
265
+ }
266
+ }
267
+
268
+ return {
269
+ restores(familyId, key) {
270
+ if (restoresAll.has(familyId)) return true;
271
+ return key != null && (restoredKeys.get(familyId)?.has(key) ?? false);
272
+ },
273
+ };
274
+ }