canary-test-cli 7.0.0 → 7.1.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/dist/engine/analysis/cli.js +116 -54
- package/dist/engine/analysis/engine.js +34 -16
- package/dist/engine/analysis/reports.js +5 -4
- package/dist/engine/cli-commands.js +249 -41
- package/dist/engine/cli-common.js +15 -24
- package/dist/engine/cli.core.js +37 -11
- package/dist/engine/cli.js +2 -2
- package/dist/engine/company-knowledge-cli.js +2 -2
- package/dist/engine/core/adoption.js +408 -0
- package/dist/engine/core/framework-probes.js +7 -7
- package/dist/engine/core/fs-glob.js +2 -2
- package/dist/engine/core/gate-result.js +17 -0
- package/dist/engine/core/migrator.js +9 -17
- package/dist/engine/core/pattern-matcher.js +23 -5
- package/dist/engine/core/persona.js +421 -0
- package/dist/engine/core/promotion-verdict.js +261 -0
- package/dist/engine/core/reporter.js +1 -9
- package/dist/engine/core/skill-examples.js +292 -0
- package/dist/engine/core/skill-surfaces.js +307 -0
- package/dist/engine/core/static-linter.js +310 -38
- package/dist/engine/core/ticket-updater.js +1 -7
- package/dist/engine/core/vacuity-scanner.js +556 -0
- package/dist/engine/core/workflow-discovery.js +2 -8
- package/dist/engine/core/workspace-detect.js +7 -6
- package/dist/engine/data/personas/registry.json +36 -0
- package/dist/engine/guardian/adjudication.js +5 -5
- package/dist/engine/guardian/analysis-emit.js +13 -27
- package/dist/engine/guardian/cli.js +30 -43
- package/dist/engine/guardian/coverage.js +1 -1
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
- package/dist/engine/guardian/pr-check.js +5 -15
- package/dist/engine/guardian/pr-comment.js +4 -3
- package/dist/engine/history/cli.js +210 -6
- package/dist/engine/history/ndjson-store.js +9 -5
- package/dist/engine/history/record.js +34 -5
- package/dist/engine/history/run-recorder.js +165 -0
- package/dist/engine/history/schema.js +25 -7
- package/dist/engine/history/store.js +9 -0
- package/dist/engine/mcp-server.js +35 -13
- package/dist/engine/skills-cli.js +133 -11
- package/dist/engine/util/ensure-ascii.js +37 -0
- package/dist/engine/workflow-cli.js +6 -6
- package/dist/gate-result.d.ts +11 -0
- package/dist/gate-result.js +18 -0
- package/dist/uninstall.js +12 -5
- package/package.json +1 -1
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* canary-cassandra -- vacuous-test detection (#612).
|
|
3
|
+
*
|
|
4
|
+
* A vacuous test PASSES WITHOUT PROVING ANYTHING. It has assertions, it goes
|
|
5
|
+
* green, and it goes green identically against the bug it was written to catch,
|
|
6
|
+
* so every gate this repo owns reads it as healthy. Three shipped examples are
|
|
7
|
+
* recorded in #486 and all three cleared coverage, `review-test`, and CI:
|
|
8
|
+
*
|
|
9
|
+
* - an assertion whose expectation could not have been false (`toBe(true)`),
|
|
10
|
+
* - a test whose target was never actually invoked, and
|
|
11
|
+
* - a test whose only assertion was an ABSENCE, which the buggy code satisfied
|
|
12
|
+
* by crashing before it could do anything.
|
|
13
|
+
*
|
|
14
|
+
* This module is one implementation, consumed by both the `canary vacuity-check`
|
|
15
|
+
* CLI and the promotion gate (`promotion-verdict.ts`, #477). It is deliberately
|
|
16
|
+
* NOT a self-contained `.mjs` skill: #605's accepted risk was that
|
|
17
|
+
* `static_linter` and `quality_scorer` already overlap and a third
|
|
18
|
+
* half-enforcer would be the real defect. `agents/skills/claude-code/
|
|
19
|
+
* canary-cassandra/SKILL.md` drives this CLI rather than carrying a second copy
|
|
20
|
+
* of the detection.
|
|
21
|
+
*
|
|
22
|
+
* ## The fidelity ladder, and why VAC-002 needs one
|
|
23
|
+
*
|
|
24
|
+
* A test's "declared target" is declared nowhere. Inferring it from imports is
|
|
25
|
+
* exactly the heuristic tier STRATEGY.md distrusts, and the issue predicts the
|
|
26
|
+
* failure precisely: a correct integration test gets flagged when the call sits
|
|
27
|
+
* several frames deeper. So:
|
|
28
|
+
*
|
|
29
|
+
* - `annotated` -- the author wrote `@covers <symbol>`. The rule checks THAT
|
|
30
|
+
* symbol and says so.
|
|
31
|
+
* - `import-inferred` -- no annotation, so the target set is the symbols
|
|
32
|
+
* imported from relative paths, closed over local declarations to a fixpoint
|
|
33
|
+
* (one helper, or a chain of them, still counts as reaching the target).
|
|
34
|
+
* - neither -- the target cannot be resolved. That is "cannot verify", which is
|
|
35
|
+
* a finding about the SCAN, so it lands in `skipped` with its reason.
|
|
36
|
+
*
|
|
37
|
+
* A skip is PER RULE, not per test: `VAC-001` needs no target and always runs,
|
|
38
|
+
* so a test whose target is unresolvable is still genuinely `checked` and stays
|
|
39
|
+
* in the denominator. Saying otherwise would understate what was verified. What
|
|
40
|
+
* must not happen is a reader mistaking that for a full pass, which is why
|
|
41
|
+
* `promotion-verdict.ts` puts the skip count in its remedy rather than letting
|
|
42
|
+
* `promote` read as unqualified.
|
|
43
|
+
*
|
|
44
|
+
* ## The denominator
|
|
45
|
+
*
|
|
46
|
+
* `scanVacuity` returns a {@link GateResult}, so a file it could not read
|
|
47
|
+
* reports `checked: 0` and `gateOutcome` structurally refuses to print a pass.
|
|
48
|
+
* A vacuity detector that could itself go quiet and look clean would be the
|
|
49
|
+
* joke telling itself.
|
|
50
|
+
*/
|
|
51
|
+
import { readFileSync } from 'node:fs';
|
|
52
|
+
import { ASSERT_JS, ASSERT_PY, enumerateTests, frameworkForPath, } from './static-linter.js';
|
|
53
|
+
import { blankStringContent } from './string-literals.js';
|
|
54
|
+
/**
|
|
55
|
+
* `@covers <symbol>` -- the explicit rung of the ladder.
|
|
56
|
+
*
|
|
57
|
+
* Global, because {@link annotationFor} needs the LAST match in its window, not
|
|
58
|
+
* the first: `exec` returns the match nearest the start, which is the FARTHEST
|
|
59
|
+
* annotation above the declaration.
|
|
60
|
+
*/
|
|
61
|
+
const COVERS_PRAGMA = /@covers\s+([A-Za-z_$][\w$]*)/g;
|
|
62
|
+
/** An import whose specifier is relative: the local code a test can target. */
|
|
63
|
+
const JS_RELATIVE_IMPORT = /import\s+(?:type\s+)?(?:\{([^}]*)\}|(\w+))[^'"]*from\s*['"](\.[^'"]*)['"]/g;
|
|
64
|
+
const JS_RELATIVE_REQUIRE = /(?:const|let|var)\s+(?:\{([^}]*)\}|(\w+))\s*=\s*require\s*\(\s*['"](\.[^'"]*)['"]/g;
|
|
65
|
+
/**
|
|
66
|
+
* Python has no `.`-prefix requirement for a first-party import, so `from x
|
|
67
|
+
* import y` counts. `import os` and the stdlib are excluded by name below --
|
|
68
|
+
* a heuristic, but the alternative is treating every pytest file as
|
|
69
|
+
* unresolvable.
|
|
70
|
+
*/
|
|
71
|
+
const PY_FROM_IMPORT = /^[ \t]*from\s+([\w.]+)\s+import\s+(\([^)]*\)|[^\n#]+)/gm;
|
|
72
|
+
const PY_STDLIB = new Set([
|
|
73
|
+
'os',
|
|
74
|
+
'sys',
|
|
75
|
+
'json',
|
|
76
|
+
're',
|
|
77
|
+
'time',
|
|
78
|
+
'math',
|
|
79
|
+
'pathlib',
|
|
80
|
+
'typing',
|
|
81
|
+
'datetime',
|
|
82
|
+
'unittest',
|
|
83
|
+
'pytest',
|
|
84
|
+
'collections',
|
|
85
|
+
'subprocess',
|
|
86
|
+
'tempfile',
|
|
87
|
+
'itertools',
|
|
88
|
+
'functools',
|
|
89
|
+
'socket',
|
|
90
|
+
'uuid',
|
|
91
|
+
'random',
|
|
92
|
+
]);
|
|
93
|
+
/**
|
|
94
|
+
* A local declaration whose body may reach the target set.
|
|
95
|
+
*
|
|
96
|
+
* Three shapes, and the third is not optional. Measured on canary's own suite,
|
|
97
|
+
* the largest single source of false positives was the testkit idiom
|
|
98
|
+
* `const { findings, write } = kitFor(dir)`: the imported target is `kitFor`,
|
|
99
|
+
* `findings()` reaches it, and a pattern that only understood `const x = ` saw
|
|
100
|
+
* none of it -- so every test in `doc-links.test.ts` read as touching nothing at
|
|
101
|
+
* all. An object pattern binds every name in it to the same reaching RHS.
|
|
102
|
+
*/
|
|
103
|
+
const JS_LOCAL_DECL = /(?:^|\n)\s*(?:export\s+)?(?:async\s+)?(?:function\s+(\w+)|(?:const|let|var)\s+(?:\{([^}]*)\}|\[([^\]]*)\]|(\w+))\s*=)/g;
|
|
104
|
+
const PY_LOCAL_DECL = /(?:^|\n)\s*def\s+(\w+)\s*\(/g;
|
|
105
|
+
/**
|
|
106
|
+
* A destructuring ASSIGNMENT with no declarator: `({ write, findings } =
|
|
107
|
+
* kitFor(root))`.
|
|
108
|
+
*
|
|
109
|
+
* The declare-then-assign-in-a-hook idiom -- `let findings: Kit['findings']` at
|
|
110
|
+
* module scope, bound inside `beforeEach`. `doc-links.test.ts` is written this
|
|
111
|
+
* way throughout, and because the binding line carries no `const`/`let`/`var`,
|
|
112
|
+
* a declaration-only pattern misses it and every test in the file reads as
|
|
113
|
+
* touching nothing at all.
|
|
114
|
+
*/
|
|
115
|
+
const JS_DESTRUCTURED_ASSIGN = /\(\s*\{([^}]*)\}\s*=\s*([^;\n]*)\)/g;
|
|
116
|
+
/** Every identifier bound by one declaration match (a pattern binds several). */
|
|
117
|
+
function boundNames(m, python) {
|
|
118
|
+
if (python)
|
|
119
|
+
return m[1] ? [m[1]] : [];
|
|
120
|
+
if (m[1])
|
|
121
|
+
return [m[1]];
|
|
122
|
+
if (m[4])
|
|
123
|
+
return [m[4]];
|
|
124
|
+
const pattern = m[2] ?? m[3] ?? '';
|
|
125
|
+
return pattern
|
|
126
|
+
.split(',')
|
|
127
|
+
.map((raw) => raw
|
|
128
|
+
.split(':')
|
|
129
|
+
.pop()
|
|
130
|
+
.trim()
|
|
131
|
+
.replace(/^\.\.\./, ''))
|
|
132
|
+
.filter((n) => /^[A-Za-z_$][\w$]*$/.test(n));
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Assertions whose expectation is an ABSENCE. A test built only from these
|
|
136
|
+
* passes identically when the code under test never ran at all -- the
|
|
137
|
+
* `canary-katana` case from #486, where a bare tmpdir exited before the write
|
|
138
|
+
* and `expect(existsSync(...)).toBe(false)` was free.
|
|
139
|
+
*/
|
|
140
|
+
const ABSENCE_ASSERTION = /\.toBe\s*\(\s*(?:false|null|undefined)\s*\)|\.toBeNull\s*\(|\.toBeUndefined\s*\(|\.toBeFalsy\s*\(|\.toHaveLength\s*\(\s*0\s*\)|\.toEqual\s*\(\s*(?:\[\s*\]|\{\s*\})\s*\)|\.not\s*\.\s*to\w+/;
|
|
141
|
+
const PY_ABSENCE_ASSERTION = /\bassert\s+not\b|\bis\s+None\b|==\s*(?:False|None)\b|==\s*(?:\[\s*\]|\{\s*\})|\bassert\s+len\s*\([^)]*\)\s*==\s*0\b/;
|
|
142
|
+
/**
|
|
143
|
+
* Any assertion at all -- imported from the linter rather than restated.
|
|
144
|
+
*
|
|
145
|
+
* A local `/\bexpect\s*\(|\bassert\s*[.(]/` was NOT the linter's vocabulary, and
|
|
146
|
+
* the comment claiming it was is how the gap survived: `ASSERT_JS` also knows
|
|
147
|
+
* `should`-style, `.should`, the `toThrow` family, and the
|
|
148
|
+
* `expectX()`/`assertX()` helper convention that the linter's own notes say
|
|
149
|
+
* accounted for 9 of 16 residual findings here. For a suite written in any of
|
|
150
|
+
* those styles the assertion list came out EMPTY, VAC-003's `length > 0` guard
|
|
151
|
+
* short-circuited, and the rule reported nothing while nothing said it could not
|
|
152
|
+
* look -- the silent zero this module exists to prevent, one layer inside it.
|
|
153
|
+
*/
|
|
154
|
+
const JS_ASSERTION = ASSERT_JS;
|
|
155
|
+
const PY_ASSERTION = new RegExp(`${ASSERT_PY.source}|\\bself\\.assert\\w+`);
|
|
156
|
+
function mk(file, line, rule, severity, test, message, suggestion, fidelity) {
|
|
157
|
+
const f = {
|
|
158
|
+
file,
|
|
159
|
+
line,
|
|
160
|
+
rule,
|
|
161
|
+
severity,
|
|
162
|
+
test,
|
|
163
|
+
message,
|
|
164
|
+
suggestion,
|
|
165
|
+
};
|
|
166
|
+
if (fidelity)
|
|
167
|
+
f.fidelity = fidelity;
|
|
168
|
+
return f;
|
|
169
|
+
}
|
|
170
|
+
function lineOf(code, offset) {
|
|
171
|
+
let n = 1;
|
|
172
|
+
for (let i = 0; i < offset && i < code.length; i += 1) {
|
|
173
|
+
if (code[i] === '\n')
|
|
174
|
+
n += 1;
|
|
175
|
+
}
|
|
176
|
+
return n;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The identifiers a comma-separated import clause binds.
|
|
180
|
+
*
|
|
181
|
+
* `{ save as store }` binds `store`; `{ save }` binds `save`. Anything that is
|
|
182
|
+
* not a bare identifier after that (`type Kit`, a stray comment) is dropped --
|
|
183
|
+
* the filter is also what guarantees no name reaching {@link mentionsAny} can
|
|
184
|
+
* carry regex metacharacters.
|
|
185
|
+
*/
|
|
186
|
+
function clauseNames(list) {
|
|
187
|
+
// Parentheses and newlines stripped first, so the multi-line
|
|
188
|
+
// `from m import (\n a,\n b,\n)` form yields names rather than `(a` -- which
|
|
189
|
+
// the identifier filter below silently dropped, taking the whole file's target
|
|
190
|
+
// set with it.
|
|
191
|
+
return (list ?? '')
|
|
192
|
+
.replace(/[()\n]/g, ' ')
|
|
193
|
+
.split(',')
|
|
194
|
+
.map((raw) => raw
|
|
195
|
+
.trim()
|
|
196
|
+
.split(/\s+as\s+/)
|
|
197
|
+
.pop()
|
|
198
|
+
?.trim() ?? '')
|
|
199
|
+
.filter((name) => /^[A-Za-z_$][\w$]*$/.test(name));
|
|
200
|
+
}
|
|
201
|
+
/** Python first-party imports: `from x import y`, minus the stdlib by name. */
|
|
202
|
+
function pythonImportedTargets(code) {
|
|
203
|
+
const names = new Set();
|
|
204
|
+
for (const m of code.matchAll(PY_FROM_IMPORT)) {
|
|
205
|
+
const root = m[1].split('.')[0];
|
|
206
|
+
if (PY_STDLIB.has(root))
|
|
207
|
+
continue;
|
|
208
|
+
for (const n of clauseNames(m[2]))
|
|
209
|
+
names.add(n);
|
|
210
|
+
}
|
|
211
|
+
return names;
|
|
212
|
+
}
|
|
213
|
+
/** JS/TS first-party imports: any `import`/`require` with a relative specifier. */
|
|
214
|
+
function jsImportedTargets(code) {
|
|
215
|
+
const names = new Set();
|
|
216
|
+
for (const re of [JS_RELATIVE_IMPORT, JS_RELATIVE_REQUIRE]) {
|
|
217
|
+
// Reset explicitly: these are module-level `/g` patterns, so a leftover
|
|
218
|
+
// `lastIndex` from an earlier file would silently skip the head of this one.
|
|
219
|
+
re.lastIndex = 0;
|
|
220
|
+
for (const m of code.matchAll(re)) {
|
|
221
|
+
for (const n of clauseNames(m[1]))
|
|
222
|
+
names.add(n);
|
|
223
|
+
if (m[2])
|
|
224
|
+
names.add(m[2]);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return names;
|
|
228
|
+
}
|
|
229
|
+
/** Names imported from first-party (relative) modules. */
|
|
230
|
+
function importedTargets(code, python) {
|
|
231
|
+
return python ? pythonImportedTargets(code) : jsImportedTargets(code);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Grow `targets` with local declarations that themselves reach a target, to a
|
|
235
|
+
* fixpoint.
|
|
236
|
+
*
|
|
237
|
+
* This is the concession the issue asked for. Without it, a test that goes
|
|
238
|
+
* through a helper defined in the same file -- `roundTrip()` calling
|
|
239
|
+
* `load(save(v))` -- reads as never touching its target, and the rule
|
|
240
|
+
* confidently reports a correct test as vacuous. One hop covers the common
|
|
241
|
+
* case; the fixpoint covers a chain of them.
|
|
242
|
+
*/
|
|
243
|
+
function closeOverLocals(code, targets, python) {
|
|
244
|
+
const decls = [];
|
|
245
|
+
const re = python ? PY_LOCAL_DECL : JS_LOCAL_DECL;
|
|
246
|
+
re.lastIndex = 0;
|
|
247
|
+
const matches = [...code.matchAll(re)];
|
|
248
|
+
for (let i = 0; i < matches.length; i += 1) {
|
|
249
|
+
const m = matches[i];
|
|
250
|
+
const names = boundNames(m, python);
|
|
251
|
+
if (names.length === 0)
|
|
252
|
+
continue;
|
|
253
|
+
const start = m.index + m[0].length;
|
|
254
|
+
const end = matches[i + 1]?.index ?? code.length;
|
|
255
|
+
decls.push({ names, body: code.slice(start, end) });
|
|
256
|
+
}
|
|
257
|
+
if (!python) {
|
|
258
|
+
JS_DESTRUCTURED_ASSIGN.lastIndex = 0;
|
|
259
|
+
for (const m of code.matchAll(JS_DESTRUCTURED_ASSIGN)) {
|
|
260
|
+
const names = (m[1] ?? '')
|
|
261
|
+
.split(',')
|
|
262
|
+
.map((raw) => raw.split(':').pop().trim())
|
|
263
|
+
.filter((n) => /^[A-Za-z_$][\w$]*$/.test(n));
|
|
264
|
+
// The RHS alone is the body here: unlike a declaration, an assignment
|
|
265
|
+
// does not own the text that follows it.
|
|
266
|
+
if (names.length > 0)
|
|
267
|
+
decls.push({ names, body: m[2] ?? '' });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const reaching = new Set(targets);
|
|
271
|
+
let grew = true;
|
|
272
|
+
while (grew) {
|
|
273
|
+
grew = false;
|
|
274
|
+
for (const d of decls) {
|
|
275
|
+
if (d.names.every((n) => reaching.has(n)))
|
|
276
|
+
continue;
|
|
277
|
+
const reaches = [...reaching].some((t) => identifierPattern(t).test(d.body));
|
|
278
|
+
if (!reaches)
|
|
279
|
+
continue;
|
|
280
|
+
for (const n of d.names)
|
|
281
|
+
reaching.add(n);
|
|
282
|
+
grew = true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return reaching;
|
|
286
|
+
}
|
|
287
|
+
/** Body lines of a test, paired with their 1-based line numbers. */
|
|
288
|
+
function bodyLines(code, block) {
|
|
289
|
+
const first = lineOf(code, block.bodyStart);
|
|
290
|
+
return block.body.split('\n').map((text, i) => ({ text, line: first + i }));
|
|
291
|
+
}
|
|
292
|
+
function isComment(line) {
|
|
293
|
+
const s = line.trim();
|
|
294
|
+
return s.startsWith('#') || s.startsWith('//') || s.startsWith('*');
|
|
295
|
+
}
|
|
296
|
+
/** The text inside a balanced `expect(...)`, or null. */
|
|
297
|
+
function expectArgument(line) {
|
|
298
|
+
const open = line.indexOf('expect(');
|
|
299
|
+
if (open < 0)
|
|
300
|
+
return null;
|
|
301
|
+
let depth = 0;
|
|
302
|
+
for (let i = open + 'expect'.length; i < line.length; i += 1) {
|
|
303
|
+
if (line[i] === '(')
|
|
304
|
+
depth += 1;
|
|
305
|
+
else if (line[i] === ')') {
|
|
306
|
+
depth -= 1;
|
|
307
|
+
if (depth === 0)
|
|
308
|
+
return line.slice(open + 'expect('.length, i);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The matcher following `expect(...)`: its argument, and whether it is negated.
|
|
315
|
+
*
|
|
316
|
+
* The negation is returned rather than swallowed. `expect(v).not.toBe(v)` has
|
|
317
|
+
* identical texts on both sides, so a comparison that ignored `.not` reported it
|
|
318
|
+
* as `VAC-001` -- "no implementation can fail it" -- about an assertion that can
|
|
319
|
+
* only ever FAIL. Inverting the rule's own claim is worse than missing the case,
|
|
320
|
+
* and it was a `critical` finding that BLOCKS promotion. `pyTautology` already
|
|
321
|
+
* guarded the analogous `assert False`; the JS path had no equivalent.
|
|
322
|
+
*/
|
|
323
|
+
function matcherOf(line) {
|
|
324
|
+
const m = /\.\s*(not\s*\.\s*)?to\w+\s*\(([^()]*)\)/.exec(line);
|
|
325
|
+
return m ? { argument: m[2], negated: m[1] !== undefined } : null;
|
|
326
|
+
}
|
|
327
|
+
function normalize(expr) {
|
|
328
|
+
return expr.replace(/\s+/g, '');
|
|
329
|
+
}
|
|
330
|
+
/** VAC-001 for one JS/TS line. */
|
|
331
|
+
function jsTautology(line) {
|
|
332
|
+
const actual = expectArgument(line);
|
|
333
|
+
const matcher = matcherOf(line);
|
|
334
|
+
if (actual === null || matcher === null || matcher.negated)
|
|
335
|
+
return false;
|
|
336
|
+
const a = normalize(actual);
|
|
337
|
+
const e = normalize(matcher.argument);
|
|
338
|
+
if (a === '' || e === '')
|
|
339
|
+
return false;
|
|
340
|
+
return a === e;
|
|
341
|
+
}
|
|
342
|
+
/** VAC-001 for one pytest line. */
|
|
343
|
+
function pyTautology(line) {
|
|
344
|
+
const t = line.trim();
|
|
345
|
+
// `assert False` is a deliberate unreachable marker -- it can only ever fail,
|
|
346
|
+
// so it is the opposite of vacuous and must never be flagged.
|
|
347
|
+
if (/^assert\s+True\s*(?:,|$)/.test(t))
|
|
348
|
+
return true;
|
|
349
|
+
// Same reason `.not` is excluded above: `assert x != x` can only ever fail.
|
|
350
|
+
const cmp = /^assert\s+(.+?)\s*==\s*(.+?)\s*(?:,|$)/.exec(t);
|
|
351
|
+
if (!cmp)
|
|
352
|
+
return false;
|
|
353
|
+
return normalize(cmp[1]) === normalize(cmp[2]);
|
|
354
|
+
}
|
|
355
|
+
function scanBlock(code, block, file, python, reaching, annotated, skipped) {
|
|
356
|
+
const lines = bodyLines(code, block).filter((l) => !isComment(l.text));
|
|
357
|
+
const targets = annotated !== null ? new Set([annotated]) : reaching;
|
|
358
|
+
return [
|
|
359
|
+
...tautologies(lines, block, file, python),
|
|
360
|
+
...targetNeverInvoked(block, file, reaching, annotated),
|
|
361
|
+
...absenceOnly(lines, block, file, python, targets, skipped),
|
|
362
|
+
];
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* A pattern matching `name` as a whole identifier.
|
|
366
|
+
*
|
|
367
|
+
* NOT `\b${name}\b`, which is wrong for the `$` that JS identifiers allow and
|
|
368
|
+
* `\w` does not. `\b` sits between a `\w` and a non-`\w`, so `\b$fetch\b` can
|
|
369
|
+
* only match after a word character -- a `$`-prefixed import never matched at
|
|
370
|
+
* all, and `\bfoo$bar\b` can never match. That silently shrank the target set,
|
|
371
|
+
* producing a `VAC-002` false positive on a test invoking its target on the only
|
|
372
|
+
* line it had. Lookarounds over `[\w$]` give the boundary JS actually has.
|
|
373
|
+
*
|
|
374
|
+
* `name` is escaped as well: every call site filters to `[A-Za-z_$][\w$]*`
|
|
375
|
+
* today, so nothing can currently smuggle a metacharacter through, but the
|
|
376
|
+
* escape means a widened filter cannot turn into a silent semantic change.
|
|
377
|
+
*/
|
|
378
|
+
function identifierPattern(name) {
|
|
379
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
380
|
+
return new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`);
|
|
381
|
+
}
|
|
382
|
+
/** Does `text` name any symbol in `targets`? */
|
|
383
|
+
function mentionsAny(text, targets) {
|
|
384
|
+
return (targets !== null &&
|
|
385
|
+
[...targets].some((t) => identifierPattern(t).test(text)));
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* VAC-001 -- deterministic, hence `critical`: an expectation identical to the
|
|
389
|
+
* value it checks cannot fail for any implementation.
|
|
390
|
+
*/
|
|
391
|
+
function tautologies(lines, block, file, python) {
|
|
392
|
+
return lines
|
|
393
|
+
.filter((l) => (python ? pyTautology(l.text) : jsTautology(l.text)))
|
|
394
|
+
.map((l) => mk(file, l.line, 'VAC-001', 'critical', block.name, 'Assertion compares a value with itself; no implementation can fail it.', 'Assert the value the code under test should have produced, not the input.'));
|
|
395
|
+
}
|
|
396
|
+
/** VAC-002 -- the target is never referenced anywhere in the body. */
|
|
397
|
+
function targetNeverInvoked(block, file, reaching, annotated) {
|
|
398
|
+
if (annotated !== null) {
|
|
399
|
+
if (mentionsAny(block.body, new Set([annotated])))
|
|
400
|
+
return [];
|
|
401
|
+
return [
|
|
402
|
+
mk(file, block.line, 'VAC-002', 'warning', block.name, `Declared target \`${annotated}\` is never referenced in this test.`, 'Invoke the target, or correct the @covers annotation to name what the test actually exercises.', 'annotated'),
|
|
403
|
+
];
|
|
404
|
+
}
|
|
405
|
+
if (reaching === null || mentionsAny(block.body, reaching))
|
|
406
|
+
return [];
|
|
407
|
+
return [
|
|
408
|
+
mk(file, block.line, 'VAC-002', 'warning', block.name, 'This test references none of the symbols the file imports from first-party modules.', 'If the target is reached indirectly, add `// @covers <symbol>` so the check verifies the real target instead of inferring one.', 'import-inferred'),
|
|
409
|
+
];
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* VAC-003 -- every assertion is an absence, AND none of them observes the
|
|
413
|
+
* target.
|
|
414
|
+
*
|
|
415
|
+
* That second clause is not a refinement, it is the rule. The first cut omitted
|
|
416
|
+
* it and reported 254 findings across canary's 2154 tests, nearly all of the
|
|
417
|
+
* form `expect(isCI()).toBe(false)` -- a perfectly good negative test, because
|
|
418
|
+
* the assertion invokes the target, so the target provably ran and the `false`
|
|
419
|
+
* is load-bearing. The #486 katana defect is the other shape:
|
|
420
|
+
* `expect(existsSync(ledger)).toBe(false)` after a bare call, where the absence
|
|
421
|
+
* is observed on a BYSTANDER and the buggy code satisfied it by exiting before
|
|
422
|
+
* the write. Reported at the first absence assertion, the line an author adds a
|
|
423
|
+
* precondition next to.
|
|
424
|
+
*/
|
|
425
|
+
function absenceOnly(lines, block, file, python, targets, skipped) {
|
|
426
|
+
if (targets === null)
|
|
427
|
+
return [];
|
|
428
|
+
const anyAssertion = python ? PY_ASSERTION : JS_ASSERTION;
|
|
429
|
+
const absence = python ? PY_ABSENCE_ASSERTION : ABSENCE_ASSERTION;
|
|
430
|
+
const assertions = lines.filter((l) => anyAssertion.test(l.text));
|
|
431
|
+
if (assertions.length === 0) {
|
|
432
|
+
// Zero recognised assertions is unanswerable, not clean: either the test
|
|
433
|
+
// asserts nothing (which is `LINT-006`'s finding, not this rule's) or its
|
|
434
|
+
// assertion style is one the vocabulary does not know. Both are "cannot
|
|
435
|
+
// verify", so both are recorded rather than passed over in silence.
|
|
436
|
+
skipped.push({
|
|
437
|
+
name: `VAC-003 (${block.name})`,
|
|
438
|
+
reason: 'no recognised assertion, so absence-only could not be judged -- the test may assert nothing (LINT-006) or use an unrecognised assertion style',
|
|
439
|
+
});
|
|
440
|
+
return [];
|
|
441
|
+
}
|
|
442
|
+
if (!assertions.every((l) => absence.test(l.text)))
|
|
443
|
+
return [];
|
|
444
|
+
if (assertions.some((l) => mentionsAny(l.text, targets)))
|
|
445
|
+
return [];
|
|
446
|
+
return [
|
|
447
|
+
mk(file, assertions[0].line, 'VAC-003', 'warning', block.name, 'Every assertion in this test asserts an absence, and none of them observes the target.', 'Add one assertion proving the operation actually ran (exit code, returned value, a positive existence) -- otherwise the test passes identically when the code crashed before doing anything.'),
|
|
448
|
+
];
|
|
449
|
+
}
|
|
450
|
+
/** A zero-denominator result that names why it could not measure. */
|
|
451
|
+
function unreadable(path, reason) {
|
|
452
|
+
return { checked: 0, findings: [], skipped: [{ name: path, reason }] };
|
|
453
|
+
}
|
|
454
|
+
function readSource(path) {
|
|
455
|
+
try {
|
|
456
|
+
return { ok: true, source: readFileSync(path, 'utf-8') };
|
|
457
|
+
}
|
|
458
|
+
catch (e) {
|
|
459
|
+
const code = e.code ?? 'unknown error';
|
|
460
|
+
return { ok: false, reason: `could not be read (${code})` };
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* The target set for the `import-inferred` rung, or `null` when there is none.
|
|
465
|
+
*
|
|
466
|
+
* Imports are read from the ORIGINAL source, not the blanked copy: blanking
|
|
467
|
+
* replaces literal CONTENT with spaces, so `from './store.js'` becomes
|
|
468
|
+
* `from ' '` and the leading `.` that marks a first-party module is
|
|
469
|
+
* gone -- which silently collapsed every JS/TS file to "target unresolvable".
|
|
470
|
+
*
|
|
471
|
+
* The cost is that an import written inside a fixture string is read as real.
|
|
472
|
+
* That only ever ADDS names to the target set, which makes VAC-002 quieter,
|
|
473
|
+
* never noisier -- the safe direction for a heuristic-tier rule.
|
|
474
|
+
*/
|
|
475
|
+
function resolveTargets(source, code, python) {
|
|
476
|
+
const imported = importedTargets(source, python);
|
|
477
|
+
return imported.size > 0 ? closeOverLocals(code, imported, python) : null;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* The `@covers` symbol declared above `block`, or `null`.
|
|
481
|
+
*
|
|
482
|
+
* Two bugs lived in the naive version, and both produced a FALSE BLOCK, which is
|
|
483
|
+
* the worst outcome available here: `annotated` is the one vacuity fidelity
|
|
484
|
+
* allowed to block a promotion, so a stray annotation failed a correct test.
|
|
485
|
+
*
|
|
486
|
+
* - The window was a blind 400-character look-back, so it reached over the
|
|
487
|
+
* PREVIOUS test and its annotation. It is now floored at `floor` -- the end of
|
|
488
|
+
* the previous test's body -- so only text genuinely between the two
|
|
489
|
+
* declarations can be read.
|
|
490
|
+
* - `exec` returns the match nearest the START of the window, i.e. the FARTHEST
|
|
491
|
+
* annotation above the declaration. It now takes the last, which is the
|
|
492
|
+
* nearest.
|
|
493
|
+
*/
|
|
494
|
+
function annotationFor(code, block, floor) {
|
|
495
|
+
const from = Math.max(floor, block.bodyStart - 400);
|
|
496
|
+
const window = code.slice(from, block.bodyStart);
|
|
497
|
+
const matches = [...window.matchAll(COVERS_PRAGMA)];
|
|
498
|
+
return matches.at(-1)?.[1] ?? null;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Scan one test file for vacuous tests.
|
|
502
|
+
*
|
|
503
|
+
* `checked` counts the tests actually analysed. A file no ruleset can parse
|
|
504
|
+
* yields `checked: 0` plus a skip entry, never an empty finding list that reads
|
|
505
|
+
* as clean.
|
|
506
|
+
*/
|
|
507
|
+
export function scanVacuity(path) {
|
|
508
|
+
const framework = frameworkForPath(path);
|
|
509
|
+
if (framework === null) {
|
|
510
|
+
return unreadable(path, 'no ruleset parses this extension, so a clean result would be meaningless');
|
|
511
|
+
}
|
|
512
|
+
const python = framework === 'pytest';
|
|
513
|
+
const read = readSource(path);
|
|
514
|
+
if (!read.ok)
|
|
515
|
+
return unreadable(path, read.reason);
|
|
516
|
+
const source = read.source;
|
|
517
|
+
// Whole-source blanking, offset-preserving: a `expect(true).toBe(true)`
|
|
518
|
+
// carried as fixture DATA is not a vacuous test, and a `it(...)` inside a
|
|
519
|
+
// string must not be able to truncate a real test's body (#590).
|
|
520
|
+
const code = blankStringContent(source, { python });
|
|
521
|
+
const blocks = enumerateTests(code, source, python);
|
|
522
|
+
const reaching = resolveTargets(source, code, python);
|
|
523
|
+
const skipped = [];
|
|
524
|
+
const findings = scanAllBlocks({ code, path, python, reaching }, blocks, skipped);
|
|
525
|
+
const result = {
|
|
526
|
+
checked: blocks.length,
|
|
527
|
+
findings,
|
|
528
|
+
};
|
|
529
|
+
if (skipped.length > 0)
|
|
530
|
+
result.skipped = skipped;
|
|
531
|
+
return result;
|
|
532
|
+
}
|
|
533
|
+
function scanAllBlocks(ctx, blocks, skipped) {
|
|
534
|
+
const findings = [];
|
|
535
|
+
for (let i = 0; i < blocks.length; i += 1) {
|
|
536
|
+
const block = blocks[i];
|
|
537
|
+
// An annotation may only be read from the gap between the previous test's
|
|
538
|
+
// end and this declaration -- see `annotationFor`.
|
|
539
|
+
const prev = blocks[i - 1];
|
|
540
|
+
const floor = prev ? prev.bodyStart + prev.body.length : 0;
|
|
541
|
+
const annotated = annotationFor(ctx.code, block, floor);
|
|
542
|
+
if (annotated === null && ctx.reaching === null) {
|
|
543
|
+
// Both target-dependent rules go dark together, and both say so. VAC-003
|
|
544
|
+
// asks "does any assertion observe the target", which is unanswerable
|
|
545
|
+
// without a target -- so it abstains rather than falling back to the
|
|
546
|
+
// 254-false-positive version of itself.
|
|
547
|
+
skipped.push({
|
|
548
|
+
name: `VAC-002/VAC-003 (${block.name})`,
|
|
549
|
+
reason: 'target unresolvable: no @covers annotation and no first-party relative import to infer from',
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
findings.push(...scanBlock(ctx.code, block, ctx.path, ctx.python, ctx.reaching, annotated, skipped));
|
|
553
|
+
}
|
|
554
|
+
return findings;
|
|
555
|
+
}
|
|
556
|
+
//# sourceMappingURL=vacuity-scanner.js.map
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import { spawnSync } from 'node:child_process';
|
|
37
37
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
38
38
|
import { join } from 'node:path';
|
|
39
|
+
import { ensureAscii } from '../util/ensure-ascii.js';
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
40
41
|
// Python-compatibility helpers (copied locally per-module, matching reporter.ts)
|
|
41
42
|
// ---------------------------------------------------------------------------
|
|
@@ -58,13 +59,6 @@ function pyTruthy(value) {
|
|
|
58
59
|
function pyGet(obj, key, fallback) {
|
|
59
60
|
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
|
|
60
61
|
}
|
|
61
|
-
/**
|
|
62
|
-
* Reproduce Python's `json.dumps(..., ensure_ascii=True)` (the library default)
|
|
63
|
-
* on `JSON.stringify` output: escape every code point >= 0x80 as `\uXXXX`.
|
|
64
|
-
*/
|
|
65
|
-
function ensureAscii(json) {
|
|
66
|
-
return json.replace(/[\u0080-\uffff]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
|
|
67
|
-
}
|
|
68
62
|
/** Python `str[:n]` by CODE POINT (never splits a surrogate pair). */
|
|
69
63
|
function codePointSlice(s, n) {
|
|
70
64
|
return [...s].slice(0, n).join('');
|
|
@@ -120,7 +114,7 @@ export const defaultSubprocess = (cmd, opts = {}) => {
|
|
|
120
114
|
// ---------------------------------------------------------------------------
|
|
121
115
|
// Schema
|
|
122
116
|
// ---------------------------------------------------------------------------
|
|
123
|
-
|
|
117
|
+
const SCHEMA_VERSION = 'https://github.com/bop-clocktower/canary/schemas/workflow-mapping/v1';
|
|
124
118
|
// Word-list used for automatic semantic-role heuristics.
|
|
125
119
|
const ROLE_TRIGGERS = {
|
|
126
120
|
qa_passed: ['qa pass', 'qa passed', 'qa done', 'tested', 'verified'],
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync, readdirSync } from 'node:fs';
|
|
10
10
|
import { join, relative, sep } from 'node:path';
|
|
11
|
-
import {
|
|
11
|
+
import { CONFIG_PROBES, inferPlaywrightTestType, probeFramework, } from './framework-probes.js';
|
|
12
12
|
import { comparePathParts, globDirs, isFile, parseJsonOrNull, readTextOrNull, } from './fs-glob.js';
|
|
13
13
|
/**
|
|
14
14
|
* Workspace package globs declared at *root*, from pnpm-workspace.yaml or
|
|
@@ -146,13 +146,13 @@ function toPosixRel(root, dir) {
|
|
|
146
146
|
function configFindings(dir, rel) {
|
|
147
147
|
const out = [];
|
|
148
148
|
const seen = new Set();
|
|
149
|
-
for (const [filename, framework, shape, confidence] of
|
|
149
|
+
for (const [filename, framework, shape, confidence] of CONFIG_PROBES) {
|
|
150
150
|
if (!isFile(join(dir, filename)))
|
|
151
151
|
continue;
|
|
152
152
|
// Mirrors the config tier's own refinement: a playwright config with no
|
|
153
153
|
// UI-fixture spec is an API suite, and the shape decides which skills ship.
|
|
154
154
|
const refined = framework === 'playwright' && shape === 'e2e_ui'
|
|
155
|
-
?
|
|
155
|
+
? inferPlaywrightTestType(dir)
|
|
156
156
|
: shape;
|
|
157
157
|
// NUL separates the parts because it cannot occur in a path, a framework
|
|
158
158
|
// name, or a shape -- so `a\0b` can never collide with `a` + `\0b`. Written
|
|
@@ -177,14 +177,15 @@ function configFindings(dir, rel) {
|
|
|
177
177
|
* Findings for one package: every config-tier match, or -- only when the config
|
|
178
178
|
* tier found nothing at all -- a single content-tier answer.
|
|
179
179
|
*
|
|
180
|
-
* The language tier is deliberately withheld; see `
|
|
181
|
-
* root `language:` per package would invent findings (#504 part 1,
|
|
180
|
+
* The language tier is deliberately withheld; see `probeFramework` for why
|
|
181
|
+
* inheriting a root `language:` per package would invent findings (#504 part 1,
|
|
182
|
+
* spec test 8).
|
|
182
183
|
*/
|
|
183
184
|
function probePackage(dir, config, rel) {
|
|
184
185
|
const fromConfig = configFindings(dir, rel);
|
|
185
186
|
if (fromConfig.length > 0)
|
|
186
187
|
return fromConfig;
|
|
187
|
-
const [framework, shape, source, confidence] =
|
|
188
|
+
const [framework, shape, source, confidence] = probeFramework(dir, config, ['content']);
|
|
188
189
|
return framework === null
|
|
189
190
|
? []
|
|
190
191
|
: [{ dir: rel, framework, shape, source, confidence }];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"fallback": "junior",
|
|
4
|
+
"minDetectionConfidence": 0.5,
|
|
5
|
+
"minDetectionSignals": 2,
|
|
6
|
+
"detectionMap": {
|
|
7
|
+
"sdet": "sdet",
|
|
8
|
+
"manual": "manual"
|
|
9
|
+
},
|
|
10
|
+
"personas": [
|
|
11
|
+
{
|
|
12
|
+
"id": "sdet",
|
|
13
|
+
"label": "Senior SDET",
|
|
14
|
+
"audience": "Writes and owns automated tests daily; fluent in the framework and the codebase.",
|
|
15
|
+
"depth": "terse",
|
|
16
|
+
"formats": ["bullets", "code"],
|
|
17
|
+
"reasoning": false
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "junior",
|
|
21
|
+
"label": "Junior SDET",
|
|
22
|
+
"audience": "Writes automated tests but is still building judgement about which ones matter.",
|
|
23
|
+
"depth": "brief",
|
|
24
|
+
"formats": ["bullets", "code", "rationale"],
|
|
25
|
+
"reasoning": true
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"id": "manual",
|
|
29
|
+
"label": "Manual tester",
|
|
30
|
+
"audience": "Tests by hand and reads test output; may not read or write the framework's code.",
|
|
31
|
+
"depth": "guided",
|
|
32
|
+
"formats": ["numbered-steps", "rationale"],
|
|
33
|
+
"reasoning": true
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|