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.
Files changed (83) hide show
  1. package/agents/skills/README.md +308 -0
  2. package/agents/skills/canary:generate.md +49 -0
  3. package/agents/skills/canary:init.md +37 -0
  4. package/agents/skills/canary:migrate.md +66 -0
  5. package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
  6. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  7. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  11. package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
  12. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  14. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
  15. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  16. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  17. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  18. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  19. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  21. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  22. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  27. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  28. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  29. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
  30. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  31. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  32. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  37. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  38. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  42. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  44. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  45. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  46. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  52. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  53. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  54. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  55. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  56. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  57. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  58. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  59. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  60. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  61. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  62. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  63. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  64. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  65. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  66. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  67. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  68. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  69. package/agents/skills/lib/parse-args.mjs +214 -0
  70. package/dist/engine/cli-commands.js +3 -65
  71. package/dist/engine/core/quality-scorer.js +15 -2
  72. package/dist/engine/core/skill-dispatch.js +115 -0
  73. package/dist/engine/core/skill-examples.js +103 -3
  74. package/dist/engine/core/skill-registry.js +59 -4
  75. package/dist/engine/core/test-files.js +77 -0
  76. package/dist/engine/core/vacuity-scanner.js +184 -14
  77. package/dist/engine/guardian/analysis-emit.js +7 -2
  78. package/dist/engine/guardian/cli.js +121 -9
  79. package/dist/engine/guardian/pr-check.js +317 -18
  80. package/dist/engine/skills-cli.js +123 -19
  81. package/dist/reporters/testtracker.d.ts +1 -1
  82. package/dist/reporters/testtracker.js +1 -1
  83. package/package.json +3 -2
@@ -67,11 +67,25 @@ function codePointCompare(a, b) {
67
67
  }
68
68
  return ca.length - cb.length;
69
69
  }
70
- /** Bundled skills live at `<repo>/agents/skills`. Python: `_AGENTS_SKILLS_DIR`. */
70
+ /**
71
+ * Bundled skills live at `<root>/agents/skills`, where `<root>` is three
72
+ * directories above this module. Python: `_AGENTS_SKILLS_DIR`.
73
+ *
74
+ * The "three levels up" is a PACKAGING CONTRACT, not an implementation detail
75
+ * (#757). It holds in the source tree (`ts/src/core`), in the compiled tree
76
+ * (`ts/dist/core`), and in the published npm package (`dist/engine/core`) --
77
+ * but only while whatever sits at that root actually ships an `agents/skills`.
78
+ * It did not: `canary-test-cli@7.1.0` published `bin/` and `dist/` only, so an
79
+ * installed CLI resolved this to a directory that has never existed and
80
+ * reported every bundled skill as absent, from any cwd. Exported so
81
+ * `ts/test/skill-packaging.test.ts` can pin both halves of the contract.
82
+ */
83
+ export function bundledSkillsDirFrom(moduleDir) {
84
+ // moduleDir = <root>/<a>/<b>/core -> the root is three levels up.
85
+ return resolve(moduleDir, '..', '..', '..', 'agents', 'skills');
86
+ }
71
87
  function defaultAgentsSkillsDir() {
72
- const here = dirname(fileURLToPath(import.meta.url));
73
- // here = ts/src/core -> repo root is three levels up (Python: parents[2]).
74
- return resolve(here, '..', '..', '..', 'agents', 'skills');
88
+ return bundledSkillsDirFrom(dirname(fileURLToPath(import.meta.url)));
75
89
  }
