canary-test-cli 7.0.0 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/agents/skills/README.md +308 -0
  2. package/agents/skills/canary:generate.md +49 -0
  3. package/agents/skills/canary:init.md +37 -0
  4. package/agents/skills/canary:migrate.md +66 -0
  5. package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
  6. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  7. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  11. package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
  12. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  14. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
  15. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  16. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  17. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  18. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  19. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  21. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  22. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  27. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  28. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  29. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
  30. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  31. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  32. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  37. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  38. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  42. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  44. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  45. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  46. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  52. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  53. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  54. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  55. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  56. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  57. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  58. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  59. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  60. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  61. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  62. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  63. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  64. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  65. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  66. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  67. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  68. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  69. package/agents/skills/lib/parse-args.mjs +214 -0
  70. package/dist/engine/analysis/cli.js +116 -54
  71. package/dist/engine/analysis/engine.js +34 -16
  72. package/dist/engine/analysis/reports.js +5 -4
  73. package/dist/engine/cli-commands.js +252 -106
  74. package/dist/engine/cli-common.js +15 -24
  75. package/dist/engine/cli.core.js +37 -11
  76. package/dist/engine/cli.js +2 -2
  77. package/dist/engine/company-knowledge-cli.js +2 -2
  78. package/dist/engine/core/adoption.js +408 -0
  79. package/dist/engine/core/framework-probes.js +7 -7
  80. package/dist/engine/core/fs-glob.js +2 -2
  81. package/dist/engine/core/gate-result.js +17 -0
  82. package/dist/engine/core/migrator.js +9 -17
  83. package/dist/engine/core/pattern-matcher.js +23 -5
  84. package/dist/engine/core/persona.js +421 -0
  85. package/dist/engine/core/promotion-verdict.js +261 -0
  86. package/dist/engine/core/quality-scorer.js +15 -2
  87. package/dist/engine/core/reporter.js +1 -9
  88. package/dist/engine/core/skill-dispatch.js +115 -0
  89. package/dist/engine/core/skill-examples.js +392 -0
  90. package/dist/engine/core/skill-registry.js +59 -4
  91. package/dist/engine/core/skill-surfaces.js +307 -0
  92. package/dist/engine/core/static-linter.js +310 -38
  93. package/dist/engine/core/test-files.js +77 -0
  94. package/dist/engine/core/ticket-updater.js +1 -7
  95. package/dist/engine/core/vacuity-scanner.js +726 -0
  96. package/dist/engine/core/workflow-discovery.js +2 -8
  97. package/dist/engine/core/workspace-detect.js +7 -6
  98. package/dist/engine/data/personas/registry.json +36 -0
  99. package/dist/engine/guardian/adjudication.js +5 -5
  100. package/dist/engine/guardian/analysis-emit.js +19 -28
  101. package/dist/engine/guardian/cli.js +150 -51
  102. package/dist/engine/guardian/coverage.js +1 -1
  103. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
  104. package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
  105. package/dist/engine/guardian/pr-check.js +322 -33
  106. package/dist/engine/guardian/pr-comment.js +4 -3
  107. package/dist/engine/history/cli.js +210 -6
  108. package/dist/engine/history/ndjson-store.js +9 -5
  109. package/dist/engine/history/record.js +34 -5
  110. package/dist/engine/history/run-recorder.js +165 -0
  111. package/dist/engine/history/schema.js +25 -7
  112. package/dist/engine/history/store.js +9 -0
  113. package/dist/engine/mcp-server.js +35 -13
  114. package/dist/engine/skills-cli.js +249 -23
  115. package/dist/engine/util/ensure-ascii.js +37 -0
  116. package/dist/engine/workflow-cli.js +6 -6
  117. package/dist/gate-result.d.ts +11 -0
  118. package/dist/gate-result.js +18 -0
  119. package/dist/reporters/testtracker.d.ts +1 -1
  120. package/dist/reporters/testtracker.js +1 -1
  121. package/dist/uninstall.js +12 -5
  122. package/package.json +3 -2
@@ -0,0 +1,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
+ }