canary-test-cli 7.1.0 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/skills/README.md +308 -0
- package/agents/skills/canary:generate.md +49 -0
- package/agents/skills/canary:init.md +37 -0
- package/agents/skills/canary:migrate.md +66 -0
- package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
- package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
- package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
- package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
- package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
- package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
- package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
- package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
- package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
- package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
- package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
- package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
- package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
- package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
- package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
- package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
- package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
- package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
- package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
- package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
- package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
- package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
- package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
- package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
- package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
- package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
- package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
- package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
- package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
- package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
- package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
- package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
- package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
- package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
- package/agents/skills/lib/parse-args.mjs +214 -0
- package/dist/engine/cli-commands.js +3 -65
- package/dist/engine/core/quality-scorer.js +15 -2
- package/dist/engine/core/skill-dispatch.js +115 -0
- package/dist/engine/core/skill-examples.js +103 -3
- package/dist/engine/core/skill-registry.js +59 -4
- package/dist/engine/core/test-files.js +77 -0
- package/dist/engine/core/vacuity-scanner.js +184 -14
- package/dist/engine/guardian/analysis-emit.js +7 -2
- package/dist/engine/guardian/cli.js +121 -9
- package/dist/engine/guardian/pr-check.js +317 -18
- package/dist/engine/skills-cli.js +123 -19
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- package/package.json +3 -2
|
@@ -67,11 +67,25 @@ function codePointCompare(a, b) {
|
|
|
67
67
|
}
|
|
68
68
|
return ca.length - cb.length;
|
|
69
69
|
}
|
|
70
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
63
|
-
|
|
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
|
|
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[
|
|
277
|
+
for (const n of clauseNames(m[clause]))
|
|
222
278
|
names.add(n);
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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:
|
|
549
|
-
reason:
|
|
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
|
-
|
|
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,
|