canary-test-cli 7.1.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/agents/skills/README.md +327 -0
  2. package/agents/skills/canary:generate.md +49 -0
  3. package/agents/skills/canary:init.md +37 -0
  4. package/agents/skills/canary:migrate.md +66 -0
  5. package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
  6. package/agents/skills/claude-code/canary-batwoman/SKILL.md +119 -0
  7. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  11. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  12. package/agents/skills/claude-code/canary-cassandra/SKILL.md +187 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +270 -0
  14. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  15. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +178 -0
  16. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  17. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  18. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  19. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  21. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  22. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  27. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  28. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  29. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  30. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +197 -0
  31. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  32. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  37. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  38. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  42. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +144 -0
  44. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  45. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  46. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  52. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  53. package/agents/skills/claude-code/canary-screech/SKILL.md +109 -0
  54. package/agents/skills/claude-code/canary-screech/scripts/blast.mjs +125 -0
  55. package/agents/skills/claude-code/canary-screech/scripts/cli.mjs +128 -0
  56. package/agents/skills/claude-code/canary-screech/scripts/cluster.mjs +97 -0
  57. package/agents/skills/claude-code/canary-screech/scripts/history.mjs +73 -0
  58. package/agents/skills/claude-code/canary-screech/scripts/redness.mjs +94 -0
  59. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  60. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  61. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  62. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  63. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  64. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  65. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  66. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  67. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  68. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  69. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  70. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  71. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  72. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  73. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  74. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  75. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  76. package/agents/skills/lib/parse-args.mjs +275 -0
  77. package/dist/engine/analysis/batwoman/audit.js +39 -0
  78. package/dist/engine/analysis/batwoman/closure.js +159 -0
  79. package/dist/engine/analysis/batwoman/gh-history.js +119 -0
  80. package/dist/engine/analysis/batwoman/probes.js +195 -0
  81. package/dist/engine/analysis/batwoman/registry.js +142 -0
  82. package/dist/engine/analysis/batwoman/render.js +194 -0
  83. package/dist/engine/analysis/batwoman/run-window.js +122 -0
  84. package/dist/engine/analysis/batwoman/text.js +84 -0
  85. package/dist/engine/analysis/batwoman/triggers.js +122 -0
  86. package/dist/engine/analysis/batwoman/verdict.js +64 -0
  87. package/dist/engine/analysis/cli.js +47 -14
  88. package/dist/engine/analysis/gh-flaky/gh-run-attempts.js +206 -0
  89. package/dist/engine/batwoman-cli.js +119 -0
  90. package/dist/engine/ci-ready-cli.js +71 -0
  91. package/dist/engine/cli-commands.js +49 -72
  92. package/dist/engine/cli.core.js +16 -0
  93. package/dist/engine/company-knowledge-cli.js +10 -2
  94. package/dist/engine/core/ci-ready.js +112 -0
  95. package/dist/engine/core/company-knowledge.js +8 -0
  96. package/dist/engine/core/migrator.js +147 -20
  97. package/dist/engine/core/permission-matrix.js +219 -0
  98. package/dist/engine/core/quality-scorer.js +27 -19
  99. package/dist/engine/core/scaling-curve.js +143 -0
  100. package/dist/engine/core/skill-dispatch.js +115 -0
  101. package/dist/engine/core/skill-examples.js +103 -3
  102. package/dist/engine/core/skill-registry.js +59 -4
  103. package/dist/engine/core/string-literals.js +3 -1
  104. package/dist/engine/core/test-files.js +77 -0
  105. package/dist/engine/core/vacuity-scanner.js +330 -15
  106. package/dist/engine/core/workflow-discovery.js +41 -23
  107. package/dist/engine/guardian/adjudication-github.js +136 -0
  108. package/dist/engine/guardian/adjudication.js +119 -340
  109. package/dist/engine/guardian/analysis-emit.js +7 -2
  110. package/dist/engine/guardian/cli.js +277 -249
  111. package/dist/engine/guardian/coverage.js +2 -1
  112. package/dist/engine/guardian/diff-coverage/coverage-delta.js +162 -0
  113. package/dist/engine/guardian/diff-coverage/formats/cobertura.js +45 -1
  114. package/dist/engine/guardian/diff-coverage/orchestrator.js +25 -21
  115. package/dist/engine/guardian/diff-coverage/paths.js +5 -9
  116. package/dist/engine/guardian/diff-coverage/report-tier.js +88 -12
  117. package/dist/engine/guardian/diff-extractor.js +31 -32
  118. package/dist/engine/guardian/pr-check.js +354 -223
  119. package/dist/engine/guardian/pr-comment.js +35 -58
  120. package/dist/engine/guardian/weak-test.js +236 -0
  121. package/dist/engine/mcp-server.js +67 -4
  122. package/dist/engine/permission-matrix-cli.js +51 -0
  123. package/dist/engine/scaling-curve-cli.js +147 -0
  124. package/dist/engine/skills-cli.js +171 -51
  125. package/dist/engine/workflow-cli.js +85 -65
  126. package/dist/reporters/testtracker.d.ts +1 -1
  127. package/dist/reporters/testtracker.js +1 -1
  128. package/package.json +3 -2
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ // canary-blackhawk -- temporal-dependency linter for test files.
3
+ //
4
+ // Statically flags tests that depend on wall-clock time, a real delay, or the
5
+ // local timezone: the ones that pass all day and fail at midnight, across a DST
6
+ // boundary, or on Feb 29.
7
+ //
8
+ // <paths> files or directories to scan (default: the current directory).
9
+ // --json emit machine-readable findings instead of human text.
10
+ // --strict exit 1 when there are findings (default is advisory: exit 0).
11
+ //
12
+ // Tier-0 deterministic analysis -- no LLM, no network, no secrets, no
13
+ // dependency on any other skill.
14
+ //
15
+ // Invoked via `canary skills run canary-blackhawk -- [paths] [--json] [--strict]`.
16
+
17
+ import fs from 'node:fs';
18
+ import { scanPaths, toJson } from './scanner.mjs';
19
+ import { RULES } from './rules.mjs';
20
+ import {
21
+ createParser,
22
+ formatUsageError,
23
+ EXIT_USAGE,
24
+ } from '../../../lib/parse-args.mjs';
25
+
26
+ export const SCHEMA_VERSION = 1;
27
+
28
+ const PREFIX = 'canary-blackhawk:';
29
+
30
+ // --- no-silent-abstention (#508 D2, skill-CLI convention half) ---------------
31
+ //
32
+ // Skill CLIs are deliberately self-contained -- no engine import, no shared
33
+ // module -- so they cannot call `gateOutcome`. They honour the doctrine by
34
+ // CONVENTION instead, emitting the same greppable line the engine helper does.
35
+ // The skill-layer conformance registry (agents/skills/test/gate-conformance.
36
+ // test.ts) is what holds them to it: a row whose fixture collapses the
37
+ // denominator and asserts the loud outcome.
38
+ //
39
+ // U+26A0 / U+2014 are written as escapes so this source stays ASCII, matching
40
+ // ts/src/core/gate-result.ts.
41
+ const ABSTAINED_LINE =
42
+ '\u{26A0} Abstained \u{2014} verified zero items; this is not a pass.';
43
+
44
+ // The rules block is GENERATED from RULES, never hand-typed: a new rule shows
45
+ // up in --help the moment it is registered, so the help text cannot drift
46
+ // behind the linter as rules are added.
47
+ const USAGE =
48
+ 'usage: canary-blackhawk [-h] [--json] [--strict] [--] [path ...]\n' +
49
+ '\n' +
50
+ 'Temporal-dependency linter for test files: flags tests that depend on the\n' +
51
+ 'wall clock, a real delay, or the local timezone.\n' +
52
+ '\n' +
53
+ 'positional arguments:\n' +
54
+ ' path files or directories to scan (default: the current directory)\n' +
55
+ '\n' +
56
+ 'options:\n' +
57
+ ' -h, --help show this help message and exit\n' +
58
+ ' --json emit machine-readable findings instead of human text\n' +
59
+ ' --strict exit 1 when there are findings (default is advisory: exit 0)\n' +
60
+ '\n' +
61
+ 'rules:\n' +
62
+ RULES.map((r) => ` ${r.ruleId} (${r.severity})`).join('\n');
63
+
64
+ function summary(result) {
65
+ const bySeverity = {};
66
+ for (const f of result.findings) {
67
+ bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
68
+ }
69
+ return {
70
+ files_scanned: result.filesScanned,
71
+ findings: result.findings.length,
72
+ by_severity: bySeverity,
73
+ suppressed: result.suppressed ?? 0,
74
+ };
75
+ }
76
+
77
+ // A trailing "N suppressed" note keeps inline-ignored lines visible but out of
78
+ // the actionable total - the pattern the PR-guardian sticky comment uses.
79
+ function suppressedNote(result) {
80
+ const n = result.suppressed ?? 0;
81
+ return n ? `\n${n} suppressed (inline blackhawk-ignore).` : '';
82
+ }
83
+
84
+ function renderText(result) {
85
+ const count = result.findings.length;
86
+ const files = result.filesScanned;
87
+ const fp = files === 1 ? '' : 's';
88
+ // #508: zero findings over zero scanned files is an ABSENT result, not a
89
+ // clean one. Findings outrank abstention (a finding proves a file was read),
90
+ // so this is checked only on the no-findings path.
91
+ if (!count && !files) {
92
+ return (
93
+ `${ABSTAINED_LINE} No file matched the given paths, so there is ` +
94
+ 'nothing to report. Point at a directory that holds test files, or ' +
95
+ 'pass a file directly.'
96
+ );
97
+ }
98
+ if (!count) {
99
+ return (
100
+ `No temporal-dependency findings (${files} file${fp} scanned).` +
101
+ suppressedNote(result)
102
+ );
103
+ }
104
+ const sp = count === 1 ? '' : 's';
105
+ const lines = [
106
+ `${count} temporal-dependency finding${sp} in ${files} file${fp}:`,
107
+ '',
108
+ ];
109
+ for (const f of result.findings) {
110
+ lines.push(` ${f.file}:${f.line} [${f.severity}] ${f.ruleId}`);
111
+ lines.push(` ${f.snippet}`);
112
+ lines.push(` why: ${f.why}`);
113
+ }
114
+ lines.push('');
115
+ lines.push(
116
+ 'Advisory by default. Re-run with --strict to fail the step on findings.',
117
+ );
118
+ return lines.join('\n') + suppressedNote(result);
119
+ }
120
+
121
+ /**
122
+ * blackhawk takes paths, so it gets the `--` end-of-options terminator (a file
123
+ * literally named `--json` stays reachable) and treats a lone `-` as a
124
+ * positional, as argparse does. The four shared invariants live in the shared
125
+ * parser (#479).
126
+ */
127
+ export const CLI_SPEC = {
128
+ prog: 'canary-blackhawk',
129
+ booleans: { '--json': 'json', '--strict': 'strict' },
130
+ positionals: { key: 'paths', defaults: ['.'] },
131
+ };
132
+
133
+ const parseArgs = createParser(CLI_SPEC);
134
+
135
+ export function main(argv = []) {
136
+ const { positionals: paths, opts, help, error } = parseArgs(argv);
137
+
138
+ // Usage and parse errors resolve before any filesystem work, so `--help`
139
+ // never reports a missing path and a typo never half-runs a scan.
140
+ if (help) {
141
+ console.log(USAGE);
142
+ return 0;
143
+ }
144
+ if (error) {
145
+ console.error(formatUsageError(CLI_SPEC.prog, error));
146
+ return EXIT_USAGE;
147
+ }
148
+
149
+ for (const entry of paths) {
150
+ if (!fs.existsSync(entry)) {
151
+ console.error(`${PREFIX} path not found: ${entry}`);
152
+ return 1;
153
+ }
154
+ }
155
+
156
+ const result = scanPaths(paths);
157
+
158
+ if (opts.json) {
159
+ console.log(
160
+ JSON.stringify(
161
+ {
162
+ schema_version: SCHEMA_VERSION,
163
+ findings: result.findings.map(toJson),
164
+ summary: summary(result),
165
+ },
166
+ null,
167
+ 2,
168
+ ),
169
+ );
170
+ } else {
171
+ console.log(renderText(result));
172
+ }
173
+
174
+ // Advisory by default (D3). Under --strict the CLI carries an exit-code
175
+ // contract, so a collapsed denominator inherits EXIT_ABSTAINED (3) -- distinct
176
+ // from 1 ("found something real"), so CI can tell them apart.
177
+ if (opts.strict && !result.filesScanned) return 3;
178
+ return opts.strict && result.findings.length ? 1 : 0;
179
+ }
180
+
181
+ // Direct execution (the skill runner execs this file via its shebang).
182
+ //
183
+ // `process.exitCode`, not `process.exit()`: a large `--json` payload exceeds
184
+ // the pipe buffer, and `process.exit` tears the process down mid-write, leaving
185
+ // truncated JSON that still exits 0 (#791).
186
+ if (import.meta.url === `file://${process.argv[1]}`) {
187
+ process.exitCode = main(process.argv.slice(2));
188
+ }
@@ -0,0 +1,120 @@
1
+ // Temporal-dependency rule catalog (pure data + compiled patterns).
2
+ //
3
+ // Each rule is a line-level pattern plus the reason it matters. The catalog is
4
+ // deliberately small and language-agnostic: the same scan runs over Python and
5
+ // JS/TS because the idioms do not collide (`new Date()` never appears in Python,
6
+ // `time.time()` never in TypeScript).
7
+ //
8
+ // Rules whose `clockDependent` flag is set are suppressed when the file already
9
+ // installs a frozen clock (see scanner.frozenClockMarkers). Timezone rules are
10
+ // not, because freezing the clock pins *when* a test runs, never *where*.
11
+ //
12
+ // JS has no verbose-regex flag, so patterns are compact literals documented by
13
+ // the comment above them.
14
+
15
+ export const SEVERITIES = ['high', 'medium', 'low'];
16
+
17
+ // Frozen-clock idioms. Their presence anywhere in a file suppresses every
18
+ // clock-dependent rule in that file -- the single most important behaviour in
19
+ // this skill, because a naive universal wall-clock rule false-positives on
20
+ // exactly the tests that already handle time correctly.
21
+ export const FROZEN_CLOCK_MARKERS = [
22
+ 'vi.useFakeTimers',
23
+ 'vi.setSystemTime',
24
+ 'jest.useFakeTimers',
25
+ 'jest.setSystemTime',
26
+ 'sinon.useFakeTimers',
27
+ 'MockDate',
28
+ 'freeze_time',
29
+ 'freezegun',
30
+ 'time_machine',
31
+ ];
32
+
33
+ // Tokens that make a datetime expression explicitly timezone-aware.
34
+ const TZ_TOKENS = [
35
+ 'tzinfo',
36
+ 'timezone.utc',
37
+ 'pytz',
38
+ 'tz=',
39
+ 'ZoneInfo',
40
+ 'astimezone',
41
+ ];
42
+
43
+ // JS Date.now() | bare new Date() | moment() | PY datetime.now/today/utcnow() |
44
+ // date.today() | time.time() | pd.Timestamp.now()
45
+ const WALL_CLOCK =
46
+ /\bDate\.now\s*\(|\bnew\s+Date\s*\(\s*\)|\bmoment\s*\(\s*\)|\bdatetime\.(?:now|today|utcnow)\s*\(|\bdate\.today\s*\(|\btime\.time\s*\(|\bTimestamp\.now\s*\(/;
47
+
48
+ // PY time.sleep(n) | JS setTimeout(fn, n) with a literal numeric delay.
49
+ const REAL_DELAY =
50
+ /\btime\.sleep\s*\(\s*(?<delay>[0-9][0-9_]*(?:\.[0-9]+)?)\s*\)|\bsetTimeout\s*\([^,]*,\s*(?<delay2>[0-9][0-9_]*(?:\.[0-9]+)?)\s*[,)]/;
51
+
52
+ // JS locale formatting | PY strftime with %z / %Z.
53
+ const LOCAL_TZ =
54
+ /\.toLocale(?:String|DateString|TimeString)\s*\(|strftime\s*\(\s*[frbu]*['"][^'"]*%[zZ]/;
55
+
56
+ // A comparison against datetime(YYYY, ...) or strptime(...) on either side.
57
+ const NAIVE_COMPARE =
58
+ /(?:==|!=|<=|>=|<|>)\s*(?:\w+\.)*datetime\s*\(\s*\d{4}|(?:\w+\.)*datetime\s*\(\s*\d{4}[^)]*\)\s*(?:==|!=|<=|>=|<|>)|(?:==|!=|<=|>=|<|>)\s*(?:\w+\.)*strptime\s*\(|(?:\w+\.)*strptime\s*\([^)]*\)\s*(?:==|!=|<=|>=|<|>)/;
59
+
60
+ /** BH002 guard: keep only when the literal delay is > 0. */
61
+ function delayIsPositive(match) {
62
+ const raw = match.groups?.delay ?? match.groups?.delay2;
63
+ const n = Number.parseFloat(String(raw).replace(/_/g, ''));
64
+ return Number.isFinite(n) && n > 0;
65
+ }
66
+
67
+ /** BH004 guard: keep only when the compared datetime carries no timezone token. */
68
+ function naiveDatetime(match) {
69
+ const line = match.input ?? '';
70
+ return !TZ_TOKENS.some((token) => line.includes(token));
71
+ }
72
+
73
+ /**
74
+ * @typedef {{ruleId: string, severity: string, why: string, pattern: RegExp,
75
+ * clockDependent: boolean, keep: ((m: RegExpExecArray) => boolean)|null}} Rule
76
+ */
77
+
78
+ /** @type {Rule[]} */
79
+ export const RULES = [
80
+ {
81
+ ruleId: 'BH001-wall-clock',
82
+ severity: 'high',
83
+ why:
84
+ 'reads the wall clock, so the assertion depends on when the suite runs ' +
85
+ '(midnight, a DST shift, or Feb 29 changes the answer)',
86
+ pattern: WALL_CLOCK,
87
+ clockDependent: true,
88
+ keep: null,
89
+ },
90
+ {
91
+ ruleId: 'BH002-real-delay',
92
+ severity: 'medium',
93
+ why:
94
+ 'burns a real delay, so the test is slow by construction and races the ' +
95
+ 'scheduler on a loaded CI runner',
96
+ pattern: REAL_DELAY,
97
+ clockDependent: true,
98
+ keep: delayIsPositive,
99
+ },
100
+ {
101
+ ruleId: 'BH003-local-timezone',
102
+ severity: 'medium',
103
+ why:
104
+ "formats against the machine's local timezone, so the expected string " +
105
+ 'differs between a developer laptop and a UTC CI runner',
106
+ pattern: LOCAL_TZ,
107
+ clockDependent: false,
108
+ keep: null,
109
+ },
110
+ {
111
+ ruleId: 'BH004-naive-datetime-compare',
112
+ severity: 'low',
113
+ why:
114
+ 'compares a timezone-naive datetime, so the result shifts with the host ' +
115
+ 'offset and breaks across a DST boundary',
116
+ pattern: NAIVE_COMPARE,
117
+ clockDependent: true,
118
+ keep: naiveDatetime,
119
+ },
120
+ ];
@@ -0,0 +1,244 @@
1
+ // Line scanner: turns test sources into temporal-dependency findings (pure).
2
+ //
3
+ // Regex/AST-lite by design -- no parser dependency, standard library only -- so
4
+ // it ships wherever node does. See SKILL.md for the fidelity limits that buys.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ import { FROZEN_CLOCK_MARKERS, RULES } from './rules.mjs';
10
+ import { stringLiteralRanges, execOutsideStrings } from './string-literals.mjs';
11
+
12
+ export const SNIPPET_LIMIT = 120;
13
+
14
+ const SUPPORTED_SUFFIXES = [
15
+ '.py',
16
+ '.js',
17
+ '.jsx',
18
+ '.ts',
19
+ '.tsx',
20
+ '.mjs',
21
+ '.cjs',
22
+ ];
23
+
24
+ const SKIP_DIRS = new Set([
25
+ '.git',
26
+ 'node_modules',
27
+ '__pycache__',
28
+ '.venv',
29
+ 'venv',
30
+ 'dist',
31
+ 'build',
32
+ '.mypy_cache',
33
+ '.pytest_cache',
34
+ '.tox',
35
+ // Fixture directories are test DATA: files here never RUN as tests, so a
36
+ // temporal/order smell in one is a property of the data, not a defect (#493
37
+ // one level up). Also keeps pragmas out of golden-pinned fixture files.
38
+ 'fixtures',
39
+ '__fixtures__',
40
+ '__mocks__',
41
+ 'testdata',
42
+ ]);
43
+
44
+ const TEST_DIRS = new Set(['tests', 'test', '__tests__', 'e2e', 'spec']);
45
+
46
+ const COMMENT_PREFIXES = ['#', '//', '*', '/*', '"""', "'''"];
47
+
48
+ const splitLines = (text) => text.split(/\r\n|\r|\n/);
49
+ const isComment = (stripped) =>
50
+ COMMENT_PREFIXES.some((p) => stripped.startsWith(p));
51
+ const partsOf = (p) => p.split(/[\\/]/).filter(Boolean);
52
+
53
+ /**
54
+ * Return the frozen-clock idioms present in `text`, in catalog order. A
55
+ * non-empty result suppresses every clock-dependent rule for the whole file --
56
+ * file-wide (not block-scoped) on purpose: a scope-accurate answer needs a real
57
+ * parser, and blackhawk errs toward silence.
58
+ */
59
+ export function frozenClockMarkers(text) {
60
+ return FROZEN_CLOCK_MARKERS.filter((marker) => text.includes(marker));
61
+ }
62
+
63
+ /** True when a path looks like a test file by name or containing directory. */
64
+ function isTestFile(filePath) {
65
+ const suffix = path.extname(filePath);
66
+ if (!SUPPORTED_SUFFIXES.includes(suffix)) return false;
67
+ const name = path.basename(filePath);
68
+ const stem = name.slice(0, name.length - suffix.length);
69
+ if (name.includes('.test.') || name.includes('.spec.')) return true;
70
+ if (stem.startsWith('test_') || stem.endsWith('_test')) return true;
71
+ return partsOf(filePath)
72
+ .slice(0, -1)
73
+ .some((part) => TEST_DIRS.has(part));
74
+ }
75
+
76
+ /** Convert an internal finding to its JSON-contract shape (snake_case id). */
77
+ export function toJson(f) {
78
+ return {
79
+ file: f.file,
80
+ line: f.line,
81
+ rule_id: f.ruleId,
82
+ severity: f.severity,
83
+ snippet: f.snippet,
84
+ why: f.why,
85
+ };
86
+ }
87
+
88
+ // Inline suppression pragma (#393): `blackhawk-ignore <RULE>[,<RULE>] -- reason`
89
+ // in a comment. Rule-scoped (so it never blanket-silences a line) and the reason
90
+ // is required (keeps suppressions honest and greppable). A pragma covers the
91
+ // finding on its own line (trailing comment) and the next line (comment above
92
+ // the code) - the two idioms teams reach for.
93
+ const PRAGMA = /\bblackhawk-ignore\s+([A-Za-z0-9,\s-]*?)\s*--\s*(\S.*)$/;
94
+
95
+ function parsePragmas(lines) {
96
+ const map = new Map();
97
+ const add = (ln, tokens) => {
98
+ if (!map.has(ln)) map.set(ln, new Set());
99
+ for (const t of tokens) map.get(ln).add(t);
100
+ };
101
+ lines.forEach((raw, i) => {
102
+ // #499: a pragma is a DIRECTIVE, so it only counts as code. Matching the
103
+ // raw line let a `blackhawk-ignore` inside a string literal register as
104
+ // live -- data acting as directive, with a fabricated "reason" entering the
105
+ // suppressed count. This suite necessarily carries pragma text inside
106
+ // fixture strings, so the self-scan was the thing at risk. Savant shipped
107
+ // this guard in #498; blackhawk never got it ported back.
108
+ const m = execOutsideStrings(PRAGMA, raw, stringLiteralRanges(raw));
109
+ if (!m || !m[2].trim()) return; // reason required
110
+ const tokens = m[1].split(/[,\s]+/).filter(Boolean);
111
+ if (!tokens.length) return; // rule-scoped: must name a rule
112
+ add(i + 1, tokens); // same-line (trailing pragma)
113
+ add(i + 2, tokens); // next line (pragma above the code)
114
+ });
115
+ return map;
116
+ }
117
+
118
+ // A `BH002` token matches `BH002-real-delay`; the full id also matches.
119
+ const tokenMatches = (ruleId, token) =>
120
+ ruleId === token || ruleId.split('-')[0] === token;
121
+
122
+ /**
123
+ * @typedef {{file: string, line: number, ruleId: string, severity: string,
124
+ * snippet: string, why: string}} Finding
125
+ */
126
+
127
+ /**
128
+ * Scan source text. Returns kept `findings` plus `suppressed` findings silenced
129
+ * by an inline pragma, both ordered by line then rule id.
130
+ * @returns {{findings: Finding[], suppressed: Finding[]}}
131
+ */
132
+ export function scanTextFull(text, file = '<text>') {
133
+ const lines = splitLines(text);
134
+ const frozen = frozenClockMarkers(text).length > 0;
135
+ const pragmas = parsePragmas(lines);
136
+ const findings = [];
137
+ const suppressed = [];
138
+ lines.forEach((raw, i) => {
139
+ const stripped = raw.trim();
140
+ if (!stripped || isComment(stripped)) return;
141
+ // #493: a match starting inside a string literal is fixture data, not
142
+ // code. Computed once per line; every rule's anchor token is code, even
143
+ // when the pattern's tail reaches into quotes (BH003's strftime('..%Z')).
144
+ const ranges = stringLiteralRanges(stripped);
145
+ for (const rule of RULES) {
146
+ if (frozen && rule.clockDependent) continue;
147
+ const match = execOutsideStrings(rule.pattern, stripped, ranges);
148
+ if (!match) continue;
149
+ if (rule.keep && !rule.keep(match)) continue;
150
+ const finding = {
151
+ file,
152
+ line: i + 1,
153
+ ruleId: rule.ruleId,
154
+ severity: rule.severity,
155
+ snippet: stripped.slice(0, SNIPPET_LIMIT),
156
+ why: rule.why,
157
+ };
158
+ const tokens = pragmas.get(i + 1);
159
+ if (tokens && [...tokens].some((t) => tokenMatches(rule.ruleId, t))) {
160
+ suppressed.push(finding);
161
+ } else {
162
+ findings.push(finding);
163
+ }
164
+ }
165
+ });
166
+ return { findings, suppressed };
167
+ }
168
+
169
+ /** Scan source text, returning kept findings (back-compat wrapper). */
170
+ export function scanText(text, file = '<text>') {
171
+ return scanTextFull(text, file).findings;
172
+ }
173
+
174
+ /** Scan one file. Unreadable files yield nothing. */
175
+ function scanFileFull(filePath) {
176
+ let text;
177
+ try {
178
+ text = fs.readFileSync(filePath, 'utf8');
179
+ } catch {
180
+ return { findings: [], suppressed: [] };
181
+ }
182
+ return scanTextFull(text, filePath);
183
+ }
184
+
185
+ /** Yield the files a path contributes: explicit files win, dirs are filtered. */
186
+ function* iterFiles(root) {
187
+ let stat;
188
+ try {
189
+ stat = fs.statSync(root);
190
+ } catch {
191
+ return;
192
+ }
193
+ if (stat.isFile()) {
194
+ if (SUPPORTED_SUFFIXES.includes(path.extname(root))) yield root;
195
+ return;
196
+ }
197
+ const collected = [];
198
+ const walk = (dir) => {
199
+ let entries;
200
+ try {
201
+ entries = fs.readdirSync(dir, { withFileTypes: true });
202
+ } catch {
203
+ return;
204
+ }
205
+ for (const entry of entries) {
206
+ if (SKIP_DIRS.has(entry.name)) continue;
207
+ const full = path.join(dir, entry.name);
208
+ if (entry.isDirectory()) walk(full);
209
+ else if (entry.isFile()) collected.push(full);
210
+ }
211
+ };
212
+ walk(root);
213
+ collected.sort();
214
+ for (const f of collected) {
215
+ if (partsOf(f).some((part) => SKIP_DIRS.has(part))) continue;
216
+ if (isTestFile(f)) yield f;
217
+ }
218
+ }
219
+
220
+ /** Scan every given file/directory, de-duplicating overlapping paths. */
221
+ export function scanPaths(paths) {
222
+ const seen = new Set();
223
+ const findings = [];
224
+ let scanned = 0;
225
+ let suppressed = 0;
226
+ for (const entry of paths) {
227
+ for (const filePath of iterFiles(entry)) {
228
+ const resolved = path.resolve(filePath);
229
+ if (seen.has(resolved)) continue;
230
+ seen.add(resolved);
231
+ scanned += 1;
232
+ const r = scanFileFull(filePath);
233
+ findings.push(...r.findings);
234
+ suppressed += r.suppressed.length;
235
+ }
236
+ }
237
+ findings.sort(
238
+ (a, b) =>
239
+ a.file.localeCompare(b.file) ||
240
+ a.line - b.line ||
241
+ a.ruleId.localeCompare(b.ruleId),
242
+ );
243
+ return { findings, filesScanned: scanned, suppressed };
244
+ }
@@ -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
+ }