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.
- package/agents/skills/README.md +308 -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-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 +180 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -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 +196 -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 +129 -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-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 +214 -0
- package/dist/engine/analysis/cli.js +116 -54
- package/dist/engine/analysis/engine.js +34 -16
- package/dist/engine/analysis/reports.js +5 -4
- package/dist/engine/cli-commands.js +252 -106
- package/dist/engine/cli-common.js +15 -24
- package/dist/engine/cli.core.js +37 -11
- package/dist/engine/cli.js +2 -2
- package/dist/engine/company-knowledge-cli.js +2 -2
- package/dist/engine/core/adoption.js +408 -0
- package/dist/engine/core/framework-probes.js +7 -7
- package/dist/engine/core/fs-glob.js +2 -2
- package/dist/engine/core/gate-result.js +17 -0
- package/dist/engine/core/migrator.js +9 -17
- package/dist/engine/core/pattern-matcher.js +23 -5
- package/dist/engine/core/persona.js +421 -0
- package/dist/engine/core/promotion-verdict.js +261 -0
- package/dist/engine/core/quality-scorer.js +15 -2
- package/dist/engine/core/reporter.js +1 -9
- package/dist/engine/core/skill-dispatch.js +115 -0
- package/dist/engine/core/skill-examples.js +392 -0
- package/dist/engine/core/skill-registry.js +59 -4
- package/dist/engine/core/skill-surfaces.js +307 -0
- package/dist/engine/core/static-linter.js +310 -38
- package/dist/engine/core/test-files.js +77 -0
- package/dist/engine/core/ticket-updater.js +1 -7
- package/dist/engine/core/vacuity-scanner.js +726 -0
- package/dist/engine/core/workflow-discovery.js +2 -8
- package/dist/engine/core/workspace-detect.js +7 -6
- package/dist/engine/data/personas/registry.json +36 -0
- package/dist/engine/guardian/adjudication.js +5 -5
- package/dist/engine/guardian/analysis-emit.js +19 -28
- package/dist/engine/guardian/cli.js +150 -51
- package/dist/engine/guardian/coverage.js +1 -1
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
- package/dist/engine/guardian/pr-check.js +322 -33
- package/dist/engine/guardian/pr-comment.js +4 -3
- package/dist/engine/history/cli.js +210 -6
- package/dist/engine/history/ndjson-store.js +9 -5
- package/dist/engine/history/record.js +34 -5
- package/dist/engine/history/run-recorder.js +165 -0
- package/dist/engine/history/schema.js +25 -7
- package/dist/engine/history/store.js +9 -0
- package/dist/engine/mcp-server.js +35 -13
- package/dist/engine/skills-cli.js +249 -23
- package/dist/engine/util/ensure-ascii.js +37 -0
- package/dist/engine/workflow-cli.js +6 -6
- package/dist/gate-result.d.ts +11 -0
- package/dist/gate-result.js +18 -0
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- package/dist/uninstall.js +12 -5
- 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
|
+
}
|