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
@@ -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, readdirSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
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 { JS_TEST_EXTENSIONS, frameworkForPath } from './core/static-linter.js';
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
- playwright: /\bexpect\s*\(|\btoBeVisible\b|\btoHaveText\b|\btoHaveTitle\b|\btoHaveURL\b|\btoBeEnabled\b|\btoBeDisabled\b|\btoBeChecked\b|\btoHaveValue\b|\btoHaveCount\b/g,
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({ skill, path, command, line, executable, reason });
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;