76
90
  /**
77
91
  * A discovered skill (Python: `SkillInfo` dataclass).
@@ -150,6 +164,47 @@ export class SkillRegistry {
150
164
  }
151
165
  return [...skills.values()].sort((a, b) => codePointCompare(a.name, b.name));
152
166
  }
167
+ /**
168
+ * The directory tiers {@link discover} consults, in precedence order.
169
+ *
170
+ * Exists so an empty discovery can state its denominator (#757). "No skills
171
+ * found." is a claim about the world; what discovery can actually attest is
172
+ * "none of THESE four roots held one", and the two read very differently to
173
+ * someone standing in a directory full of SKILL.md files. The local tier
174
+ * walks cwd up to the git root, so it renders as the range it swept rather
175
+ * than one line per ancestor.
176
+ */
177
+ searchRoots(root) {
178
+ const searchRoot = resolve(root ?? process.cwd());
179
+ const ancestors = SkillRegistry.ancestorsToGitRoot(searchRoot);
180
+ const localDirs = ancestors.map((a) => join(a, '.canary', 'skills'));
181
+ const overlaysRoot = join(this.home, '.canary', 'overlays');
182
+ const globalDir = join(this.home, '.canary', 'skills');
183
+ return [
184
+ {
185
+ tier: 'bundled',
186
+ path: this.agentsSkillsDir,
187
+ exists: existsSync(this.agentsSkillsDir),
188
+ },
189
+ {
190
+ tier: 'overlay',
191
+ path: overlaysRoot,
192
+ exists: SkillRegistry.isDir(overlaysRoot),
193
+ },
194
+ {
195
+ tier: 'global',
196
+ path: globalDir,
197
+ exists: SkillRegistry.isDir(globalDir),
198
+ },
199
+ {
200
+ tier: 'local',
201
+ path: localDirs.length > 1
202
+ ? `${localDirs[0]} (and ${localDirs.length - 1} ancestor(s) up to the git root)`
203
+ : (localDirs[0] ?? join(searchRoot, '.canary', 'skills')),
204
+ exists: localDirs.some((d) => existsSync(d)),
205
+ },
206
+ ];
207
+ }
153
208
  /** Return the SkillInfo for `name` honoring precedence, or null. */
