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,374 @@
|
|
|
1
|
+
// Tier-1 static scanner: test sources -> shared-state suspect findings (pure).
|
|
2
|
+
//
|
|
3
|
+
// AST-lite by design -- no test execution, no parser dependency, standard
|
|
4
|
+
// library only -- so it ships wherever node does and runs cheaply on every PR.
|
|
5
|
+
// Two rules (SV001, SV002) need whole-file context, so the scan is two-pass: a
|
|
6
|
+
// file-level pass for those, plus a line pass for the local rules (SV003,
|
|
7
|
+
// SV004). See SKILL.md for the fidelity limits this buys.
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
SEVERITY,
|
|
14
|
+
WHY,
|
|
15
|
+
SV004_CODE_PATTERN,
|
|
16
|
+
SV004_TEXT_PATTERN,
|
|
17
|
+
PYTHON_SETUP_TEARDOWN,
|
|
18
|
+
JS_SETUP_TEARDOWN,
|
|
19
|
+
PY_MODULE_MUTABLE,
|
|
20
|
+
JS_MODULE_MUTABLE,
|
|
21
|
+
mutationPattern,
|
|
22
|
+
} from './rules.mjs';
|
|
23
|
+
import {
|
|
24
|
+
analyzeRestoration,
|
|
25
|
+
classifyMutation,
|
|
26
|
+
isSnapshotWriteBack,
|
|
27
|
+
} from './restoration.mjs';
|
|
28
|
+
import {
|
|
29
|
+
stringLiteralRanges,
|
|
30
|
+
inStringLiteral,
|
|
31
|
+
execOutsideStrings,
|
|
32
|
+
} from './string-literals.mjs';
|
|
33
|
+
|
|
34
|
+
export const SNIPPET_LIMIT = 120;
|
|
35
|
+
|
|
36
|
+
const SUPPORTED_SUFFIXES = [
|
|
37
|
+
'.py',
|
|
38
|
+
'.js',
|
|
39
|
+
'.jsx',
|
|
40
|
+
'.ts',
|
|
41
|
+
'.tsx',
|
|
42
|
+
'.mjs',
|
|
43
|
+
'.cjs',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const SKIP_DIRS = new Set([
|
|
47
|
+
'.git',
|
|
48
|
+
'node_modules',
|
|
49
|
+
'__pycache__',
|
|
50
|
+
'.venv',
|
|
51
|
+
'venv',
|
|
52
|
+
'dist',
|
|
53
|
+
'build',
|
|
54
|
+
'.mypy_cache',
|
|
55
|
+
'.pytest_cache',
|
|
56
|
+
'.tox',
|
|
57
|
+
// Fixture directories are test DATA: files here never RUN as tests, so a
|
|
58
|
+
// temporal/order smell in one is a property of the data, not a defect (#493
|
|
59
|
+
// one level up). Also keeps pragmas out of golden-pinned fixture files.
|
|
60
|
+
'fixtures',
|
|
61
|
+
'__fixtures__',
|
|
62
|
+
'__mocks__',
|
|
63
|
+
'testdata',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const TEST_DIRS = new Set(['tests', 'test', '__tests__', 'e2e', 'spec']);
|
|
67
|
+
|
|
68
|
+
const COMMENT_PREFIXES = ['#', '//', '*', '/*', '"""', "'''"];
|
|
69
|
+
|
|
70
|
+
const splitLines = (text) => text.split(/\r\n|\r|\n/);
|
|
71
|
+
const isComment = (stripped) =>
|
|
72
|
+
COMMENT_PREFIXES.some((p) => stripped.startsWith(p));
|
|
73
|
+
|
|
74
|
+
/** @returns {string[]} the path's components, separator-agnostic. */
|
|
75
|
+
const partsOf = (p) => p.split(/[\\/]/).filter(Boolean);
|
|
76
|
+
|
|
77
|
+
/** True when a path looks like a test file by name or containing directory. */
|
|
78
|
+
function isTestFile(filePath) {
|
|
79
|
+
const suffix = path.extname(filePath);
|
|
80
|
+
if (!SUPPORTED_SUFFIXES.includes(suffix)) return false;
|
|
81
|
+
const name = path.basename(filePath);
|
|
82
|
+
const stem = name.slice(0, name.length - suffix.length);
|
|
83
|
+
if (name.includes('.test.') || name.includes('.spec.')) return true;
|
|
84
|
+
if (stem.startsWith('test_') || stem.endsWith('_test')) return true;
|
|
85
|
+
const dirs = partsOf(filePath).slice(0, -1);
|
|
86
|
+
return dirs.some((part) => TEST_DIRS.has(part));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function makeFinding(file, line, ruleId, snippet) {
|
|
90
|
+
return {
|
|
91
|
+
file,
|
|
92
|
+
line,
|
|
93
|
+
ruleId,
|
|
94
|
+
severity: SEVERITY[ruleId],
|
|
95
|
+
snippet: snippet.slice(0, SNIPPET_LIMIT),
|
|
96
|
+
why: WHY[ruleId],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Convert an internal finding to its JSON-contract shape (snake_case id). */
|
|
101
|
+
export function toJson(f) {
|
|
102
|
+
return {
|
|
103
|
+
file: f.file,
|
|
104
|
+
line: f.line,
|
|
105
|
+
rule_id: f.ruleId,
|
|
106
|
+
severity: f.severity,
|
|
107
|
+
snippet: f.snippet,
|
|
108
|
+
why: f.why,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Module-level mutable declarations that some line later mutates in place. */
|
|
113
|
+
function sv001ModuleMutables(lines, file, isPy, text) {
|
|
114
|
+
const declRe = isPy ? PY_MODULE_MUTABLE : JS_MODULE_MUTABLE;
|
|
115
|
+
const findings = [];
|
|
116
|
+
lines.forEach((raw, i) => {
|
|
117
|
+
// Module scope == column 0 (unindented). A mutable declared inside a
|
|
118
|
+
// function is local and cannot leak between tests.
|
|
119
|
+
if (/^\s/.test(raw)) return;
|
|
120
|
+
const match = declRe.exec(raw.trim());
|
|
121
|
+
if (!match) return;
|
|
122
|
+
const name = match[1];
|
|
123
|
+
if (mutationPattern(name).test(text)) {
|
|
124
|
+
findings.push(
|
|
125
|
+
makeFinding(file, i + 1, 'SV001-module-mutable-global', raw.trim()),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
return findings;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Line comment openers, for the code-only projection below. A whole-line
|
|
133
|
+
// comment is caught earlier by isComment (which also covers block-comment
|
|
134
|
+
// continuations and Python docstring fences).
|
|
135
|
+
const COMMENT_OPENERS = ['//', '/*', '#'];
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The line with comments dropped and string-literal CONTENT blanked, so a
|
|
139
|
+
* token found in the result is code rather than prose or data.
|
|
140
|
+
*
|
|
141
|
+
* #732: SV002 asked `text.includes(teardown)` of the RAW file while its setup
|
|
142
|
+
* half already skipped comments, so any file that merely MENTIONED the
|
|
143
|
+
* teardown token exempted itself -- invisibly, because a finding that is never
|
|
144
|
+
* generated never appears in the `N suppressed` line either. Blanking rather
|
|
145
|
+
* than deleting preserves column positions for callers that keep ranges.
|
|
146
|
+
*
|
|
147
|
+
* Erring here means erring toward FIRING (a token wrongly read as prose costs
|
|
148
|
+
* a false flag, which restoration.mjs's header calls the safe direction),
|
|
149
|
+
* never toward the silent exemption this replaces.
|
|
150
|
+
*/
|
|
151
|
+
function codeOnly(line) {
|
|
152
|
+
if (isComment(line.trim())) return '';
|
|
153
|
+
const ranges = stringLiteralRanges(line);
|
|
154
|
+
let out = '';
|
|
155
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
156
|
+
if (inStringLiteral(ranges, i)) {
|
|
157
|
+
out += ' ';
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
// Rest of the line is a trailing comment.
|
|
161
|
+
if (COMMENT_OPENERS.some((c) => line.startsWith(c, i))) break;
|
|
162
|
+
out += line[i];
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Setup markers whose matching teardown is absent from the file. */
|
|
168
|
+
function sv002MissingTeardown(lines, file, isPy) {
|
|
169
|
+
const pairs = isPy ? PYTHON_SETUP_TEARDOWN : JS_SETUP_TEARDOWN;
|
|
170
|
+
// #732: pair against code only. Both halves read the same projection, so
|
|
171
|
+
// the rule can no longer be switched off by a comment or a fixture string.
|
|
172
|
+
const codeLines = lines.map(codeOnly);
|
|
173
|
+
const codeText = codeLines.join('\n');
|
|
174
|
+
const findings = [];
|
|
175
|
+
for (const [setup, teardown] of pairs) {
|
|
176
|
+
if (codeText.includes(teardown)) continue;
|
|
177
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
178
|
+
const code = codeLines[i].trim();
|
|
179
|
+
if (!code) continue;
|
|
180
|
+
const hit = isPy
|
|
181
|
+
? code.includes(`def ${setup}`)
|
|
182
|
+
: code.startsWith(`${setup}(`) || code.includes(` ${setup}(`);
|
|
183
|
+
if (hit) {
|
|
184
|
+
const stripped = lines[i].trim();
|
|
185
|
+
findings.push(
|
|
186
|
+
makeFinding(file, i + 1, 'SV002-missing-teardown', stripped),
|
|
187
|
+
);
|
|
188
|
+
break; // one finding per unmatched setup marker
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return findings;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Inline suppression pragma (#496): `savant-ignore <RULE>[,<RULE>] -- reason`
|
|
196
|
+
// in a comment, matching canary-blackhawk's `blackhawk-ignore` (#393) so a
|
|
197
|
+
// user moving between the two skills learns one dialect. Rule-scoped (so it
|
|
198
|
+
// never blanket-silences a line) and the reason is required (keeps
|
|
199
|
+
// suppressions honest and greppable). A pragma covers the finding on its own
|
|
200
|
+
// line (trailing comment) and the next line (comment above the code) - the
|
|
201
|
+
// two idioms teams reach for. One deliberate divergence from blackhawk: the
|
|
202
|
+
// anchor is string-literal guarded (#493 style), because savant's own suite
|
|
203
|
+
// carries pragma text inside fixture strings and data must never act as a
|
|
204
|
+
// directive.
|
|
205
|
+
const PRAGMA = /\bsavant-ignore\s+([A-Za-z0-9,\s-]*?)\s*--\s*(\S.*)$/;
|
|
206
|
+
|
|
207
|
+
function parsePragmas(lines) {
|
|
208
|
+
const map = new Map();
|
|
209
|
+
const add = (ln, tokens) => {
|
|
210
|
+
if (!map.has(ln)) map.set(ln, new Set());
|
|
211
|
+
for (const t of tokens) map.get(ln).add(t);
|
|
212
|
+
};
|
|
213
|
+
lines.forEach((raw, i) => {
|
|
214
|
+
const m = execOutsideStrings(PRAGMA, raw, stringLiteralRanges(raw));
|
|
215
|
+
if (!m || !m[2].trim()) return; // reason required
|
|
216
|
+
const tokens = m[1].split(/[,\s]+/).filter(Boolean);
|
|
217
|
+
if (!tokens.length) return; // rule-scoped: must name a rule
|
|
218
|
+
add(i + 1, tokens); // same-line (trailing pragma)
|
|
219
|
+
add(i + 2, tokens); // next line (pragma above the code)
|
|
220
|
+
});
|
|
221
|
+
return map;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// An `SV003` token matches `SV003-shared-singleton-mutation`; the full id
|
|
225
|
+
// also matches.
|
|
226
|
+
const tokenMatches = (ruleId, token) =>
|
|
227
|
+
ruleId === token || ruleId.split('-')[0] === token;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @typedef {{file: string, line: number, ruleId: string, severity: string,
|
|
231
|
+
* snippet: string, why: string}} Finding
|
|
232
|
+
*/
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Scan source text. Returns kept `findings` plus `suppressed` findings
|
|
236
|
+
* silenced by an inline pragma, both ordered by line then rule id.
|
|
237
|
+
* @returns {{findings: Finding[], suppressed: Finding[]}}
|
|
238
|
+
*/
|
|
239
|
+
export function scanTextFull(text, file = '<text>') {
|
|
240
|
+
const isPy = file.endsWith('.py');
|
|
241
|
+
const lines = splitLines(text);
|
|
242
|
+
const findings = [];
|
|
243
|
+
// #493 root cause 2: SV003's why asserts persistence, so a file that
|
|
244
|
+
// restores the global (teardown restore or snapshot write-back) must not
|
|
245
|
+
// be flagged. Computed once per file.
|
|
246
|
+
const restoration = analyzeRestoration(text);
|
|
247
|
+
|
|
248
|
+
findings.push(...sv001ModuleMutables(lines, file, isPy, text));
|
|
249
|
+
findings.push(...sv002MissingTeardown(lines, file, isPy));
|
|
250
|
+
|
|
251
|
+
lines.forEach((raw, i) => {
|
|
252
|
+
const stripped = raw.trim();
|
|
253
|
+
if (!stripped) return;
|
|
254
|
+
// #493: a match starting inside a string literal is fixture data, not
|
|
255
|
+
// code. SV003 and SV004's code-anchored alternatives reject those; the
|
|
256
|
+
// SV004 text alternatives stay unfiltered because their signal (titles,
|
|
257
|
+
// docstrings, comments) legitimately lives inside strings.
|
|
258
|
+
const ranges = stringLiteralRanges(stripped);
|
|
259
|
+
// SV004 is self-reported ordering: it fires on comments and code alike.
|
|
260
|
+
if (
|
|
261
|
+
execOutsideStrings(SV004_CODE_PATTERN, stripped, ranges) ||
|
|
262
|
+
SV004_TEXT_PATTERN.test(stripped)
|
|
263
|
+
) {
|
|
264
|
+
findings.push(
|
|
265
|
+
makeFinding(file, i + 1, 'SV004-order-coupled-name', stripped),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (isComment(stripped)) return;
|
|
269
|
+
const mutation = classifyMutation(stripped, ranges);
|
|
270
|
+
if (mutation) {
|
|
271
|
+
const restored =
|
|
272
|
+
isSnapshotWriteBack(mutation, lines) ||
|
|
273
|
+
restoration.restores(mutation.family, mutation.key);
|
|
274
|
+
if (!restored) {
|
|
275
|
+
findings.push(
|
|
276
|
+
makeFinding(file, i + 1, 'SV003-shared-singleton-mutation', stripped),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
findings.sort((a, b) => a.line - b.line || a.ruleId.localeCompare(b.ruleId));
|
|
283
|
+
|
|
284
|
+
// Partition after the sort (stable), so both arrays stay line-ordered.
|
|
285
|
+
const pragmas = parsePragmas(lines);
|
|
286
|
+
const kept = [];
|
|
287
|
+
const suppressed = [];
|
|
288
|
+
for (const f of findings) {
|
|
289
|
+
const tokens = pragmas.get(f.line);
|
|
290
|
+
if (tokens && [...tokens].some((t) => tokenMatches(f.ruleId, t))) {
|
|
291
|
+
suppressed.push(f);
|
|
292
|
+
} else {
|
|
293
|
+
kept.push(f);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return { findings: kept, suppressed };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Scan source text, returning kept findings (back-compat wrapper). */
|
|
300
|
+
export function scanText(text, file = '<text>') {
|
|
301
|
+
return scanTextFull(text, file).findings;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Scan one file. Unreadable files yield nothing. */
|
|
305
|
+
function scanFileFull(filePath) {
|
|
306
|
+
let text;
|
|
307
|
+
try {
|
|
308
|
+
text = fs.readFileSync(filePath, 'utf8');
|
|
309
|
+
} catch {
|
|
310
|
+
return { findings: [], suppressed: [] };
|
|
311
|
+
}
|
|
312
|
+
return scanTextFull(text, filePath);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Yield the files a path contributes: explicit files win, dirs are filtered. */
|
|
316
|
+
function* iterFiles(root) {
|
|
317
|
+
let stat;
|
|
318
|
+
try {
|
|
319
|
+
stat = fs.statSync(root);
|
|
320
|
+
} catch {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (stat.isFile()) {
|
|
324
|
+
if (SUPPORTED_SUFFIXES.includes(path.extname(root))) yield root;
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const collected = [];
|
|
328
|
+
const walk = (dir) => {
|
|
329
|
+
let entries;
|
|
330
|
+
try {
|
|
331
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
332
|
+
} catch {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
for (const entry of entries) {
|
|
336
|
+
const full = path.join(dir, entry.name);
|
|
337
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
338
|
+
if (entry.isDirectory()) walk(full);
|
|
339
|
+
else if (entry.isFile()) collected.push(full);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
walk(root);
|
|
343
|
+
collected.sort();
|
|
344
|
+
for (const f of collected) {
|
|
345
|
+
if (partsOf(f).some((part) => SKIP_DIRS.has(part))) continue;
|
|
346
|
+
if (isTestFile(f)) yield f;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Scan every given file/directory, de-duplicating overlapping paths. */
|
|
351
|
+
export function scanPaths(paths) {
|
|
352
|
+
const seen = new Set();
|
|
353
|
+
const findings = [];
|
|
354
|
+
let scanned = 0;
|
|
355
|
+
let suppressed = 0;
|
|
356
|
+
for (const entry of paths) {
|
|
357
|
+
for (const filePath of iterFiles(entry)) {
|
|
358
|
+
const resolved = path.resolve(filePath);
|
|
359
|
+
if (seen.has(resolved)) continue;
|
|
360
|
+
seen.add(resolved);
|
|
361
|
+
scanned += 1;
|
|
362
|
+
const r = scanFileFull(filePath);
|
|
363
|
+
findings.push(...r.findings);
|
|
364
|
+
suppressed += r.suppressed.length;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
findings.sort(
|
|
368
|
+
(a, b) =>
|
|
369
|
+
a.file.localeCompare(b.file) ||
|
|
370
|
+
a.line - b.line ||
|
|
371
|
+
a.ruleId.localeCompare(b.ruleId),
|
|
372
|
+
);
|
|
373
|
+
return { findings, filesScanned: scanned, suppressed };
|
|
374
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// String-literal ranges for a single source line (pure, stdlib-only). #493.
|
|
2
|
+
//
|
|
3
|
+
// Both canary-blackhawk and canary-savant regex over raw lines, so without
|
|
4
|
+
// this they flag their own test fixtures: `pyFile('time.sleep(1)')` is data,
|
|
5
|
+
// not a call. The correction is deliberately narrow -- a match is rejected
|
|
6
|
+
// only when its START index falls inside a string literal. Stripping string
|
|
7
|
+
// contents before matching would be wrong: blackhawk's BH003 pattern matches
|
|
8
|
+
// `strftime('..%Z')` with the `%Z` inside the quotes ON PURPOSE; the anchor
|
|
9
|
+
// token (`strftime`, `time.sleep`, `Date.now`, ...) is what separates code
|
|
10
|
+
// from data.
|
|
11
|
+
//
|
|
12
|
+
// This file is intentionally duplicated verbatim in canary-blackhawk and
|
|
13
|
+
// canary-savant: skills are self-contained by contract (their packaging
|
|
14
|
+
// suites forbid cross-imports), and #479 tracks extracting shared skill
|
|
15
|
+
// infrastructure. A parity test pins the two copies byte-identical.
|
|
16
|
+
//
|
|
17
|
+
// Fidelity limits (line-based scanner, no parser):
|
|
18
|
+
// - Handles '...', "...", and `...` template literals. `${...}` interpolation
|
|
19
|
+
// regions are CODE (a nested-frame scan, so `${`x`}` and `${fn({a:1})}`
|
|
20
|
+
// work); backslash escapes are respected; a quote of the other kind inside
|
|
21
|
+
// a string is content.
|
|
22
|
+
// - An unterminated quote marks the REST OF THE LINE as string. That is the
|
|
23
|
+
// safe default for multi-line Python strings whose opener ends mid-line,
|
|
24
|
+
// and for apostrophes in trailing comments: this helper only ever REJECTS
|
|
25
|
+
// matches, so the worst case is a suppressed match inside what was really
|
|
26
|
+
// string-ish text -- never a new false positive.
|
|
27
|
+
// - Strings spanning lines (template literals, triple quotes) are only seen
|
|
28
|
+
// on their opening line; continuation lines look like code. Accepted: the
|
|
29
|
+
// scanners are line-based by design.
|
|
30
|
+
// - Regex literals containing quotes (/['"]/) can open a phantom string for
|
|
31
|
+
// the rest of the line. Same rejection-only safety argument applies.
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Compute the [start, end) index ranges of string-literal CONTENT in `line`
|
|
35
|
+
* (quote characters excluded; empty literals contribute no range).
|
|
36
|
+
* @param {string} line
|
|
37
|
+
* @returns {Array<[number, number]>}
|
|
38
|
+
*/
|
|
39
|
+
export function stringLiteralRanges(line) {
|
|
40
|
+
/** @type {Array<[number, number]>} */
|
|
41
|
+
const ranges = [];
|
|
42
|
+
// Frames: {quote, start} while inside a string; {interp: true, depth}
|
|
43
|
+
// while inside a template's ${...} (which is code and may nest strings).
|
|
44
|
+
const stack = [];
|
|
45
|
+
const top = () => stack[stack.length - 1];
|
|
46
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
47
|
+
const ch = line[i];
|
|
48
|
+
const frame = top();
|
|
49
|
+
if (frame && frame.quote) {
|
|
50
|
+
if (ch === '\\') {
|
|
51
|
+
i += 1; // escaped char is content, never a closer
|
|
52
|
+
} else if (ch === frame.quote) {
|
|
53
|
+
ranges.push([frame.start, i]);
|
|
54
|
+
stack.pop();
|
|
55
|
+
} else if (frame.quote === '`' && ch === '$' && line[i + 1] === '{') {
|
|
56
|
+
// Interpolation is code: close the string segment before `${`.
|
|
57
|
+
ranges.push([frame.start, i]);
|
|
58
|
+
stack.push({ interp: true, depth: 0 });
|
|
59
|
+
i += 1;
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// Code context: top-level, or inside `${ ... }`.
|
|
64
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
65
|
+
stack.push({ quote: ch, start: i + 1 });
|
|
66
|
+
} else if (frame && frame.interp) {
|
|
67
|
+
if (ch === '{') {
|
|
68
|
+
frame.depth += 1;
|
|
69
|
+
} else if (ch === '}') {
|
|
70
|
+
if (frame.depth === 0) {
|
|
71
|
+
stack.pop();
|
|
72
|
+
top().start = i + 1; // the enclosing template resumes here
|
|
73
|
+
} else {
|
|
74
|
+
frame.depth -= 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Unterminated string: treat the rest of the line as string (see header).
|
|
80
|
+
// An open interpolation frame is code and stays unmarked.
|
|
81
|
+
const frame = top();
|
|
82
|
+
if (frame && frame.quote) ranges.push([frame.start, line.length]);
|
|
83
|
+
return ranges.filter(([start, end]) => end > start);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* True when `index` falls inside any of the given content ranges.
|
|
88
|
+
* @param {Array<[number, number]>} ranges
|
|
89
|
+
* @param {number} index
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
export function inStringLiteral(ranges, index) {
|
|
93
|
+
return ranges.some(([start, end]) => index >= start && index < end);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Like `pattern.exec(line)`, but skips matches whose start index falls
|
|
98
|
+
* inside a string literal, returning the first CODE match (or null).
|
|
99
|
+
* @param {RegExp} pattern
|
|
100
|
+
* @param {string} line
|
|
101
|
+
* @param {Array<[number, number]>} ranges precomputed for `line`
|
|
102
|
+
* @returns {RegExpExecArray | null}
|
|
103
|
+
*/
|
|
104
|
+
export function execOutsideStrings(pattern, line, ranges) {
|
|
105
|
+
if (ranges.length === 0) return pattern.exec(line);
|
|
106
|
+
const flags = pattern.flags.includes('g')
|
|
107
|
+
? pattern.flags
|
|
108
|
+
: `${pattern.flags}g`;
|
|
109
|
+
const re = new RegExp(pattern.source, flags);
|
|
110
|
+
let match;
|
|
111
|
+
while ((match = re.exec(line)) !== null) {
|
|
112
|
+
if (!inStringLiteral(ranges, match.index)) return match;
|
|
113
|
+
if (re.lastIndex === match.index) re.lastIndex += 1; // zero-width guard
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|