canary-test-cli 7.1.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/cli-commands.js +3 -65
- package/dist/engine/core/quality-scorer.js +15 -2
- package/dist/engine/core/skill-dispatch.js +115 -0
- package/dist/engine/core/skill-examples.js +103 -3
- package/dist/engine/core/skill-registry.js +59 -4
- package/dist/engine/core/test-files.js +77 -0
- package/dist/engine/core/vacuity-scanner.js +184 -14
- package/dist/engine/guardian/analysis-emit.js +7 -2
- package/dist/engine/guardian/cli.js +121 -9
- package/dist/engine/guardian/pr-check.js +317 -18
- package/dist/engine/skills-cli.js +123 -19
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- package/package.json +3 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canary-cassandra
|
|
3
|
+
description: >
|
|
4
|
+
Vacuous-test detection — finds tests that PASS WITHOUT PROVING ANYTHING: an
|
|
5
|
+
assertion that compares a value with itself, a test that never invokes the
|
|
6
|
+
target it claims to cover, and a test whose every assertion is an absence
|
|
7
|
+
observed on a bystander rather than on the code under test. Use when the user
|
|
8
|
+
says "why did this pass against the bug", "are these tests actually testing
|
|
9
|
+
anything", "audit my suite for vacuous tests", "green but worthless", or after
|
|
10
|
+
a bug shipped through a green suite. Advisory and deterministic — no LLM, no
|
|
11
|
+
execution. NOT for tests with zero assertions (that is `canary review-test`'s
|
|
12
|
+
LINT-006), NOT for flaky tests (canary-flake-hunter), and NOT a coverage tool
|
|
13
|
+
— a vacuous test has coverage, which is exactly why coverage never caught it.
|
|
14
|
+
cli: scripts/cli.mjs
|
|
15
|
+
requires: [node>=20]
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Canary Cassandra
|
|
19
|
+
|
|
20
|
+
> Cassandra Cain reads the fake. So does this skill.
|
|
21
|
+
|
|
22
|
+
A test with no assertions is easy to find and everybody already looks for it.
|
|
23
|
+
The dangerous test is the one with assertions that **cannot fail** — it has
|
|
24
|
+
coverage, it goes green, and it goes green identically against the bug it was
|
|
25
|
+
written to catch. Three of those shipped in this project in a single cycle
|
|
26
|
+
(recorded in [#486]) and every gate the repo owned read all three as healthy.
|
|
27
|
+
|
|
28
|
+
Cassandra is Tier-0: deterministic, no LLM, no network, no execution.
|
|
29
|
+
|
|
30
|
+
## What it finds
|
|
31
|
+
|
|
32
|
+
| Rule | Severity | Fires on |
|
|
33
|
+
| --------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
34
|
+
| `VAC-001` | critical | An assertion whose expectation is identical to the value it checks — `expect(true).toBe(true)`, `assert x == x` |
|
|
35
|
+
| `VAC-002` | warning | The test never references the target it claims to cover |
|
|
36
|
+
| `VAC-003` | warning | Every assertion in the test asserts an _absence_, and none of them observes the target — so nothing proves it ran |
|
|
37
|
+
|
|
38
|
+
`VAC-001` is deterministic, hence `critical`: no implementation can fail it.
|
|
39
|
+
`VAC-002` and `VAC-003` depend on resolving a target, which is inference, so
|
|
40
|
+
they are `warning` and carry a fidelity tier.
|
|
41
|
+
|
|
42
|
+
## Run it
|
|
43
|
+
|
|
44
|
+
Two doors, one detector. Both run the same engine rules, so they cannot disagree
|
|
45
|
+
about a finding or about the denominator.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
canary vacuity-check tests/ # human-readable
|
|
49
|
+
canary vacuity-check tests/ --json # verdict + denominator + skips
|
|
50
|
+
canary vacuity-check tests/a.test.ts # one file
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
As a skill, for an orchestrator or a CI step composing all four Tier-0
|
|
54
|
+
detectors:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
canary skills run canary-cassandra -- tests/
|
|
58
|
+
canary skills run canary-cassandra -- tests/ --json --strict
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Usage, flags, and the full rule list — this one is runnable verbatim, needs no
|
|
62
|
+
fixtures, and is what CI executes to prove the doc still matches the CLI:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
canary skills run canary-cassandra -- --help
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The `--json` envelope matches `canary-savant` / `canary-blackhawk` /
|
|
69
|
+
`canary-katana` — `schema_version`, a `findings` array of
|
|
70
|
+
`{file, line, rule_id, severity, snippet, why}`, and a `summary` — so findings
|
|
71
|
+
from all four merge without special-casing one. Cassandra adds `suggestion` and
|
|
72
|
+
`fidelity` per finding, and `tests_checked` to the summary, because its
|
|
73
|
+
denominator is tests rather than files.
|
|
74
|
+
|
|
75
|
+
**Advisory by design.** Findings exit **0**. This is the repo's established
|
|
76
|
+
shape for a new detector — advisory first, ratchet to strict only after triage
|
|
77
|
+
(see the dogfooding jobs, [#485]). Do not wire it as a required check on the day
|
|
78
|
+
you adopt it; run it, triage the count, then decide.
|
|
79
|
+
|
|
80
|
+
A collapsed denominator is **not** advisory and exits **3**. See below.
|
|
81
|
+
|
|
82
|
+
## The fidelity ladder (the part that decides whether to trust a finding)
|
|
83
|
+
|
|
84
|
+
A test's "declared target" is declared nowhere, so `VAC-002`/`VAC-003` have to
|
|
85
|
+
resolve one. Three rungs, and the finding says which one it used:
|
|
86
|
+
|
|
87
|
+
| Tier | How the target was resolved | Trust |
|
|
88
|
+
| ----------------- | --------------------------------------------------------------------------------------------- | ------------------------------------- |
|
|
89
|
+
| `annotated` | The author wrote `// @covers <symbol>`. That exact symbol is checked. | High — the author stated the contract |
|
|
90
|
+
| `import-inferred` | The symbols imported from first-party (relative) modules, closed over local helpers | Medium — read the test before acting |
|
|
91
|
+
| _(skipped)_ | Neither available. Reported as a skip with its reason; the test is **not** reported as clean. | None — the check did not run |
|
|
92
|
+
|
|
93
|
+
`import-inferred` reads four binding forms, not one: a named or default import,
|
|
94
|
+
a **namespace** import (`import * as store from './store.js'`), a **dynamic**
|
|
95
|
+
import (`const { save } = await import('./store.js')`), and a handle bound to a
|
|
96
|
+
first-party **script path** that a `spawnSync`/`execFileSync`-family call then
|
|
97
|
+
runs. The last is what a subprocess test has instead of a symbol; the
|
|
98
|
+
discriminator is the path, so spawning a bare command (`spawnSync('git', …)`)
|
|
99
|
+
still counts as reaching nothing. A test that launches a script path written
|
|
100
|
+
inline, or that carries a bare `await import('./x.js')`, is likewise read as
|
|
101
|
+
reaching its target — but `VAC-003` stays dark for it and says so in the skip
|
|
102
|
+
list, because "did an assertion observe the target" needs a symbol that a
|
|
103
|
+
subprocess boundary does not provide (#705).
|
|
104
|
+
|
|
105
|
+
To upgrade a finding from inferred to annotated, add the annotation above the
|
|
106
|
+
test:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// @covers resolveOverlay
|
|
110
|
+
it('falls back to the tracked overlay', () => {
|
|
111
|
+
/* ... */
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
That is also the fix for a false positive: if the target is reached several
|
|
116
|
+
frames deep and the inference cannot see it, `@covers` tells the check what to
|
|
117
|
+
look for instead of arguing with the heuristic.
|
|
118
|
+
|
|
119
|
+
## Why a zero denominator exits 3
|
|
120
|
+
|
|
121
|
+
The whole point of this skill is that **green can mean nothing**. A vacuity
|
|
122
|
+
detector that reported its own silence as success would be the joke telling
|
|
123
|
+
itself, so:
|
|
124
|
+
|
|
125
|
+
- **No test file matched** → exit 3, `Abstained — verified zero items`.
|
|
126
|
+
- **Files matched but held zero tests** → also exit 3. This is the subtler zero:
|
|
127
|
+
the file-level count looks healthy, and a scanner that only guarded that one
|
|
128
|
+
prints a clean tick.
|
|
129
|
+
- **A test whose target could not be resolved** → a `skipped` entry naming the
|
|
130
|
+
test and the reason, rendered in every summary line.
|
|
131
|
+
|
|
132
|
+
`checked` in the JSON payload is the number of **tests** read, not files. Always
|
|
133
|
+
read it before believing a zero.
|
|
134
|
+
|
|
135
|
+
## Reading the output
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
[CRITICAL] tests/cli.test.ts:41 (VAC-001)
|
|
139
|
+
--help does not write the ledger: Assertion compares a value with itself; no implementation can fail it.
|
|
140
|
+
→ Assert the value the code under test should have produced, not the input.
|
|
141
|
+
|
|
142
|
+
3 finding(s) across 214 checked (11 skipped: VAC-002/VAC-003 (adds two numbers) [target unresolvable: ...])
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The summary line always carries the denominator and the skips. A finding count
|
|
146
|
+
with no denominator is not a result.
|
|
147
|
+
|
|
148
|
+
## Rationalizations to reject
|
|
149
|
+
|
|
150
|
+
| Rationalization | Why it is wrong |
|
|
151
|
+
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
152
|
+
| "It's covered, so it's tested" | A vacuous test executes the line and asserts nothing about it. Coverage is the reason this class survives — it satisfies the metric perfectly. |
|
|
153
|
+
| "VAC-002 flagged a test I know is correct, so it's junk" | Add `// @covers <symbol>`. That converts a guess into a checked claim, permanently, for every future run. |
|
|
154
|
+
| "0 findings, we're clean" | Only if `checked` is greater than zero. Read the denominator; exit 3 means the check never ran. |
|
|
155
|
+
| "We'll make it a required check right away" | A new detector lands advisory. Triage the existing count first, then ratchet — otherwise the first red build teaches the team to bypass the gate. |
|
|
156
|
+
| "The absence assertion is fine, the test passes" | That is the `VAC-003` defect exactly: it also passed when the code exited before doing anything. One positive assertion makes it load-bearing. |
|
|
157
|
+
|
|
158
|
+
## Escalation
|
|
159
|
+
|
|
160
|
+
- **A VAC-001 finding is never a false positive.** It is a comparison of a value
|
|
161
|
+
with itself. Fix the test.
|
|
162
|
+
- **A large VAC-002 count on first run usually means dynamic imports.** Tests
|
|
163
|
+
that `await import(...)` their target have no static import for the inference
|
|
164
|
+
to read. Annotate them, or accept the tier and move on — do not rewrite the
|
|
165
|
+
suite to satisfy a heuristic.
|
|
166
|
+
- **A finding you disagree with is a doc gap, not an argument.** Write the
|
|
167
|
+
`@covers` annotation; the next reader gets the answer for free.
|
|
168
|
+
|
|
169
|
+
## Related
|
|
170
|
+
|
|
171
|
+
- `canary review-test` — `LINT-006` (zero assertions) and `SOUND-001/002/003` (a
|
|
172
|
+
test that pins a value no correct implementation must produce). Cassandra
|
|
173
|
+
deliberately does not duplicate either.
|
|
174
|
+
- `canary-promote-test` — consumes these findings as a structured verdict and
|
|
175
|
+
blocks promotion on the deterministic ones.
|
|
176
|
+
- `canary-katana`, `canary-savant`, `canary-blackhawk` — the other Tier-0
|
|
177
|
+
deterministic scanners.
|
|
178
|
+
|
|
179
|
+
[#485]: https://github.com/bop-clocktower/canary/issues/485
|
|
180
|
+
[#486]: https://github.com/bop-clocktower/canary/issues/486
|