canary-test-cli 7.1.0 → 8.0.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 (128) hide show
  1. package/agents/skills/README.md +327 -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-batwoman/SKILL.md +119 -0
  7. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  11. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  12. package/agents/skills/claude-code/canary-cassandra/SKILL.md +187 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +270 -0
  14. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  15. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +178 -0
  16. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  17. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  18. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  19. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  21. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  22. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  27. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  28. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  29. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  30. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +197 -0
  31. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  32. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  37. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  38. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  42. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +144 -0
  44. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  45. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  46. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  52. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  53. package/agents/skills/claude-code/canary-screech/SKILL.md +109 -0
  54. package/agents/skills/claude-code/canary-screech/scripts/blast.mjs +125 -0
  55. package/agents/skills/claude-code/canary-screech/scripts/cli.mjs +128 -0
  56. package/agents/skills/claude-code/canary-screech/scripts/cluster.mjs +97 -0
  57. package/agents/skills/claude-code/canary-screech/scripts/history.mjs +73 -0
  58. package/agents/skills/claude-code/canary-screech/scripts/redness.mjs +94 -0
  59. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  60. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  61. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  62. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  63. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  64. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  65. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  66. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  67. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  68. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  69. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  70. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  71. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  72. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  73. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  74. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  75. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  76. package/agents/skills/lib/parse-args.mjs +275 -0
  77. package/dist/engine/analysis/batwoman/audit.js +39 -0
  78. package/dist/engine/analysis/batwoman/closure.js +159 -0
  79. package/dist/engine/analysis/batwoman/gh-history.js +119 -0
  80. package/dist/engine/analysis/batwoman/probes.js +195 -0
  81. package/dist/engine/analysis/batwoman/registry.js +142 -0
  82. package/dist/engine/analysis/batwoman/render.js +194 -0
  83. package/dist/engine/analysis/batwoman/run-window.js +122 -0
  84. package/dist/engine/analysis/batwoman/text.js +84 -0
  85. package/dist/engine/analysis/batwoman/triggers.js +122 -0
  86. package/dist/engine/analysis/batwoman/verdict.js +64 -0
  87. package/dist/engine/analysis/cli.js +47 -14
  88. package/dist/engine/analysis/gh-flaky/gh-run-attempts.js +206 -0
  89. package/dist/engine/batwoman-cli.js +119 -0
  90. package/dist/engine/ci-ready-cli.js +71 -0
  91. package/dist/engine/cli-commands.js +49 -72
  92. package/dist/engine/cli.core.js +16 -0
  93. package/dist/engine/company-knowledge-cli.js +10 -2
  94. package/dist/engine/core/ci-ready.js +112 -0
  95. package/dist/engine/core/company-knowledge.js +8 -0
  96. package/dist/engine/core/migrator.js +147 -20
  97. package/dist/engine/core/permission-matrix.js +219 -0
  98. package/dist/engine/core/quality-scorer.js +27 -19
  99. package/dist/engine/core/scaling-curve.js +143 -0
  100. package/dist/engine/core/skill-dispatch.js +115 -0
  101. package/dist/engine/core/skill-examples.js +103 -3
  102. package/dist/engine/core/skill-registry.js +59 -4
  103. package/dist/engine/core/string-literals.js +3 -1
  104. package/dist/engine/core/test-files.js +77 -0
  105. package/dist/engine/core/vacuity-scanner.js +330 -15
  106. package/dist/engine/core/workflow-discovery.js +41 -23
  107. package/dist/engine/guardian/adjudication-github.js +136 -0
  108. package/dist/engine/guardian/adjudication.js +119 -340
  109. package/dist/engine/guardian/analysis-emit.js +7 -2
  110. package/dist/engine/guardian/cli.js +277 -249
  111. package/dist/engine/guardian/coverage.js +2 -1
  112. package/dist/engine/guardian/diff-coverage/coverage-delta.js +162 -0
  113. package/dist/engine/guardian/diff-coverage/formats/cobertura.js +45 -1
  114. package/dist/engine/guardian/diff-coverage/orchestrator.js +25 -21
  115. package/dist/engine/guardian/diff-coverage/paths.js +5 -9
  116. package/dist/engine/guardian/diff-coverage/report-tier.js +88 -12
  117. package/dist/engine/guardian/diff-extractor.js +31 -32
  118. package/dist/engine/guardian/pr-check.js +354 -223
  119. package/dist/engine/guardian/pr-comment.js +35 -58
  120. package/dist/engine/guardian/weak-test.js +236 -0
  121. package/dist/engine/mcp-server.js +67 -4
  122. package/dist/engine/permission-matrix-cli.js +51 -0
  123. package/dist/engine/scaling-curve-cli.js +147 -0
  124. package/dist/engine/skills-cli.js +171 -51
  125. package/dist/engine/workflow-cli.js +85 -65
  126. package/dist/reporters/testtracker.d.ts +1 -1
  127. package/dist/reporters/testtracker.js +1 -1
  128. package/package.json +3 -2
