canary-test-cli 7.1.0 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/skills/README.md +308 -0
- package/agents/skills/canary:generate.md +49 -0
- package/agents/skills/canary:init.md +37 -0
- package/agents/skills/canary:migrate.md +66 -0
- package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
- package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
- package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
- package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
- package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
- package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
- package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
- package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
- package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
- package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
- package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
- package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
- package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
- package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
- package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
- package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
- package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
- package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
- package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
- package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
- package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
- package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
- package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
- package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
- package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
- package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
- package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
- package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
- package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
- package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
- package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
- package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
- package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
- package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
- package/agents/skills/lib/parse-args.mjs +214 -0
- package/dist/engine/cli-commands.js +3 -65
- package/dist/engine/core/quality-scorer.js +15 -2
- package/dist/engine/core/skill-dispatch.js +115 -0
- package/dist/engine/core/skill-examples.js +103 -3
- package/dist/engine/core/skill-registry.js +59 -4
- package/dist/engine/core/test-files.js +77 -0
- package/dist/engine/core/vacuity-scanner.js +184 -14
- package/dist/engine/guardian/analysis-emit.js +7 -2
- package/dist/engine/guardian/cli.js +121 -9
- package/dist/engine/guardian/pr-check.js +317 -18
- package/dist/engine/skills-cli.js +123 -19
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Shared skill-CLI argument parser (#479).
|
|
2
|
+
//
|
|
3
|
+
// Skill CLIs are deliberately self-contained -- they import no engine code, so
|
|
4
|
+
// the runner can exec them anywhere node runs. That constraint is why five of
|
|
5
|
+
// them hand-rolled the same `parseArgs` loop, and why the same bug class came
|
|
6
|
+
// back three consecutive rounds: the pattern was copy-paste, so each new skill
|
|
7
|
+
// inherited whichever version its author happened to copy.
|
|
8
|
+
//
|
|
9
|
+
// This module is the one implementation of the four invariants that loop has to
|
|
10
|
+
// honour. It stays dependency-free ESM under `agents/skills/lib/`, which keeps
|
|
11
|
+
// the self-contained property intact -- a skill importing a sibling file in the
|
|
12
|
+
// same shipped tree is still a skill that needs nothing installed.
|
|
13
|
+
//
|
|
14
|
+
// 1. null-prototype flag lookup -- an inherited key must never resolve
|
|
15
|
+
// 2. empty-value rejection -- `--flag=` and `--flag ''` alike
|
|
16
|
+
// 3. arity checking -- `argument <flag>: expected one argument`
|
|
17
|
+
// 4. `--flag=value` -- accepted everywhere, not per-skill
|
|
18
|
+
//
|
|
19
|
+
// `test/skill-cli-conformance.test.ts` discovers every SKILL.md declaring
|
|
20
|
+
// `cli:` and asserts the module exports the `CLI_SPEC` it passed here, so a
|
|
21
|
+
// sixth hand-rolled copy fails CI instead of quietly starting the cycle again.
|
|
22
|
+
|
|
23
|
+
/** Exit code argparse reserves for usage errors; the whole family follows it. */
|
|
24
|
+
export const EXIT_USAGE = 2;
|
|
25
|
+
|
|
26
|
+
/** Well-formed integer, sign allowed -- no floats, no exponents, no 0x. */
|
|
27
|
+
const INT_RE = /^[+-]?\d+$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* argparse prints `prog: error: message` for usage errors. The family's
|
|
31
|
+
* comments have claimed argparse parity throughout while shipping two formats
|
|
32
|
+
* under that banner; this is the one that makes the claim true. Runtime
|
|
33
|
+
* failures (exit 1) keep the plainer `prog: message` -- argparse never owned
|
|
34
|
+
* those, so there is nothing to be faithful to.
|
|
35
|
+
*/
|
|
36
|
+
export function formatUsageError(prog, message) {
|
|
37
|
+
return `${prog}: error: ${message}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Inherited keys must not resolve, so every lookup map is null-prototype. */
|
|
41
|
+
function nullProtoMap(entries) {
|
|
42
|
+
return Object.assign(Object.create(null), entries);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a parser from a declarative spec.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} spec
|
|
49
|
+
* @param {string} spec.prog program name used in error output
|
|
50
|
+
* @param {Record<string,string>} [spec.booleans] '--json' -> 'json'
|
|
51
|
+
* @param {Record<string,{key:string,type?:'string'|'int'}>} [spec.values]
|
|
52
|
+
* @param {Record<string,unknown>} [spec.defaults] initial option values
|
|
53
|
+
* @param {string[]} [spec.required] value flags that must be supplied
|
|
54
|
+
* @param {{key:string,defaults?:string[]}} [spec.positionals]
|
|
55
|
+
* declaring positionals also enables the `--` end-of-options terminator
|
|
56
|
+
* and a lone `-`; a CLI that takes no paths gets neither, since there is
|
|
57
|
+
* nothing for them to protect.
|
|
58
|
+
* @returns {(argv: string[]) => {opts: Record<string,unknown>, positionals: string[], help: boolean, error: string|null}}
|
|
59
|
+
*/
|
|
60
|
+
export function createParser(spec) {
|
|
61
|
+
const {
|
|
62
|
+
prog,
|
|
63
|
+
booleans = {},
|
|
64
|
+
values = {},
|
|
65
|
+
defaults = {},
|
|
66
|
+
required = [],
|
|
67
|
+
positionals = null,
|
|
68
|
+
} = spec ?? {};
|
|
69
|
+
|
|
70
|
+
if (!prog) throw new Error('createParser: spec.prog is required');
|
|
71
|
+
|
|
72
|
+
const BOOLEANS = nullProtoMap(booleans);
|
|
73
|
+
const VALUES = nullProtoMap(values);
|
|
74
|
+
|
|
75
|
+
// Fail loudly at construction on a spec that cannot be satisfied -- a rename
|
|
76
|
+
// that leaves a stale default or a required flag behind is otherwise silent.
|
|
77
|
+
const declaredKeys = new Set([
|
|
78
|
+
...Object.values(booleans),
|
|
79
|
+
...Object.values(values).map((v) => v.key),
|
|
80
|
+
]);
|
|
81
|
+
for (const key of Object.keys(defaults)) {
|
|
82
|
+
if (!declaredKeys.has(key)) {
|
|
83
|
+
throw new Error(`createParser: defaults names unknown key '${key}'`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const flag of required) {
|
|
87
|
+
if (VALUES[flag] === undefined) {
|
|
88
|
+
throw new Error(`createParser: required names undeclared flag '${flag}'`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return function parse(argv = []) {
|
|
93
|
+
const opts = Object.create(null);
|
|
94
|
+
for (const key of Object.values(booleans)) opts[key] = false;
|
|
95
|
+
for (const { key } of Object.values(values)) {
|
|
96
|
+
opts[key] = key in defaults ? defaults[key] : null;
|
|
97
|
+
}
|
|
98
|
+
Object.assign(opts, defaults);
|
|
99
|
+
|
|
100
|
+
const found = [];
|
|
101
|
+
const result = { opts, positionals: found, help: false, error: null };
|
|
102
|
+
const fail = (message) => {
|
|
103
|
+
result.error = message;
|
|
104
|
+
return result;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
let endOfOptions = false;
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
110
|
+
const arg = argv[i];
|
|
111
|
+
|
|
112
|
+
if (endOfOptions) {
|
|
113
|
+
found.push(arg);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Help short-circuits everything, including the required-flag check
|
|
118
|
+
// below -- otherwise `--help` reports the arguments it is being asked to
|
|
119
|
+
// explain as missing.
|
|
120
|
+
if (arg === '-h' || arg === '--help') {
|
|
121
|
+
result.help = true;
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (arg === '--') {
|
|
126
|
+
if (!positionals) return fail('unrecognized arguments: --');
|
|
127
|
+
endOfOptions = true;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Split `--flag=value` once, up front, so both spellings share one path.
|
|
132
|
+
const eq = arg.startsWith('--') ? arg.indexOf('=') : -1;
|
|
133
|
+
const flag = eq === -1 ? arg : arg.slice(0, eq);
|
|
134
|
+
const inline = eq === -1 ? null : arg.slice(eq + 1);
|
|
135
|
+
|
|
136
|
+
if (BOOLEANS[flag] !== undefined && inline === null) {
|
|
137
|
+
opts[BOOLEANS[flag]] = true;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const def = VALUES[flag];
|
|
142
|
+
if (def !== undefined) {
|
|
143
|
+
let raw;
|
|
144
|
+
if (inline !== null) {
|
|
145
|
+
raw = inline;
|
|
146
|
+
} else {
|
|
147
|
+
const next = argv[i + 1];
|
|
148
|
+
// A leading '-' normally means "the next flag, not my value" -- but a
|
|
149
|
+
// well-formed integer is a legitimate value for an int flag, so
|
|
150
|
+
// `--seed -5` and `--seed=-5` stay the same command.
|
|
151
|
+
const looksLikeFlag =
|
|
152
|
+
next !== undefined &&
|
|
153
|
+
next.startsWith('-') &&
|
|
154
|
+
!(def.type === 'int' && INT_RE.test(next));
|
|
155
|
+
if (next === undefined || looksLikeFlag) {
|
|
156
|
+
return fail(`argument ${flag}: expected one argument`);
|
|
157
|
+
}
|
|
158
|
+
raw = next;
|
|
159
|
+
i += 1;
|
|
160
|
+
}
|
|
161
|
+
// Empty is the missing-value case wearing a disguise. `--repo=` is
|
|
162
|
+
// typed by nobody, but `--repo "$UNSET_VAR"` expands to `--repo ''` in
|
|
163
|
+
// any shell, and an accepted empty path silently retargets writes at
|
|
164
|
+
// the process CWD.
|
|
165
|
+
if (raw === '') return fail(`argument ${flag}: expected one argument`);
|
|
166
|
+
if (def.type === 'int') {
|
|
167
|
+
// Validate the VALUE, not just its presence: a flag whose purpose is
|
|
168
|
+
// determinism must not decay to a default when its value is junk.
|
|
169
|
+
if (!INT_RE.test(raw)) {
|
|
170
|
+
return fail(`argument ${flag}: invalid int value: '${raw}'`);
|
|
171
|
+
}
|
|
172
|
+
// Syntactically an integer is not enough: Number() silently rounds
|
|
173
|
+
// past 2^53-1, so `--seed 9007199254740993` would RUN with ...992 --
|
|
174
|
+
// the value used differing from the value asked for, which is the
|
|
175
|
+
// exact class of lie a determinism flag must not tell.
|
|
176
|
+
const parsed = Number(raw);
|
|
177
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
178
|
+
return fail(
|
|
179
|
+
`argument ${flag}: integer out of safe range: '${raw}'`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
opts[def.key] = parsed;
|
|
183
|
+
} else {
|
|
184
|
+
opts[def.key] = raw;
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A lone `-` is a positional, as argparse treats it.
|
|
190
|
+
if (positionals && (arg === '-' || !arg.startsWith('-'))) {
|
|
191
|
+
found.push(arg);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return fail(`unrecognized arguments: ${arg}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const missing = required.filter((flag) => {
|
|
199
|
+
const value = opts[VALUES[flag].key];
|
|
200
|
+
return value === null || value === undefined;
|
|
201
|
+
});
|
|
202
|
+
if (missing.length) {
|
|
203
|
+
return fail(
|
|
204
|
+
`the following arguments are required: ${missing.join(', ')}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (positionals && !found.length && positionals.defaults) {
|
|
209
|
+
found.push(...positionals.defaults);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return result;
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* strips on a non-TTY sink so plain text is byte-exact), and output glyphs as
|
|
11
11
|
* `\u{...}` escapes emitted verbatim.
|
|
12
12
|
*/
|
|
13
|
-
import { existsSync,
|
|
13
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { basename, extname, join, resolve } from 'node:path';
|
|
15
15
|
import pc from 'picocolors';
|
|
16
16
|
import { CliExitError, jsonIndent2 } from './cli-common.js';
|
|
@@ -22,7 +22,8 @@ import { ckInitCmd } from './company-knowledge-cli.js';
|
|
|
22
22
|
import { extractFrameworkHint } from './core/classifier.js';
|
|
23
23
|
import { VALID_CATEGORIES, buildFeedback } from './core/feedback.js';
|
|
24
24
|
import { OverlayNotFound, listOverlays, resolveOverlay, } from './core/overlays.js';
|
|
25
|
-
import {
|
|
25
|
+
import { frameworkForPath } from './core/static-linter.js';
|
|
26
|
+
import { SCANNABLE_DESC, collectTestFiles, isDir } from './core/test-files.js';
|
|
26
27
|
import { RunSummary } from './core/ticket-updater.js';
|
|
27
28
|
import { renderBanner } from './ui/banner.js';
|
|
28
29
|
import { ARROW, CHECK, CHECK_MARK, CROSS, EM_DASH, HAMMER, NEXT, REDX, ROCKET, WARN, WRENCH, } from './main-deps.js';
|
|
@@ -42,67 +43,6 @@ function isFile(p) {
|
|
|
42
43
|
return false;
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
|
-
function isDir(p) {
|
|
46
|
-
try {
|
|
47
|
-
return statSync(p).isDirectory();
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Directories never worth walking. A dependency's own test suite is not the
|
|
55
|
-
* consumer's to fix: before #566, `node_modules` accounted for 254 of 256
|
|
56
|
-
* findings in one downstream run, and the only `critical` sat inside vendored
|
|
57
|
-
* code. `pattern-matcher.ts` has carried this set since the Python port; this
|
|
58
|
-
* walk was the copy that never got it.
|
|
59
|
-
*/
|
|
60
|
-
const IGNORED_DIRS = new Set([
|
|
61
|
-
'node_modules',
|
|
62
|
-
'.git',
|
|
63
|
-
'__pycache__',
|
|
64
|
-
'.venv',
|
|
65
|
-
'venv',
|
|
66
|
-
'dist',
|
|
67
|
-
'build',
|
|
68
|
-
'.next',
|
|
69
|
-
'.nuxt',
|
|
70
|
-
]);
|
|
71
|
-
function walkFiles(dir) {
|
|
72
|
-
const out = [];
|
|
73
|
-
let entries;
|
|
74
|
-
try {
|
|
75
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
return out;
|
|
79
|
-
}
|
|
80
|
-
for (const e of entries) {
|
|
81
|
-
const full = join(dir, e.name);
|
|
82
|
-
if (e.isDirectory()) {
|
|
83
|
-
if (!IGNORED_DIRS.has(e.name))
|
|
84
|
-
out.push(...walkFiles(full));
|
|
85
|
-
}
|
|
86
|
-
else if (e.isFile())
|
|
87
|
-
out.push(full);
|
|
88
|
-
}
|
|
89
|
-
return out;
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* `test_*.py` plus `*.test.*` / `*.spec.*` over every extension the scanners
|
|
93
|
-
* can actually read -- `.mjs` and `.cjs` included, which is the half of #566
|
|
94
|
-
* that made a directory of ESM tests collect zero files.
|
|
95
|
-
*/
|
|
96
|
-
const JS_TEST_FILE_RE = new RegExp(`\\.(test|spec)\\.(${JS_TEST_EXTENSIONS.map((e) => e.slice(1)).join('|')})$`);
|
|
97
|
-
/** Recursive test-file glob matching Python's `rglob` union, sorted by path. */
|
|
98
|
-
function collectTestFiles(dir) {
|
|
99
|
-
return walkFiles(dir)
|
|
100
|
-
.filter((p) => {
|
|
101
|
-
const b = basename(p);
|
|
102
|
-
return ((b.startsWith('test_') && b.endsWith('.py')) || JS_TEST_FILE_RE.test(b));
|
|
103
|
-
})
|
|
104
|
-
.sort();
|
|
105
|
-
}
|
|
106
46
|
export function recommendFrameworkCmd(promptText, opts, deps) {
|
|
107
47
|
const classifier = deps.makeClassifier();
|
|
108
48
|
const recommender = deps.makeRecommender();
|
|
@@ -453,8 +393,6 @@ function findingPayload(f) {
|
|
|
453
393
|
suggestion: f.suggestion,
|
|
454
394
|
};
|
|
455
395
|
}
|
|
456
|
-
/** Human-readable list of what the collectors look for, for remedy text. */
|
|
457
|
-
const SCANNABLE_DESC = `test_*.py, *.test|spec.{${JS_TEST_EXTENSIONS.map((e) => e.slice(1)).join(',')}}`;
|
|
458
396
|
/** Emit the abstention notice in the caller's output mode, then exit 3. */
|
|
459
397
|
function abstain(remedy, deps, json, skipped = []) {
|
|
460
398
|
const result = { checked: 0, findings: [] };
|
|
@@ -19,10 +19,23 @@ const ASSERTIONS = {
|
|
|
19
19
|
// nothing. (`\bassert\b` alone does NOT match `assert_valid`: `_` is a word
|
|
20
20
|
// char, so the `\b` after `assert` fails there.)
|
|
21
21
|
pytest: /\bassert\b|\bpytest\.raises\b|\bself\.assert\w+\b|\bassert\w*\s*\(/g,
|
|
22
|
-
|
|
22
|
+
// `\bexpect\w*\s*\(` is the JS/TS half of that same rationale (#738). The
|
|
23
|
+
// naming convention for a custom assertion helper is `expect*` here, not
|
|
24
|
+
// `assert*`, and it is the pattern Playwright's own docs recommend: a suite
|
|
25
|
+
// that routes its checks through `expectRouteTestId(page, id)` so the
|
|
26
|
+
// assertion semantics live in one documented place had EVERY added test
|
|
27
|
+
// flagged `added test asserts nothing`. `\w*` is zero-width-matchable, so
|
|
28
|
+
// plain `expect(` still matches and the change is purely additive.
|
|
29
|
+
//
|
|
30
|
+
// The precision cost is the mirror image of the accepted pytest one: a
|
|
31
|
+
// non-asserting function whose name happens to start with `expect` now
|
|
32
|
+
// counts. That is the right side to err on for an advisory finding that
|
|
33
|
+
// never gates — a false "asserts nothing" on a correct test is what teaches
|
|
34
|
+
// a reader to ignore the finding.
|
|
35
|
+
playwright: /\bexpect\w*\s*\(|\btoBeVisible\b|\btoHaveText\b|\btoHaveTitle\b|\btoHaveURL\b|\btoBeEnabled\b|\btoBeDisabled\b|\btoBeChecked\b|\btoHaveValue\b|\btoHaveCount\b/g,
|
|
23
36
|
// Plus non-`expect` assertion styles common in JS/TS: node:assert / vitest
|
|
24
37
|
// `assert(...)` / `assert.equal(...)`, and chai BDD `x.should.equal`.
|
|
25
|
-
vitest: /\bexpect\s*\(|\btoBe\s*\(|\btoEqual\s*\(|\btoThrow\b|\btoContain\s*\(|\btoBeNull\b|\btoBeUndefined\b|\btoMatchObject\b|\bassert\s*\(|\bassert\.\w+|\.should\b/g,
|
|
38
|
+
vitest: /\bexpect\w*\s*\(|\btoBe\s*\(|\btoEqual\s*\(|\btoThrow\b|\btoContain\s*\(|\btoBeNull\b|\btoBeUndefined\b|\btoMatchObject\b|\bassert\s*\(|\bassert\.\w+|\.should\b/g,
|
|
26
39
|
k6: /\bcheck\s*\(|'[^']+'\s*:\s*\([^)]*\)\s*=>/g,
|
|
27
40
|
};
|
|
28
41
|
const NEGATIVE_KW = /\b(error|invalid|empty|null|undefined|throws|raises|exception|fail|missing|negative|reject|4\d{2}|5\d{2}|boundary|edge)\b/i;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 2 of `canary skills run`: the dispatcher (#756).
|
|
3
|
+
*
|
|
4
|
+
* Canary shipped tier 1 (a skill declaring `cli:`/`entry:` is spawned) and
|
|
5
|
+
* tier 3 (a prose skill is unreachable), and nothing between. 14 of canary's 21
|
|
6
|
+
* skills carry no `cli:`, so no orchestrator, CI step, or sibling skill could
|
|
7
|
+
* invoke them at all -- a have/have-not split that costs far more here than the
|
|
8
|
+
* same split costs harness, where the dispatcher runs the CLI-less majority.
|
|
9
|
+
*
|
|
10
|
+
* ## What "running a prose skill" means here, honestly
|
|
11
|
+
*
|
|
12
|
+
* Canary is a CLI. It has no agent runtime, and it is not going to grow one to
|
|
13
|
+
* close this gap. So the dispatcher does the one thing a CLI can do faithfully:
|
|
14
|
+
* it RESOLVES the skill and hands back its executable contract -- identity,
|
|
15
|
+
* declared runtime requirements, and the workflow text an agent is to apply --
|
|
16
|
+
* with the tier and the determinism stated on the payload. The caller gets a
|
|
17
|
+
* resolved, machine-readable handle to a real skill instead of exit 2.
|
|
18
|
+
*
|
|
19
|
+
* What it deliberately does NOT do is apply the workflow and present the result
|
|
20
|
+
* as canary's. That would be canary claiming an answer it did not compute.
|
|
21
|
+
*
|
|
22
|
+
* ## Determinism labelling (issue design question 2)
|
|
23
|
+
*
|
|
24
|
+
* Every dispatch is stamped `determinism: 'agent-applied'`, against
|
|
25
|
+
* `'deterministic'` for a `cli:` skill. A consumer merging findings across
|
|
26
|
+
* skills must be able to tell a scanner's output from an agent's reading of a
|
|
27
|
+
* ruleset; without the label the two look interchangeable, which is exactly the
|
|
28
|
+
* confusion #755 documents about cassandra.
|
|
29
|
+
*
|
|
30
|
+
* ## Why no `--allow-executable-skills` equivalent (design question 3)
|
|
31
|
+
*
|
|
32
|
+
* That flag exists because a freshly cloned overlay can carry a `cli:` script,
|
|
33
|
+
* and invoking it runs someone else's code on the next CI run. Dispatch runs
|
|
34
|
+
* nothing: it reads a markdown file the registry already read at discovery and
|
|
35
|
+
* prints it. There is no new execution to gate, so gating it would be
|
|
36
|
+
* ceremony -- and ceremony that would keep the 14 skills unreachable in exactly
|
|
37
|
+
* the non-interactive contexts the issue is about. The trust boundary moves to
|
|
38
|
+
* whatever the caller does with the returned text, which is the caller's gate
|
|
39
|
+
* to own, and the payload labels itself so the caller can see what it holds.
|
|
40
|
+
*
|
|
41
|
+
* ## Failure mode (design question 4)
|
|
42
|
+
*
|
|
43
|
+
* A skill that could not be dispatched raises {@link SkillDispatchError}. An
|
|
44
|
+
* unreadable or bodyless SKILL.md is a failure, never an empty success -- a
|
|
45
|
+
* dispatcher that returned "nothing to do" for a skill it could not read would
|
|
46
|
+
* be indistinguishable from one that ran and found nothing.
|
|
47
|
+
*/
|
|
48
|
+
import { readFileSync } from 'node:fs';
|
|
49
|
+
import { errnoCode } from './gate-result.js';
|
|
50
|
+
// Written as an escape so this source stays ASCII, matching gate-result.ts.
|
|
51
|
+
const EMDASH = '\u{2014}';
|
|
52
|
+
/** A dispatch that could not be completed. Never degrades to an empty result. */
|
|
53
|
+
export class SkillDispatchError extends Error {
|
|
54
|
+
skill;
|
|
55
|
+
constructor(skill, message) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = 'SkillDispatchError';
|
|
58
|
+
this.skill = skill;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Strip a leading `---` frontmatter block, leaving the workflow prose.
|
|
63
|
+
*
|
|
64
|
+
* Mirrors the delimiter handling in `SkillRegistry.parseFrontmatter`: an
|
|
65
|
+
* unterminated block means the whole file was frontmatter, and there is no
|
|
66
|
+
* body to hand back.
|
|
67
|
+
*/
|
|
68
|
+
export function skillBody(text) {
|
|
69
|
+
if (!text.startsWith('---'))
|
|
70
|
+
return text.trim();
|
|
71
|
+
const rest = text.split('\n').slice(1);
|
|
72
|
+
const end = rest.findIndex((l) => l.trim() === '---');
|
|
73
|
+
return end === -1
|
|
74
|
+
? ''
|
|
75
|
+
: rest
|
|
76
|
+
.slice(end + 1)
|
|
77
|
+
.join('\n')
|
|
78
|
+
.trim();
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Resolve a prose skill into its dispatch payload.
|
|
82
|
+
*
|
|
83
|
+
* @throws {SkillDispatchError} when SKILL.md cannot be read, or holds no body.
|
|
84
|
+
*/
|
|
85
|
+
export function dispatchProseSkill(skill, args) {
|
|
86
|
+
let text;
|
|
87
|
+
try {
|
|
88
|
+
text = readFileSync(skill.path, 'utf-8');
|
|
89
|
+
}
|
|
90
|
+
catch (exc) {
|
|
91
|
+
const code = errnoCode(exc);
|
|
92
|
+
if (code === null)
|
|
93
|
+
throw exc;
|
|
94
|
+
throw new SkillDispatchError(skill.name, `cannot read ${skill.path} (${code}) ${EMDASH} the skill was ` +
|
|
95
|
+
'discovered but its workflow could not be loaded.');
|
|
96
|
+
}
|
|
97
|
+
const instructions = skillBody(text);
|
|
98
|
+
if (!instructions) {
|
|
99
|
+
throw new SkillDispatchError(skill.name, `${skill.path} carries frontmatter but no workflow body ${EMDASH} ` +
|
|
100
|
+
'there is nothing to dispatch. Reporting this as an empty run would ' +
|
|
101
|
+
'be indistinguishable from a skill that ran and found nothing.');
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
skill: skill.name,
|
|
105
|
+
path: skill.path,
|
|
106
|
+
tier: 'dispatcher',
|
|
107
|
+
determinism: 'agent-applied',
|
|
108
|
+
requires_agent_runtime: true,
|
|
109
|
+
requires: skill.requires,
|
|
110
|
+
description: skill.description,
|
|
111
|
+
instructions,
|
|
112
|
+
args,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=skill-dispatch.js.map
|
|
@@ -59,6 +59,48 @@ const PLACEHOLDER = /[<>${}|`*\\]/;
|
|
|
59
59
|
const HELP_FLAGS = new Set(['--help', '-h', '--version', '-V']);
|
|
60
60
|
/** Non-mutating subcommands worth executing even without a help flag. */
|
|
61
61
|
const READ_ONLY_COMMANDS = new Set(['canary skills list']);
|
|
62
|
+
/**
|
|
63
|
+
* The author's declaration that a block is illustrative (#707).
|
|
64
|
+
*
|
|
65
|
+
* Placed on its own line immediately above the fence it governs:
|
|
66
|
+
*
|
|
67
|
+
* <!-- canary:illustrative -->
|
|
68
|
+
* ```bash
|
|
69
|
+
* canary katana scan --since HEAD~1
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* Two facts land in the same "unverifiable" bucket and they are not the same
|
|
73
|
+
* fact: "nobody could run this" and "this was never meant to be run". The
|
|
74
|
+
* first is a gap in the corpus; the second is a deliberate authoring choice.
|
|
75
|
+
* Collapsing them is what let 88% of the corpus read as coverage debt when
|
|
76
|
+
* some of it was prose doing its job — and, worse, hid the real gaps inside
|
|
77
|
+
* the pile.
|
|
78
|
+
*
|
|
79
|
+
* Marking is NOT an escape hatch from the executable-example rule. It changes
|
|
80
|
+
* the reason on one block; a code-bearing skill still has to carry at least
|
|
81
|
+
* one example that actually runs (`no-executable-example`), so a skill cannot
|
|
82
|
+
* mark its way to green.
|
|
83
|
+
*/
|
|
84
|
+
const ILLUSTRATIVE_MARKER = /^\s*<!--\s*canary:illustrative\s*-->\s*$/;
|
|
85
|
+
/**
|
|
86
|
+
* The reason carried by a declared-illustrative example.
|
|
87
|
+
*
|
|
88
|
+
* Exported because the summary line splits the unverifiable bucket on it
|
|
89
|
+
* (see {@link countDeclaredIllustrative}). A string literal compared in two
|
|
90
|
+
* files is a drift waiting to happen, and the drift would be silent: the
|
|
91
|
+
* split would quietly read 0 declared and the distinction this issue exists
|
|
92
|
+
* to draw would be gone with nothing red.
|
|
93
|
+
*/
|
|
94
|
+
export const ILLUSTRATIVE_REASON = 'declared illustrative by the author, so it is not run';
|
|
95
|
+
/**
|
|
96
|
+
* How many of a gate's skipped examples were skipped BY DECLARATION.
|
|
97
|
+
*
|
|
98
|
+
* The rest are the honest gap: examples nobody could run and nobody said
|
|
99
|
+
* were prose.
|
|
100
|
+
*/
|
|
101
|
+
export function countDeclaredIllustrative(skipped) {
|
|
102
|
+
return skipped.filter((s) => s.reason === ILLUSTRATIVE_REASON).length;
|
|
103
|
+
}
|
|
62
104
|
/** How an example turned out. */
|
|
63
105
|
export var ExampleVerdict;
|
|
64
106
|
(function (ExampleVerdict) {
|
|
@@ -75,6 +117,14 @@ export var ExampleFindingKind;
|
|
|
75
117
|
ExampleFindingKind["ExampleFailed"] = "example-failed";
|
|
76
118
|
/** A code-bearing skill's doc offers no command to execute at all. */
|
|
77
119
|
ExampleFindingKind["NoDocumentedExample"] = "no-documented-example";
|
|
120
|
+
/**
|
|
121
|
+
* A code-bearing skill documents commands, but not one of them can be run
|
|
122
|
+
* (#707). Distinct from {@link NoDocumentedExample}, and it was the larger
|
|
123
|
+
* hole: 5 of 9 `cli:` skills sat here while the corpus looked documented.
|
|
124
|
+
* A skill in this state can break in every documented way and CI stays
|
|
125
|
+
* green, which is the false-green shape the whole check exists to close.
|
|
126
|
+
*/
|
|
127
|
+
ExampleFindingKind["NoExecutableExample"] = "no-executable-example";
|
|
78
128
|
})(ExampleFindingKind || (ExampleFindingKind = {}));
|
|
79
129
|
/**
|
|
80
130
|
* Whether `line` closes the currently open fence.
|
|
@@ -95,6 +145,11 @@ function fencedShellLines(text) {
|
|
|
95
145
|
const lines = text.split('\n');
|
|
96
146
|
let fence = null;
|
|
97
147
|
let shell = false;
|
|
148
|
+
let illustrative = false;
|
|
149
|
+
// The marker governs the NEXT fence, so it survives the blank line authors
|
|
150
|
+
// naturally leave between a comment and a block, and is spent by the fence
|
|
151
|
+
// it opens — a marker cannot leak onto a later, unrelated example.
|
|
152
|
+
let pendingMarker = false;
|
|
98
153
|
for (let i = 0; i < lines.length; i++) {
|
|
99
154
|
const line = lines[i];
|
|
100
155
|
const delimiter = /^\s*(`{3,}|~{3,})\s*([A-Za-z0-9_+-]*)/.exec(line);
|
|
@@ -104,16 +159,24 @@ function fencedShellLines(text) {
|
|
|
104
159
|
if (delimiter) {
|
|
105
160
|
fence = delimiter[1];
|
|
106
161
|
shell = SHELL_FENCES.has((delimiter[2] ?? '').toLowerCase());
|
|
162
|
+
illustrative = pendingMarker;
|
|
163
|
+
pendingMarker = false;
|
|
164
|
+
continue;
|
|
107
165
|
}
|
|
166
|
+
if (ILLUSTRATIVE_MARKER.test(line))
|
|
167
|
+
pendingMarker = true;
|
|
168
|
+
else if (line.trim() !== '')
|
|
169
|
+
pendingMarker = false;
|
|
108
170
|
continue;
|
|
109
171
|
}
|
|
110
172
|
if (closesFence(line, delimiter, fence)) {
|
|
111
173
|
fence = null;
|
|
112
174
|
shell = false;
|
|
175
|
+
illustrative = false;
|
|
113
176
|
continue;
|
|
114
177
|
}
|
|
115
178
|
if (shell)
|
|
116
|
-
out.push({ line: i + 1, raw: line });
|
|
179
|
+
out.push({ line: i + 1, raw: line, illustrative });
|
|
117
180
|
}
|
|
118
181
|
return out;
|
|
119
182
|
}
|
|
@@ -146,7 +209,7 @@ function classify(command) {
|
|
|
146
209
|
*/
|
|
147
210
|
export function extractExamples(text, skill, path) {
|
|
148
211
|
const out = [];
|
|
149
|
-
for (const { line, raw } of fencedShellLines(text)) {
|
|
212
|
+
for (const { line, raw, illustrative } of fencedShellLines(text)) {
|
|
150
213
|
// Strip a `$ ` or `> ` shell prompt; a doc that shows a prompt is still
|
|
151
214
|
// documenting the command after it.
|
|
152
215
|
const command = raw
|
|
@@ -157,8 +220,31 @@ export function extractExamples(text, skill, path) {
|
|
|
157
220
|
continue;
|
|
158
221
|
if (command !== 'canary' && !command.startsWith('canary '))
|
|
159
222
|
continue;
|
|
223
|
+
// A declaration beats an inference. The author saying "this is prose"
|
|
224
|
+
// is a better fact than the classifier guessing why it could not run,
|
|
225
|
+
// and it is the fact a reader of the skipped list needs.
|
|
226
|
+
if (illustrative) {
|
|
227
|
+
out.push({
|
|
228
|
+
skill,
|
|
229
|
+
path,
|
|
230
|
+
command,
|
|
231
|
+
line,
|
|
232
|
+
executable: false,
|
|
233
|
+
declaredIllustrative: true,
|
|
234
|
+
reason: ILLUSTRATIVE_REASON,
|
|
235
|
+
});
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
160
238
|
const { executable, reason } = classify(command);
|
|
161
|
-
out.push({
|
|
239
|
+
out.push({
|
|
240
|
+
skill,
|
|
241
|
+
path,
|
|
242
|
+
command,
|
|
243
|
+
line,
|
|
244
|
+
executable,
|
|
245
|
+
declaredIllustrative: false,
|
|
246
|
+
reason,
|
|
247
|
+
});
|
|
162
248
|
}
|
|
163
249
|
return out;
|
|
164
250
|
}
|
|
@@ -239,6 +325,20 @@ export function checkExamples(surfaces, run, cwd) {
|
|
|
239
325
|
}
|
|
240
326
|
continue;
|
|
241
327
|
}
|
|
328
|
+
// #707: documenting commands is not the same as documenting a RUNNABLE
|
|
329
|
+
// one. The cheapest fix is the skill's own `--help`, which needs no
|
|
330
|
+
// fixtures, credentials or network — and marking blocks illustrative
|
|
331
|
+
// cannot satisfy this, so the declaration stays honest.
|
|
332
|
+
if (codeBearing(decl) && !examples.some((e) => e.executable)) {
|
|
333
|
+
tally.findings.push({
|
|
334
|
+
kind: ExampleFindingKind.NoExecutableExample,
|
|
335
|
+
skill: decl.name,
|
|
336
|
+
path: decl.path,
|
|
337
|
+
detail: `declares a \`cli:\` and documents ${examples.length} command(s), ` +
|
|
338
|
+
'but none is executable, so nothing in its doc has ever been run. ' +
|
|
339
|
+
'Add one placeholder-free help-shaped example (its own `--help`).',
|
|
340
|
+
});
|
|
341
|
+
}
|
|
242
342
|
tallyDeclaration(decl, runExamples(examples, run, cwd), tally);
|
|
243
343
|
}
|
|
244
344
|
return tally;
|