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.
- package/agents/skills/README.md +327 -0
- package/agents/skills/canary:generate.md +49 -0
- package/agents/skills/canary:init.md +37 -0
- package/agents/skills/canary:migrate.md +66 -0
- package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
- package/agents/skills/claude-code/canary-batwoman/SKILL.md +119 -0
- package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-cassandra/SKILL.md +187 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +270 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +178 -0
- package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
- package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
- package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
- package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
- package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
- package/agents/skills/claude-code/canary-fleet-health/SKILL.md +197 -0
- package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
- package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
- package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
- package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
- package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
- package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
- package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
- package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
- package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
- package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
- package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +144 -0
- package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
- package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
- package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
- package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
- package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
- package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
- package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-screech/SKILL.md +109 -0
- package/agents/skills/claude-code/canary-screech/scripts/blast.mjs +125 -0
- package/agents/skills/claude-code/canary-screech/scripts/cli.mjs +128 -0
- package/agents/skills/claude-code/canary-screech/scripts/cluster.mjs +97 -0
- package/agents/skills/claude-code/canary-screech/scripts/history.mjs +73 -0
- package/agents/skills/claude-code/canary-screech/scripts/redness.mjs +94 -0
- package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
- package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
- package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
- package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
- package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
- package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
- package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
- package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
- package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
- package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
- package/agents/skills/lib/parse-args.mjs +275 -0
- package/dist/engine/analysis/batwoman/audit.js +39 -0
- package/dist/engine/analysis/batwoman/closure.js +159 -0
- package/dist/engine/analysis/batwoman/gh-history.js +119 -0
- package/dist/engine/analysis/batwoman/probes.js +195 -0
- package/dist/engine/analysis/batwoman/registry.js +142 -0
- package/dist/engine/analysis/batwoman/render.js +194 -0
- package/dist/engine/analysis/batwoman/run-window.js +122 -0
- package/dist/engine/analysis/batwoman/text.js +84 -0
- package/dist/engine/analysis/batwoman/triggers.js +122 -0
- package/dist/engine/analysis/batwoman/verdict.js +64 -0
- package/dist/engine/analysis/cli.js +47 -14
- package/dist/engine/analysis/gh-flaky/gh-run-attempts.js +206 -0
- package/dist/engine/batwoman-cli.js +119 -0
- package/dist/engine/ci-ready-cli.js +71 -0
- package/dist/engine/cli-commands.js +49 -72
- package/dist/engine/cli.core.js +16 -0
- package/dist/engine/company-knowledge-cli.js +10 -2
- package/dist/engine/core/ci-ready.js +112 -0
- package/dist/engine/core/company-knowledge.js +8 -0
- package/dist/engine/core/migrator.js +147 -20
- package/dist/engine/core/permission-matrix.js +219 -0
- package/dist/engine/core/quality-scorer.js +27 -19
- package/dist/engine/core/scaling-curve.js +143 -0
- package/dist/engine/core/skill-dispatch.js +115 -0
- package/dist/engine/core/skill-examples.js +103 -3
- package/dist/engine/core/skill-registry.js +59 -4
- package/dist/engine/core/string-literals.js +3 -1
- package/dist/engine/core/test-files.js +77 -0
- package/dist/engine/core/vacuity-scanner.js +330 -15
- package/dist/engine/core/workflow-discovery.js +41 -23
- package/dist/engine/guardian/adjudication-github.js +136 -0
- package/dist/engine/guardian/adjudication.js +119 -340
- package/dist/engine/guardian/analysis-emit.js +7 -2
- package/dist/engine/guardian/cli.js +277 -249
- package/dist/engine/guardian/coverage.js +2 -1
- package/dist/engine/guardian/diff-coverage/coverage-delta.js +162 -0
- package/dist/engine/guardian/diff-coverage/formats/cobertura.js +45 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +25 -21
- package/dist/engine/guardian/diff-coverage/paths.js +5 -9
- package/dist/engine/guardian/diff-coverage/report-tier.js +88 -12
- package/dist/engine/guardian/diff-extractor.js +31 -32
- package/dist/engine/guardian/pr-check.js +354 -223
- package/dist/engine/guardian/pr-comment.js +35 -58
- package/dist/engine/guardian/weak-test.js +236 -0
- package/dist/engine/mcp-server.js +67 -4
- package/dist/engine/permission-matrix-cli.js +51 -0
- package/dist/engine/scaling-curve-cli.js +147 -0
- package/dist/engine/skills-cli.js +171 -51
- package/dist/engine/workflow-cli.js +85 -65
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- 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
|
+
}
|