@@ -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
+ }
@@ -0,0 +1,168 @@
1
+ // Static suspect-rule catalog for canary-savant Tier-1 (pure data).
2
+ //
3
+ // Tier-1 flags the shared-state smells that *predict* order-dependent tests
4
+ // without executing anything: a module-level mutable that a test writes to, a
5
+ // setup with no matching teardown, a mutated process singleton, an
6
+ // order-coupled name. It is advisory: a smell is a suspect, not a proven leak.
7
+ // The dynamic confirmer (Tier-2, opt-in) is what turns a suspect into a named
8
+ // polluter.
9
+ //
10
+ // The detection logic lives in scanner.mjs; this module holds the metadata
11
+ // (id, severity, one-line rationale) each finding carries and the regexes the
12
+ // scanner tests against. JS has no verbose-regex flag, so patterns are compact
13
+ // literals documented by the comment above them.
14
+
15
+ export const SEVERITIES = ['high', 'medium', 'low'];
16
+
17
+ /** @typedef {{ruleId: string, severity: string, why: string}} Rule */
18
+
19
+ /** @type {Rule[]} */
20
+ export const RULES = [
21
+ {
22
+ ruleId: 'SV001-module-mutable-global',
23
+ severity: 'medium',
24
+ why:
25
+ 'a module-level mutable is written by a test, so state leaks into ' +
26
+ 'whatever test runs next',
27
+ },
28
+ {
29
+ ruleId: 'SV002-missing-teardown',
30
+ severity: 'medium',
31
+ why:
32
+ 'setup acquires state with no matching teardown, so the state outlives ' +
33
+ 'the test that created it',
34
+ },
35
+ {
36
+ ruleId: 'SV003-shared-singleton-mutation',
37
+ severity: 'low',
38
+ why:
39
+ 'a process-global singleton is mutated without restore, so the change ' +
40
+ 'persists across tests',
41
+ },
42
+ {
43
+ ruleId: 'SV004-order-coupled-name',
44
+ severity: 'low',
45
+ why:
46
+ 'the name or comment encodes an execution order, a self-reported ' +
47
+ 'dependence on another test running first',
48
+ },
49
+ ];
50
+
51
+ export const WHY = Object.fromEntries(RULES.map((r) => [r.ruleId, r.why]));
52
+ export const SEVERITY = Object.fromEntries(
53
+ RULES.map((r) => [r.ruleId, r.severity]),
54
+ );
55
+
56
+ // SV003: singleton / env mutation (assignment, never a read or comparison).
57
+ // A trailing negative lookahead on `=` keeps `==` comparisons out. One entry
58
+ // per process-global family (#493): `assign` detects the mutation (and, in a
59
+ // teardown region, the restore); `deletes` and `restoreAll` are restore-only
60
+ // idioms. Group 1/2 of `assign` and `deletes` capture the key (dot-property
61
+ // or bracket expression) so restoration.mjs can match restores per key.
62
+ // os.environ['X'] = ... | sys.modules['m'] = ... | process.env.X = ...
63
+ // | process.env['X'] = ...
64
+ export const SINGLETON_FAMILIES = [
65
+ {
66
+ id: 'process.env',
67
+ token: 'process\\.env',
68
+ assign: /\bprocess\.env\s*(?:\.(\w+)|\[([^\]]+)\])\s*=(?!=)/,
69
+ deletes: [/\bdelete\s+process\.env\s*(?:\.(\w+)|\[([^\]]+)\])/],
70
+ restoreAll: [/\bObject\.assign\s*\(\s*process\.env\s*,/],
71
+ },
72
+ {
73
+ id: 'os.environ',
74
+ token: 'os\\.environ',
75
+ assign: /\bos\.environ\s*\[([^\]]+)\]\s*=(?!=)/,
76
+ deletes: [
77
+ /\bdel\s+os\.environ\s*\[([^\]]+)\]/,
78
+ /\bos\.environ\.pop\s*\(\s*([^,)]+)/,
79
+ ],
80
+ restoreAll: [/\bos\.environ\.(?:update|clear)\s*\(/],
81
+ },
82
+ {
83
+ id: 'sys.modules',
84
+ token: 'sys\\.modules',
85
+ assign: /\bsys\.modules\s*\[([^\]]+)\]\s*=(?!=)/,
86
+ deletes: [
87
+ /\bdel\s+sys\.modules\s*\[([^\]]+)\]/,
88
+ /\bsys\.modules\.pop\s*\(\s*([^,)]+)/,
89
+ ],
90
+ restoreAll: [/\bsys\.modules\.update\s*\(/],
91
+ },
92
+ ];
93
+
94
+ // SV004: order-coupled name or comment (fires on code and comment lines).
95
+ // Split in two (#493) because the alternatives anchor differently:
96
+ //
97
+ // CODE-anchored: the token is source code in real usage, so a match starting
98
+ // inside a string literal is fixture data and is rejected.
99
+ // def test_1_... -> ordinal-indexed test
100
+ // def test_first / test_last(_more) -> ordinal test name (not test_firstname)
101
+ // it('... run first') -> ordering inside an it() title (anchor: `it(`)
102
+ export const SV004_CODE_PATTERN =
103
+ /\bdef\s+test_\d+_|\bdef\s+test_(?:first|second|third|fourth|fifth|sixth|seventh|last|initial|final)\s*[(:]|\bit\s*\(\s*['"][^'"]*\b(?:run|runs|running)\s+(?:first|last|before|after)\b/i;
104
+ //
105
+ // TEXT-anchored: the directive legitimately lives inside strings (test
106
+ // titles, docstrings) and comments, so it is NOT string-literal filtered.
107
+ // "must run before ..." -> self-reported ordering note
108
+ // "runs before ..."
109
+ export const SV004_TEXT_PATTERN =
110
+ /\bmust\s+run\s+(?:before|after|first|last)\b|\bruns?\s+(?:before|after)\b/i;
111
+
112
+ // SV002: framework-conditioned setup/teardown pairs. Only CLASS/ALL-scoped
113
+ // setup is included: it manages state shared across a class's tests, so a
114
+ // missing teardown genuinely leaks. Per-test setup (setUp / setup_method /
115
+ // beforeEach) rebuilds state for each test, so a missing teardown there is not a
116
+ // leak - and firing on it was the dominant false positive when dogfooding on
117
+ // canary's own suite (Phase 5). A setup present without its teardown fires.
118
+ export const PYTHON_SETUP_TEARDOWN = [
119
+ ['setup_class', 'teardown_class'],
120
+ ['setUpClass', 'tearDownClass'],
121
+ ];
122
+ export const JS_SETUP_TEARDOWN = [['beforeAll', 'afterAll']];
123
+
124
+ // SV001: mutable-literal declarations and the mutations that indict them.
125
+ // Python: NAME = {} | [] | set() | dict() | list() (optional trailing #comment)
126
+ export const PY_MODULE_MUTABLE =
127
+ /^(\w+)\s*=\s*(?:\{[^}]*\}|\[[^\]]*\]|set\(\)|dict\(\)|list\(\))\s*(?:#.*)?$/;
128
+ // JS: (let|var|const) NAME = {} | []
129
+ export const JS_MODULE_MUTABLE =
130
+ /^(?:let|var|const)\s+(\w+)\s*=\s*(?:\{[^}]*\}|\[[^\]]*\])/;
131
+
132
+ // Method calls that mutate a container in place (Python + JS array/object).
133
+ const MUTATING_METHODS = [
134
+ 'append',
135
+ 'add',
136
+ 'update',
137
+ 'extend',
138
+ 'insert',
139
+ 'pop',
140
+ 'clear',
141
+ 'setdefault',
142
+ 'remove',
143
+ 'discard',
144
+ 'push',
145
+ 'unshift',
146
+ 'splice',
147
+ ];
148
+
149
+ const RE_META = /[.*+?^${}()|[\]\\]/g;
150
+ const escapeRe = (s) => s.replace(RE_META, '\\$&');
151
+
152
+ /**
153
+ * A pattern matching an in-place mutation of `name`
154
+ * (index assign, mutating method, +=, or attribute/property set).
155
+ * @param {string} name
156
+ * @returns {RegExp}
157
+ */
158
+ export function mutationPattern(name) {
159
+ const n = escapeRe(name);
160
+ const methods = MUTATING_METHODS.join('|');
161
+ // \bNAME[...] = ... | \bNAME.method( | \bNAME += | \bNAME.attr = ...
162
+ return new RegExp(
163
+ `\\b${n}\\s*\\[[^\\]]*\\]\\s*=(?!=)` +
164
+ `|\\b${n}\\s*\\.\\s*(?:${methods})\\s*\\(` +
165
+ `|\\b${n}\\s*\\+=` +
166
+ `|\\b${n}\\s*\\.\\w+\\s*=(?!=)`,
167
+ );
168
+ }