154
209
  find(name, root) {
155
210
  for (const skill of this.discover(root)) {
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The one answer to "which files in this tree are tests?" (#755).
3
+ *
4
+ * Extracted from `cli-commands.ts`, where it was private, because
5
+ * `canary-cassandra`'s skill CLI needs the SAME answer as `canary
6
+ * vacuity-check`. The four Tier-0 detectors are meant to be mergeable by a
7
+ * single consumer, and two collectors disagreeing about the denominator is the
8
+ * quietest way for that to stop being true: the same run would report a
9
+ * different `checked` depending on which door it came through.
10
+ *
11
+ * The walk's ignore set is load-bearing (#566): a dependency's own test suite
12
+ * is not the consumer's to fix. One downstream run before that fix produced 254
13
+ * of 256 findings inside `node_modules`, with the only `critical` in vendored
14
+ * code.
15
+ */
16
+ import { readdirSync, statSync } from 'node:fs';
17
+ import { basename, join } from 'node:path';
18
+ import { JS_TEST_EXTENSIONS } from './static-linter.js';
19
+ /** True when `p` is a readable directory. A missing path is not one. */
20
+ export function isDir(p) {
21
+ try {
22
+ return statSync(p).isDirectory();
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ /** Directories never worth walking; see the module docstring for why. */
29
+ const IGNORED_DIRS = new Set([
30
+ 'node_modules',
31
+ '.git',
32
+ '__pycache__',
33
+ '.venv',
34
+ 'venv',
35
+ 'dist',
36
+ 'build',
37
+ '.next',
38
+ '.nuxt',
39
+ ]);
40
+ function walkFiles(dir) {
41
+ const out = [];
42
+ let entries;
43
+ try {
44
+ entries = readdirSync(dir, { withFileTypes: true });
45
+ }
46
+ catch {
47
+ return out;
48
+ }
49
+ for (const e of entries) {
50
+ const full = join(dir, e.name);
51
+ if (e.isDirectory()) {
52
+ if (!IGNORED_DIRS.has(e.name))
53
+ out.push(...walkFiles(full));
54
+ }
55
+ else if (e.isFile())
56
+ out.push(full);
57
+ }
58
+ return out;
59
+ }
60
+ /**
61
+ * `test_*.py` plus `*.test.*` / `*.spec.*` over every extension the scanners
62
+ * can actually read -- `.mjs` and `.cjs` included, which is the half of #566
63
+ * that made a directory of ESM tests collect zero files.
64
+ */
65
+ const JS_TEST_FILE_RE = new RegExp(`\\.(test|spec)\\.(${JS_TEST_EXTENSIONS.map((e) => e.slice(1)).join('|')})$`);
66
+ /** Recursive test-file glob matching Python's `rglob` union, sorted by path. */
67
+ export function collectTestFiles(dir) {
68
+ return walkFiles(dir)
69
+ .filter((p) => {
70
+ const b = basename(p);
71
+ return ((b.startsWith('test_') && b.endsWith('.py')) || JS_TEST_FILE_RE.test(b));
72
+ })
73
+ .sort();
74
+ }
75
+ /** Human-readable list of what {@link collectTestFiles} looks for. */
76
+ export const SCANNABLE_DESC = `test_*.py, *.test|spec.{${JS_TEST_EXTENSIONS.map((e) => e.slice(1)).join(',')}}`;
77
+ //# sourceMappingURL=test-files.js.map
@@ -34,6 +34,15 @@
34
34
  * - neither -- the target cannot be resolved. That is "cannot verify", which is
35
35
  * a finding about the SCAN, so it lands in `skipped` with its reason.
36
36
  *
37
+ * The inference reads four binding forms, because #705 measured what happens
38
+ * when it reads one: 65 of 99 findings on canary's own tree were the namespace
39
+ * import, the dynamic import, and the subprocess launch -- tests invoking
40
+ * exactly what they claimed, through a construct the target set could not see.
41
+ * The issue rules out every quiet answer (a threshold, a mute, a widened blanket
42
+ * skip) on the grounds that a suppressed inference and a passing check must not
43
+ * look alike, so the fix is to WIDEN WHAT THE INFERENCE CAN SEE and leave the
44
+ * rule's authority untouched.
45
+ *
37
46
  * A skip is PER RULE, not per test: `VAC-001` needs no target and always runs,
38
47
  * so a test whose target is unresolvable is still genuinely `checked` and stays
39
48
  * in the denominator. Saying otherwise would understate what was verified. What
@@ -59,9 +68,47 @@ import { blankStringContent } from './string-literals.js';
59
68
  * annotation above the declaration.
60
69
  */
61
70
  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;
71
+ /**
72
+ * An import whose specifier is relative: the local code a test can target.
73
+ *
74
+ * The namespace form (`import * as ns from './x.js'`) needs its own alternative
75
+ * rather than falling out of `(\w+)`: `*` is not a word character, so a file
76
+ * written entirely in namespace imports resolved to an EMPTY target set and
77
+ * every test in it drew a `VAC-002` (#705). On canary's own `agents/skills/test`
78
+ * tree that single omission was the largest share of the 65 findings the issue
79
+ * counted -- `import * as diffscan from '../.../diffscan.mjs'` is the house
80
+ * style there, and `diffscan.findDeletions(...)` is unmistakably an invocation
81
+ * of the target.
82
+ */
83
+ const JS_RELATIVE_IMPORT = /import\s+(?:type\s+)?(?:\*\s+as\s+(\w+)|\{([^}]*)\}|(\w+))[^'"]*from\s*['"](\.[^'"]*)['"]/g;
64
84
  const JS_RELATIVE_REQUIRE = /(?:const|let|var)\s+(?:\{([^}]*)\}|(\w+))\s*=\s*require\s*\(\s*['"](\.[^'"]*)['"]/g;
85
+ /**
86
+ * `const x = await import('./y.js')` / `const { a } = await import('./y.js')`.
87
+ *
88
+ * A dynamic import leaves no static import statement, so a suite that loads its
89
+ * subject this way -- to control module state per test, or to import a module
90
+ * only after an env var is set -- resolved to no target at all (#705).
91
+ */
92
+ const JS_DYNAMIC_IMPORT = /(?:const|let|var)\s+(?:\{([^}]*)\}|(\w+))\s*=\s*(?:await\s+)?import\s*\(\s*['"](\.[^'"]*)['"]\s*\)/g;
93
+ /** A bare `await import('./y.js')` -- no binding, so it names no symbol. */
94
+ const JS_BARE_DYNAMIC_IMPORT = /(?<![\w$.])import\s*\(\s*['"]\.[^'"]*['"]\s*\)/;
95
+ /**
96
+ * A string literal naming a first-party script -- something a subprocess can be
97
+ * pointed at and that lives in this repo.
98
+ *
99
+ * The discriminator is deliberately the EXTENSION, not the path shape: it is
100
+ * what separates `spawnSync(cli, ...)` where `cli` is
101
+ * `path.join(SCRIPTS, 'cli.mjs')` from `spawnSync('git', args)`. A bare command
102
+ * name is not a repo path and must not make a test look covered.
103
+ */
104
+ const SCRIPT_PATH_LITERAL = /['"`][^'"`\n]*[\w$)/.-]\.(?:mjs|cjs|jsx?|tsx?|py|sh)['"`]/;
105
+ /**
106
+ * A declaration binding one name to an expression -- the statement-bounded form
107
+ * used to spot a handle on a first-party script.
108
+ */
109
+ const JS_SIMPLE_DECL = /(?:^|\n)\s*(?:const|let|var)\s+(\w+)\s*=\s*([^;\n]+)/g;
110
+ /** The child-process launchers whose first argument is an executable target. */
111
+ const SUBPROCESS_LAUNCH = /(?<![\w$.])(?:execFileSync|execSync|spawnSync|execFile|spawn|fork)\s*\(/;
65
112
  /**
66
113
  * Python has no `.`-prefix requirement for a first-party import, so `from x
67
114
  * import y` counts. `import os` and the stdlib are excluded by name below --
@@ -210,26 +257,132 @@ function pythonImportedTargets(code) {
210
257
  }
211
258
  return names;
212
259
  }
260
+ /**
261
+ * One binding form: which capture holds the `{...}` clause, and which the single
262
+ * name (default import, namespace alias, or `const x = ...`).
263
+ */
264
+ const JS_BINDING_FORMS = [
265
+ { re: JS_RELATIVE_IMPORT, clause: 2, singles: [1, 3] },
266
+ { re: JS_RELATIVE_REQUIRE, clause: 1, singles: [2] },
267
+ { re: JS_DYNAMIC_IMPORT, clause: 1, singles: [2] },
268
+ ];
213
269
  /** JS/TS first-party imports: any `import`/`require` with a relative specifier. */
214
270
  function jsImportedTargets(code) {
215
271
  const names = new Set();
216
- for (const re of [JS_RELATIVE_IMPORT, JS_RELATIVE_REQUIRE]) {
272
+ for (const { re, clause, singles } of JS_BINDING_FORMS) {
217
273
  // Reset explicitly: these are module-level `/g` patterns, so a leftover
218
274
  // `lastIndex` from an earlier file would silently skip the head of this one.
219
275
  re.lastIndex = 0;
220
276
  for (const m of code.matchAll(re)) {
221
- for (const n of clauseNames(m[1]))
277
+ for (const n of clauseNames(m[clause]))
222
278
  names.add(n);
223
- if (m[2])
224
- names.add(m[2]);
279
+ for (const g of singles)
280
+ if (m[g])
281
+ names.add(m[g]);
225
282
  }
226
283
  }
284
+ for (const n of subprocessScriptHandles(code))
285
+ names.add(n);
227
286
  return names;
228
287
  }
288
+ /**
289
+ * Names bound to a first-party SCRIPT PATH -- the target of a subprocess test.
290
+ *
291
+ * `agents/skills/test` drives most skill CLIs the way a user does, by spawning
292
+ * them: `const cli = path.join(SCRIPTS, 'cli.mjs'); spawnSync(cli, ['--help'])`.
293
+ * No symbol crosses that boundary, so import-inferred fidelity saw a test that
294
+ * referenced none of its file's imports and reported `VAC-002` on a test that is
295
+ * in fact exercising exactly what it claims (#705).
296
+ *
297
+ * The handle -- `cli` -- is the symbol that stands in for the target, so binding
298
+ * it is what lets the existing machinery work unchanged, `closeOverLocals`
299
+ * included. A launch site must be present in the file: a path literal on its own
300
+ * is data (a fixture, an expected value), not an invocation.
301
+ */
302
+ function subprocessScriptHandles(code) {
303
+ const names = new Set();
304
+ if (!SUBPROCESS_LAUNCH.test(code))
305
+ return names;
306
+ JS_SIMPLE_DECL.lastIndex = 0;
307
+ for (const m of code.matchAll(JS_SIMPLE_DECL)) {
308
+ if (SCRIPT_PATH_LITERAL.test(m[2] ?? ''))
309
+ names.add(m[1]);
310
+ }
311
+ return names;
312
+ }
313
+ /**
314
+ * Does this test body itself reach first-party code the target set cannot name?
315
+ *
316
+ * Two shapes, both of which leave no identifier to match: a subprocess launched
317
+ * at a script path written inline (`spawnSync(path.join(D, 'cli.mjs'), ...)`),
318
+ * and a bare `await import('./x.js')` whose result is never bound. Read from the
319
+ * ORIGINAL source rather than the blanked copy, because the evidence in both
320
+ * cases IS the string literal.
321
+ *
322
+ * `SUBPROCESS_LAUNCH` and `SCRIPT_PATH_LITERAL` are required together: a test
323
+ * that spawns `git` and separately mentions a `.py` fixture path is not covered
324
+ * by either half alone.
325
+ */
326
+ function reachesOutOfBandTarget(rawBody) {
327
+ if (SUBPROCESS_LAUNCH.test(rawBody) && SCRIPT_PATH_LITERAL.test(rawBody))
328
+ return true;
329
+ return JS_BARE_DYNAMIC_IMPORT.test(rawBody);
330
+ }
229
331
  /** Names imported from first-party (relative) modules. */
230
332
  function importedTargets(code, python) {
231
333
  return python ? pythonImportedTargets(code) : jsImportedTargets(code);
232
334
  }
335
+ /**
336
+ * Brace depth immediately BEFORE each character, over already-blanked code.
337
+ *
338
+ * Cheap and approximate on purpose: string content is blanked before this runs,
339
+ * so the only braces it can see are real ones (a `{` inside a comment is the
340
+ * residual inaccuracy, and it can only widen a body, never narrow one).
341
+ */
342
+ function braceDepths(code) {
343
+ const depths = new Int32Array(code.length);
344
+ let d = 0;
345
+ for (let i = 0; i < code.length; i += 1) {
346
+ depths[i] = d;
347
+ const c = code[i];
348
+ if (c === '{')
349
+ d += 1;
350
+ else if (c === '}')
351
+ d -= 1;
352
+ }
353
+ return depths;
354
+ }
355
+ /**
356
+ * Where declaration `i`'s body ends.
357
+ *
358
+ * Bounding it at the NEXT declaration is wrong for any helper that declares
359
+ * something inside itself, and that is the common shape for the subprocess
360
+ * helper #705 is about:
361
+ *
362
+ * ```ts
363
+ * const SCRIPT = join(REPO_ROOT, 'scripts', 'entropy-ratchet.mjs');
364
+ * function run() {
365
+ * const r = spawnSync(process.execPath, [SCRIPT, ...]); // <- next decl
366
+ * ...
367
+ * }
368
+ * ```
369
+ *
370
+ * `run`'s body stopped at `const r`, so it never saw `SCRIPT`, so `run()` did
371
+ * not reach the target and every test calling it read as vacuous. Nesting is the
372
+ * discriminator: the body runs to the next declaration at the same or shallower
373
+ * brace depth, which is the first one that is genuinely a SIBLING.
374
+ */
375
+ function declEnd(code, matches, i, depth) {
376
+ if (depth === null)
377
+ return matches[i + 1]?.index ?? code.length;
378
+ const own = depth[matches[i].index] ?? 0;
379
+ for (let j = i + 1; j < matches.length; j += 1) {
380
+ const at = matches[j].index;
381
+ if ((depth[at] ?? 0) <= own)
382
+ return at;
383
+ }
384
+ return code.length;
385
+ }
233
386
  /**
234
387
  * Grow `targets` with local declarations that themselves reach a target, to a
235
388
  * fixpoint.
@@ -245,13 +398,14 @@ function closeOverLocals(code, targets, python) {
245
398
  const re = python ? PY_LOCAL_DECL : JS_LOCAL_DECL;
246
399
  re.lastIndex = 0;
247
400
  const matches = [...code.matchAll(re)];
401
+ const depth = python ? null : braceDepths(code);
248
402
  for (let i = 0; i < matches.length; i += 1) {
249
403
  const m = matches[i];
250
404
  const names = boundNames(m, python);
251
405
  if (names.length === 0)
252
406
  continue;
253
407
  const start = m.index + m[0].length;
254
- const end = matches[i + 1]?.index ?? code.length;
408
+ const end = declEnd(code, matches, i, depth);
255
409
  decls.push({ names, body: code.slice(start, end) });
256
410
  }
257
411
  if (!python) {
@@ -352,12 +506,12 @@ function pyTautology(line) {
352
506
  return false;
353
507
  return normalize(cmp[1]) === normalize(cmp[2]);
354
508
  }
355
- function scanBlock(code, block, file, python, reaching, annotated, skipped) {
509
+ function scanBlock(code, block, file, python, reaching, annotated, skipped, outOfBand) {
356
510
  const lines = bodyLines(code, block).filter((l) => !isComment(l.text));
357
511
  const targets = annotated !== null ? new Set([annotated]) : reaching;
358
512
  return [
359
513
  ...tautologies(lines, block, file, python),
360
- ...targetNeverInvoked(block, file, reaching, annotated),
514
+ ...targetNeverInvoked(block, file, reaching, annotated, outOfBand),
361
515
  ...absenceOnly(lines, block, file, python, targets, skipped),
362
516
  ];
363
517
  }
@@ -394,7 +548,7 @@ function tautologies(lines, block, file, python) {
394
548
  .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
549
  }
396
550
  /** VAC-002 -- the target is never referenced anywhere in the body. */
397
- function targetNeverInvoked(block, file, reaching, annotated) {
551
+ function targetNeverInvoked(block, file, reaching, annotated, outOfBand) {
398
552
  if (annotated !== null) {
399
553
  if (mentionsAny(block.body, new Set([annotated])))
400
554
  return [];
@@ -402,6 +556,10 @@ function targetNeverInvoked(block, file, reaching, annotated) {
402
556
  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
557
  ];
404
558
  }
559
+ // The subprocess / bare-dynamic-import shapes reach first-party code without
560
+ // naming a symbol, so no target set can ever match them (#705).
561
+ if (outOfBand)
562
+ return [];
405
563
  if (reaching === null || mentionsAny(block.body, reaching))
406
564
  return [];
407
565
  return [
@@ -521,7 +679,7 @@ export function scanVacuity(path) {
521
679
  const blocks = enumerateTests(code, source, python);
522
680
  const reaching = resolveTargets(source, code, python);
523
681
  const skipped = [];
524
- const findings = scanAllBlocks({ code, path, python, reaching }, blocks, skipped);
682
+ const findings = scanAllBlocks({ code, source, path, python, reaching }, blocks, skipped);
525
683
  const result = {
526
684
  checked: blocks.length,
527
685
  findings,
@@ -539,17 +697,29 @@ function scanAllBlocks(ctx, blocks, skipped) {
539
697
  const prev = blocks[i - 1];
540
698
  const floor = prev ? prev.bodyStart + prev.body.length : 0;
541
699
  const annotated = annotationFor(ctx.code, block, floor);
700
+ const outOfBand = !ctx.python &&
701
+ annotated === null &&
702
+ reachesOutOfBandTarget(ctx.source.slice(block.bodyStart, block.bodyStart + block.body.length));
542
703
  if (annotated === null && ctx.reaching === null) {
543
704
  // Both target-dependent rules go dark together, and both say so. VAC-003
544
705
  // asks "does any assertion observe the target", which is unanswerable
545
706
  // without a target -- so it abstains rather than falling back to the
546
707
  // 254-false-positive version of itself.
708
+ //
709
+ // An out-of-band reach answers VAC-002 (the test DOES invoke first-party
710
+ // code) but not VAC-003, which needs a SYMBOL to ask "did an assertion
711
+ // observe it". So the skip narrows rather than disappearing: reporting
712
+ // both as dark would overstate the gap, dropping it entirely would hide a
713
+ // real one, and #705 is explicit that a suppressed inference and a passing
714
+ // check must not look alike.
547
715
  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',
716
+ name: `${outOfBand ? 'VAC-003' : 'VAC-002/VAC-003'} (${block.name})`,
717
+ reason: outOfBand
718
+ ? 'target reached out of band (subprocess or bare dynamic import), so VAC-002 is answered but no symbol exists for absence-only to observe'
719
+ : 'target unresolvable: no @covers annotation and no first-party relative import to infer from',
550
720
  });
551
721
  }
552
- findings.push(...scanBlock(ctx.code, block, ctx.path, ctx.python, ctx.reaching, annotated, skipped));
722
+ findings.push(...scanBlock(ctx.code, block, ctx.path, ctx.python, ctx.reaching, annotated, skipped, outOfBand));
553
723
  }
554
724
  return findings;
555
725
  }
@@ -36,13 +36,17 @@ import { mkdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'nod
36
36
  import { dirname, join } from 'node:path';
37
37
  import { ensureAscii } from '../util/ensure-ascii.js';
38
38
  import { coverageDegradedNotice, coverageStatus, } from './coverage.js';
39
- import { combineNotices, renderFindings } from './pr-check.js';
39
+ import { combineNotices, renderFindings, } from './pr-check.js';
40
40
  // 1.1 adds the additive `coverage` block (#554); readers of 1.0 are unaffected.
41
41
  // 1.2 adds the additive `skipped` list (#582). Additive again, and bumped again
42
42
  // for the reason recorded in #572: a reader that pins a version must be able to
43
43
  // tell which fields it can rely on being present, and silence about a new field
44
44
  // is indistinguishable from the field being absent for a real reason.
45
- export const SCHEMA_VERSION = '1.2';
45
+ // 1.3 adds the additive `provenance` block (#761). Same additive rule. This is
46
+ // the field an archived record needs most: when a run is questioned WEEKS later
47
+ // from its uploaded artifact, `checked: 43` is only interpretable next to the
48
+ // diff endpoints that produced it.
49
+ export const SCHEMA_VERSION = '1.3';
46
50
  const ANALYSIS_SOURCE = 'canary-pr-guardian';
47
51
  const REF_SAFE = /[^A-Za-z0-9._-]/g;
48
52
  const REF_MAX = 100; // cap the sanitized ref so a long branch never hits ENAMETOOLONG
@@ -111,6 +115,7 @@ export function buildAnalysisRecord(findings, args) {
111
115
  ? null
112
116
  : { status: coverageStatus(coverage), ...coverage },
113
117
  skipped: args.skipped ?? [],
118
+ provenance: args.provenance ?? null,
114
119
  summary: {
115
120
  total: findings.length,
116
121
  unaddressed: active.length,