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.
Files changed (47) hide show
  1. package/dist/engine/analysis/cli.js +116 -54
  2. package/dist/engine/analysis/engine.js +34 -16
  3. package/dist/engine/analysis/reports.js +5 -4
  4. package/dist/engine/cli-commands.js +249 -41
  5. package/dist/engine/cli-common.js +15 -24
  6. package/dist/engine/cli.core.js +37 -11
  7. package/dist/engine/cli.js +2 -2
  8. package/dist/engine/company-knowledge-cli.js +2 -2
  9. package/dist/engine/core/adoption.js +408 -0
  10. package/dist/engine/core/framework-probes.js +7 -7
  11. package/dist/engine/core/fs-glob.js +2 -2
  12. package/dist/engine/core/gate-result.js +17 -0
  13. package/dist/engine/core/migrator.js +9 -17
  14. package/dist/engine/core/pattern-matcher.js +23 -5
  15. package/dist/engine/core/persona.js +421 -0
  16. package/dist/engine/core/promotion-verdict.js +261 -0
  17. package/dist/engine/core/reporter.js +1 -9
  18. package/dist/engine/core/skill-examples.js +292 -0
  19. package/dist/engine/core/skill-surfaces.js +307 -0
  20. package/dist/engine/core/static-linter.js +310 -38
  21. package/dist/engine/core/ticket-updater.js +1 -7
  22. package/dist/engine/core/vacuity-scanner.js +556 -0
  23. package/dist/engine/core/workflow-discovery.js +2 -8
  24. package/dist/engine/core/workspace-detect.js +7 -6
  25. package/dist/engine/data/personas/registry.json +36 -0
  26. package/dist/engine/guardian/adjudication.js +5 -5
  27. package/dist/engine/guardian/analysis-emit.js +13 -27
  28. package/dist/engine/guardian/cli.js +30 -43
  29. package/dist/engine/guardian/coverage.js +1 -1
  30. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
  31. package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
  32. package/dist/engine/guardian/pr-check.js +5 -15
  33. package/dist/engine/guardian/pr-comment.js +4 -3
  34. package/dist/engine/history/cli.js +210 -6
  35. package/dist/engine/history/ndjson-store.js +9 -5
  36. package/dist/engine/history/record.js +34 -5
  37. package/dist/engine/history/run-recorder.js +165 -0
  38. package/dist/engine/history/schema.js +25 -7
  39. package/dist/engine/history/store.js +9 -0
  40. package/dist/engine/mcp-server.js +35 -13
  41. package/dist/engine/skills-cli.js +133 -11
  42. package/dist/engine/util/ensure-ascii.js +37 -0
  43. package/dist/engine/workflow-cli.js +6 -6
  44. package/dist/gate-result.d.ts +11 -0
  45. package/dist/gate-result.js +18 -0
  46. package/dist/uninstall.js +12 -5
  47. 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
- export const SCHEMA_VERSION = 'https://github.com/bop-clocktower/canary/schemas/workflow-mapping/v1';
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 { _CONFIG_PROBES, inferPlaywrightShape, probe, } from './framework-probes.js';
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 _CONFIG_PROBES) {
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
- ? inferPlaywrightShape(dir)
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 `probe` for why inheriting a
181
- * root `language:` per package would invent findings (#504 part 1, spec test 8).
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] = probe(dir, config, ['content']);
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
+ }