canary-test-cli 7.0.0 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/engine/analysis/cli.js +116 -54
  2. package/dist/engine/analysis/engine.js +34 -16
  3. package/dist/engine/analysis/reports.js +5 -4
  4. package/dist/engine/cli-commands.js +249 -41
  5. package/dist/engine/cli-common.js +15 -24
  6. package/dist/engine/cli.core.js +37 -11
  7. package/dist/engine/cli.js +2 -2
  8. package/dist/engine/company-knowledge-cli.js +2 -2
  9. package/dist/engine/core/adoption.js +408 -0
  10. package/dist/engine/core/framework-probes.js +7 -7
  11. package/dist/engine/core/fs-glob.js +2 -2
  12. package/dist/engine/core/gate-result.js +17 -0
  13. package/dist/engine/core/migrator.js +9 -17
  14. package/dist/engine/core/pattern-matcher.js +23 -5
  15. package/dist/engine/core/persona.js +421 -0
  16. package/dist/engine/core/promotion-verdict.js +261 -0
  17. package/dist/engine/core/reporter.js +1 -9
  18. package/dist/engine/core/skill-examples.js +292 -0
  19. package/dist/engine/core/skill-surfaces.js +307 -0
  20. package/dist/engine/core/static-linter.js +310 -38
  21. package/dist/engine/core/ticket-updater.js +1 -7
  22. package/dist/engine/core/vacuity-scanner.js +556 -0
  23. package/dist/engine/core/workflow-discovery.js +2 -8
  24. package/dist/engine/core/workspace-detect.js +7 -6
  25. package/dist/engine/data/personas/registry.json +36 -0
  26. package/dist/engine/guardian/adjudication.js +5 -5
  27. package/dist/engine/guardian/analysis-emit.js +13 -27
  28. package/dist/engine/guardian/cli.js +30 -43
  29. package/dist/engine/guardian/coverage.js +1 -1
  30. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
  31. package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
  32. package/dist/engine/guardian/pr-check.js +5 -15
  33. package/dist/engine/guardian/pr-comment.js +4 -3
  34. package/dist/engine/history/cli.js +210 -6
  35. package/dist/engine/history/ndjson-store.js +9 -5
  36. package/dist/engine/history/record.js +34 -5
  37. package/dist/engine/history/run-recorder.js +165 -0
  38. package/dist/engine/history/schema.js +25 -7
  39. package/dist/engine/history/store.js +9 -0
  40. package/dist/engine/mcp-server.js +35 -13
  41. package/dist/engine/skills-cli.js +133 -11
  42. package/dist/engine/util/ensure-ascii.js +37 -0
  43. package/dist/engine/workflow-cli.js +6 -6
  44. package/dist/gate-result.d.ts +11 -0
  45. package/dist/gate-result.js +18 -0
  46. package/dist/uninstall.js +12 -5
  47. package/package.json +1 -1
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { mkdirSync, writeFileSync } from 'node:fs';
27
27
  import { dirname } from 'node:path';
28
+ import { ensureAscii } from '../util/ensure-ascii.js';
28
29
  const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
29
30
  const TOOL_NAME = 'Canary';
30
31
  const TOOL_VERSION = '0.1.0';
@@ -70,15 +71,6 @@ function pyOr(value, fallback) {
70
71
  function pyGet(obj, key, fallback) {
71
72
  return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
72
73
  }
73
- /**
74
- * Reproduce Python's `json.dumps(..., ensure_ascii=True)` (the library default)
75
- * on `JSON.stringify` output: escape every code point >= 0x80 as `\uXXXX`. Only
76
- * touches the >= 0x80 range, so the ASCII escapes `JSON.stringify` already
77
- * produced are left intact. (Same helper as `guardian/pr-check.ts`.)
78
- */
79
- function ensureAscii(json) {
80
- return json.replace(/[€-￿]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
81
- }
82
74
  /**
83
75
  * `json.dumps(default=str)` replacer. Values the encoder can't natively handle
84
76
  * are coerced via `str()`; in JS the only common such value that would
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Execute the commands the docs promise (#487).
3
+ *
4
+ * #472 added `canary skills run canary-blackhawk -- --help` to a SKILL.md in
5
+ * the same PR that left the command broken: the CLI landed at mode 644, the
6
+ * spawn hit `EACCES`, and that became a bare exit 1 with no output. Nobody had
7
+ * ever run the command the doc documented. #480 answered part of it with a
8
+ * hand-written `spawnSync(cli, ['--help'])` per skill — real execution, which
9
+ * is why the exec-bit bug surfaced at all — but the block is duplicated across
10
+ * five test files, so a seventh skill is covered only when somebody remembers
11
+ * to add a sixth copy (#479).
12
+ *
13
+ * This module is the discovery-driven form. It consumes the surface inventory
14
+ * from {@link ./skill-surfaces.js} — deliberately, rather than walking the skill
15
+ * tree a second time: two walkers with two notions of what a skill is would
16
+ * disagree eventually, and that disagreement is the very bug class both checks
17
+ * exist to catch.
18
+ *
19
+ * ## Which examples are executable
20
+ *
21
+ * #487 left this as an open scope decision. The answer taken here is
22
+ * conservative and mechanical, because the alternative — a fenced-block
23
+ * annotation — asks every SKILL.md author to opt in, and an opt-in that is
24
+ * forgotten reads exactly like a skill with no examples:
25
+ *
26
+ * - The command must live in a **shell-info fenced block** (` ```bash `,
27
+ * `sh`, `shell`, `zsh`, `console`). Prose backticks are illustrative.
28
+ * - It must be a **`canary` invocation**. Running arbitrary `npm`/`git` lines
29
+ * out of a doc is a different and much larger blast radius.
30
+ * - It must carry **no placeholder or shell metacharacter** (`<path>`, `$VAR`,
31
+ * a pipe, a glob). A placeholder command was never meant to run verbatim.
32
+ * - It must be **help-shaped** (`--help` / `-h` / `--version`, or a pure
33
+ * listing command). This is what keeps a documented `katana scan` from
34
+ * writing a ledger into whatever directory CI happens to be sitting in.
35
+ *
36
+ * Everything else is {@link ExampleVerdict.Unverifiable}, **with its reason
37
+ * recorded**. That is the load-bearing half of the design, and it is the same
38
+ * distinction `reachability.ts` draws between a dead link and a slow one: an
39
+ * outcome the checker is not entitled to assert on gets its own status instead
40
+ * of being folded into either pass or fail.
41
+ *
42
+ * ## Denominator
43
+ *
44
+ * `checked` counts the examples **actually executed** — never the examples
45
+ * found. Unverifiable examples travel in `GateResult.skipped`, so `gateOutcome`
46
+ * renders them in every summary line (D7) and an all-illustrative corpus
47
+ * ABSTAINS rather than reporting "all 0 examples passed" (#508).
48
+ */
49
+ import { spawnSync } from 'node:child_process';
50
+ import { SurfaceKind } from './skill-surfaces.js';
51
+ /** Fence info strings whose contents are shell commands. */
52
+ const SHELL_FENCES = new Set(['bash', 'sh', 'shell', 'zsh', 'console']);
53
+ /**
54
+ * Characters that make a command line unsafe to run verbatim: placeholder
55
+ * brackets, variable expansion, redirection, pipes, subshells, globs.
56
+ */
57
+ const PLACEHOLDER = /[<>${}|`*\\]/;
58
+ /** Flags that make an invocation a pure read of the CLI's own surface. */
59
+ const HELP_FLAGS = new Set(['--help', '-h', '--version', '-V']);
60
+ /** Non-mutating subcommands worth executing even without a help flag. */
61
+ const READ_ONLY_COMMANDS = new Set(['canary skills list']);
62
+ /** How an example turned out. */
63
+ export var ExampleVerdict;
64
+ (function (ExampleVerdict) {
65
+ /** Ran and exited 0 — the doc is proven. */
66
+ ExampleVerdict["Executed"] = "executed";
67
+ /** Ran and did not exit 0 — the doc promises something broken. */
68
+ ExampleVerdict["Failed"] = "failed";
69
+ /** Could not be run at all; the reason travels with it. */
70
+ ExampleVerdict["Unverifiable"] = "unverifiable";
71
+ })(ExampleVerdict || (ExampleVerdict = {}));
72
+ export var ExampleFindingKind;
73
+ (function (ExampleFindingKind) {
74
+ /** A documented command was executed and failed. */
75
+ ExampleFindingKind["ExampleFailed"] = "example-failed";
76
+ /** A code-bearing skill's doc offers no command to execute at all. */
77
+ ExampleFindingKind["NoDocumentedExample"] = "no-documented-example";
78
+ })(ExampleFindingKind || (ExampleFindingKind = {}));
79
+ /**
80
+ * Whether `line` closes the currently open fence.
81
+ *
82
+ * A fence closes only on a BARE delimiter run at least as long as the opener,
83
+ * so a ```` block may legitimately contain ``` -- the same fence rule
84
+ * `scripts/check_doc_links.mjs` had to get right for #686.
85
+ */
86
+ function closesFence(line, delimiter, fence) {
87
+ if (delimiter === null)
88
+ return false;
89
+ const run = delimiter[1];
90
+ return (run[0] === fence[0] && run.length >= fence.length && line.trim() === run);
91
+ }
92
+ /** Shell-fenced lines with their 1-based source line numbers. */
93
+ function fencedShellLines(text) {
94
+ const out = [];
95
+ const lines = text.split('\n');
96
+ let fence = null;
97
+ let shell = false;
98
+ for (let i = 0; i < lines.length; i++) {
99
+ const line = lines[i];
100
+ const delimiter = /^\s*(`{3,}|~{3,})\s*([A-Za-z0-9_+-]*)/.exec(line);
101
+ if (fence === null) {
102
+ // A fence opens on a delimiter run; the info string decides whether the
103
+ // body is shell. Anything else (json, ts, text) is not a command.
104
+ if (delimiter) {
105
+ fence = delimiter[1];
106
+ shell = SHELL_FENCES.has((delimiter[2] ?? '').toLowerCase());
107
+ }
108
+ continue;
109
+ }
110
+ if (closesFence(line, delimiter, fence)) {
111
+ fence = null;
112
+ shell = false;
113
+ continue;
114
+ }
115
+ if (shell)
116
+ out.push({ line: i + 1, raw: line });
117
+ }
118
+ return out;
119
+ }
120
+ /** Classify one command line: executable, or unverifiable with a reason. */
121
+ function classify(command) {
122
+ if (PLACEHOLDER.test(command)) {
123
+ return {
124
+ executable: false,
125
+ reason: 'contains a placeholder or shell metacharacter, so it was never ' +
126
+ 'meant to run verbatim',
127
+ };
128
+ }
129
+ if (READ_ONLY_COMMANDS.has(command))
130
+ return { executable: true, reason: null };
131
+ const tokens = command.split(/\s+/);
132
+ if (tokens.some((t) => HELP_FLAGS.has(t))) {
133
+ return { executable: true, reason: null };
134
+ }
135
+ return {
136
+ executable: false,
137
+ reason: 'not help-shaped, so running it could write files, need credentials, ' +
138
+ 'or reach the network',
139
+ };
140
+ }
141
+ /**
142
+ * Extract the documented `canary` commands from one document.
143
+ *
144
+ * `text` is the raw file body (the inventory already read it), `skill` and
145
+ * `path` are carried through onto each example so a finding is attributable.
146
+ */
147
+ export function extractExamples(text, skill, path) {
148
+ const out = [];
149
+ for (const { line, raw } of fencedShellLines(text)) {
150
+ // Strip a `$ ` or `> ` shell prompt; a doc that shows a prompt is still
151
+ // documenting the command after it.
152
+ const command = raw
153
+ .trim()
154
+ .replace(/^[$>]\s+/, '')
155
+ .trim();
156
+ if (command === '' || command.startsWith('#'))
157
+ continue;
158
+ if (command !== 'canary' && !command.startsWith('canary '))
159
+ continue;
160
+ const { executable, reason } = classify(command);
161
+ out.push({ skill, path, command, line, executable, reason });
162
+ }
163
+ return out;
164
+ }
165
+ /** Execute the executable examples; report a verdict for every example. */
166
+ export function runExamples(examples, run, cwd) {
167
+ return examples.map((example) => {
168
+ if (!example.executable) {
169
+ return {
170
+ example,
171
+ verdict: ExampleVerdict.Unverifiable,
172
+ detail: example.reason ?? 'unverifiable',
173
+ };
174
+ }
175
+ const { status, output } = run(example.command, cwd);
176
+ if (status === 0) {
177
+ return { example, verdict: ExampleVerdict.Executed, detail: 'exit 0' };
178
+ }
179
+ return {
180
+ example,
181
+ verdict: ExampleVerdict.Failed,
182
+ detail: status === null
183
+ ? `the process never started: ${output.trim() || '(no output)'}`
184
+ : `exit ${status}: ${output.trim() || '(no output)'}`,
185
+ };
186
+ });
187
+ }
188
+ /** A skill declaration that ships code, and can therefore ship broken code. */
189
+ function codeBearing(decl) {
190
+ return ((decl.kind === SurfaceKind.Skill || decl.kind === SurfaceKind.FlatSkill) &&
191
+ decl.cli !== null);
192
+ }
193
+ /** Fold one skill declaration's results into `tally`. */
194
+ function tallyDeclaration(decl, results, tally) {
195
+ for (const result of results) {
196
+ // `<skill>:<line>` rather than the absolute path: a skill's SKILL.md is
197
+ // unambiguous from its name, and 29 absolute paths turned the D7 skip
198
+ // suffix into a summary line no reader would finish. Still fully
199
+ // attributable; `--json` carries the paths.
200
+ const where = `${decl.name}:${result.example.line}`;
201
+ if (result.verdict === ExampleVerdict.Unverifiable) {
202
+ // Never silently dropped: a skip renders in the summary line, so an
203
+ // example nobody can run stays visible instead of leaving the corpus.
204
+ tally.skipped.push({ name: where, reason: result.detail });
205
+ continue;
206
+ }
207
+ tally.checked += 1;
208
+ if (result.verdict === ExampleVerdict.Failed) {
209
+ tally.findings.push({
210
+ kind: ExampleFindingKind.ExampleFailed,
211
+ skill: decl.name,
212
+ path: decl.path,
213
+ detail: `\`${result.example.command}\` (line ${result.example.line}) ${result.detail}`,
214
+ });
215
+ }
216
+ }
217
+ }
218
+ export function checkExamples(surfaces, run, cwd) {
219
+ const tally = { checked: 0, findings: [], skipped: [] };
220
+ for (const decl of surfaces) {
221
+ if (decl.kind !== SurfaceKind.Skill &&
222
+ decl.kind !== SurfaceKind.FlatSkill) {
223
+ continue;
224
+ }
225
+ const examples = extractExamples(decl.text, decl.name, decl.path);
226
+ // #487 acceptance: a code-bearing skill with no runnable command in its
227
+ // doc is UNPROVEN, not clean. A markdown-only skill has no command that a
228
+ // mode bit could break, so it is not held to this.
229
+ if (examples.length === 0) {
230
+ if (codeBearing(decl)) {
231
+ tally.findings.push({
232
+ kind: ExampleFindingKind.NoDocumentedExample,
233
+ skill: decl.name,
234
+ path: decl.path,
235
+ detail: 'declares a `cli:` but its SKILL.md documents no command in a ' +
236
+ 'shell fence, so nothing about it has ever been executed from ' +
237
+ 'the doc',
238
+ });
239
+ }
240
+ continue;
241
+ }
242
+ tallyDeclaration(decl, runExamples(examples, run, cwd), tally);
243
+ }
244
+ return tally;
245
+ }
246
+ /**
247
+ * The production runner: spawn the documented command against the repo's own
248
+ * built CLI, in `cwd`.
249
+ *
250
+ * Two deliberate substitutions, both narrow:
251
+ *
252
+ * - The leading `canary` token becomes `node <canaryBin>`, because a doc
253
+ * writes the installed name and CI has a checkout.
254
+ * - `--allow-executable-skills` is inserted into a `skills run` invocation,
255
+ * ahead of any `--` separator. `isExecutableSkillAllowed` refuses `cli:`
256
+ * skills without a TTY, which a spawned process never has, so without the
257
+ * flag every example would exit 3 and the check would measure the sandbox
258
+ * rather than the doc. The flag is an execution-context opt-in and changes
259
+ * nothing about the command's behaviour once it runs. See
260
+ * {@link exampleArgv} for why the position matters.
261
+ */
262
+ export function exampleArgv(command, canaryBin) {
263
+ const [, ...rest] = command.split(/\s+/);
264
+ if (rest[0] !== 'skills' || rest[1] !== 'run')
265
+ return [canaryBin, ...rest];
266
+ // The flag must land BEFORE `--`, or canary forwards it to the skill and the
267
+ // executable-skill guard still refuses. Appending it was the first bug this
268
+ // checker found, in itself: all four documented `skills run ... -- --help`
269
+ // examples reported exit 3, which measured the sandbox rather than the doc.
270
+ const sep = rest.indexOf('--');
271
+ const at = sep === -1 ? rest.length : sep;
272
+ return [
273
+ canaryBin,
274
+ ...rest.slice(0, at),
275
+ '--allow-executable-skills',
276
+ ...rest.slice(at),
277
+ ];
278
+ }
279
+ export function spawnRunner(canaryBin) {
280
+ return (command, cwd) => {
281
+ const res = spawnSync(process.execPath, exampleArgv(command, canaryBin), {
282
+ cwd,
283
+ encoding: 'utf-8',
284
+ timeout: 60_000,
285
+ });
286
+ return {
287
+ status: res.status,
288
+ output: `${res.stdout ?? ''}${res.stderr ?? ''}`,
289
+ };
290
+ };
291
+ }
292
+ //# sourceMappingURL=skill-examples.js.map
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Cross-surface skill-declaration inventory and integrity check (#452).
3
+ *
4
+ * A canary skill is not declared in one place. The same name appears in the
5
+ * `SKILL.md` under `agents/skills/`, in the plugin slash command under
6
+ * `commands/`, in the agent definition under `agents/`, and again in the
7
+ * per-host `agents/commands/<host>/` and `agents/agents/<host>/` trees. Each of
8
+ * those files is internally consistent, so every per-file assertion passes even
9
+ * when they disagree with each other or point at something that no longer
10
+ * exists.
11
+ *
12
+ * That is the bug class #452 describes: grading a value against a hardcoded
13
+ * literal is blind to a fact that is right on one surface and stale on another.
14
+ * The remedy is to grade against **agreement** and **resolvability** instead:
15
+ * enumerate the surfaces that declare a name, then verify the declaration
16
+ * against reality.
17
+ *
18
+ * ## The two rules it enforces
19
+ *
20
+ * 1. **A declared name is the name the invocation path uses.** Skill discovery
21
+ * ({@link ../core/skill-registry.js}) keys off the frontmatter `name:`, while
22
+ * every doc invokes the skill by its directory. When those diverge the
23
+ * documented command misses and nothing says so.
24
+ * 2. **A documented invocation reaches something that exists and can run.** A
25
+ * `cli:` path that is absent, or present at mode 0644, installs cleanly,
26
+ * lists cleanly, documents cleanly — and cannot run (#478). A
27
+ * `canary skills run <name>` in prose whose target has been renamed away is
28
+ * the same defect one layer up.
29
+ *
30
+ * ## What it deliberately refuses to do
31
+ *
32
+ * It does not adjudicate prose. When two surfaces carry different descriptions
33
+ * there is no principled culprit: #452's triage settled that majority-wins is
34
+ * wrong at N=2 and wrong in general when one surface is the write path and the
35
+ * others are read models, and that only a fixture-intent floor may name a
36
+ * culprit. No such floor exists for skill prose, so divergent descriptions are
37
+ * not reported at all rather than reported against an invented winner. Adding
38
+ * that arbitration is the remaining half of #452 and is out of scope here.
39
+ *
40
+ * ## Denominator
41
+ *
42
+ * {@link checkSurfaces} returns a {@link GateResult} whose `checked` is the
43
+ * number of surface declarations actually inspected. Zero means the layout was
44
+ * renamed underneath the check, and `gateOutcome` turns that into a loud
45
+ * abstention rather than "all surfaces agree" (#508).
46
+ */
47
+ import { accessSync, constants, readFileSync, readdirSync } from 'node:fs';
48
+ import { basename, extname, join } from 'node:path';
49
+ import { SkillRegistry } from './skill-registry.js';
50
+ /** Where a skill name was declared. */
51
+ export var SurfaceKind;
52
+ (function (SurfaceKind) {
53
+ /** `agents/skills/<host>/<dir>/SKILL.md` — the skill itself. */
54
+ SurfaceKind["Skill"] = "skill";
55
+ /** `agents/skills/*.md` — a flat slash-command skill. */
56
+ SurfaceKind["FlatSkill"] = "flat-skill";
57
+ /** `commands/*.md` — the plugin slash command fronting a skill. */
58
+ SurfaceKind["PluginCommand"] = "plugin-command";
59
+ /** `agents/*.md` — the plugin agent definition. */
60
+ SurfaceKind["PluginAgent"] = "plugin-agent";
61
+ /** `agents/commands/<host>/**` — the per-host command definition. */
62
+ SurfaceKind["HarnessCommand"] = "harness-command";
63
+ /** `agents/agents/<host>/**` — the per-host agent definition. */
64
+ SurfaceKind["HarnessAgent"] = "harness-agent";
65
+ })(SurfaceKind || (SurfaceKind = {}));
66
+ /** What a cross-surface check found. */
67
+ export var SurfaceFindingKind;
68
+ (function (SurfaceFindingKind) {
69
+ /** Frontmatter `name:` disagrees with the directory docs invoke it by. */
70
+ SurfaceFindingKind["NameMismatch"] = "name-mismatch";
71
+ /** A declared `cli:` target does not exist. */
72
+ SurfaceFindingKind["CliMissing"] = "cli-missing";
73
+ /** A declared `cli:` target exists but is not executable (#478). */
74
+ SurfaceFindingKind["CliNotExecutable"] = "cli-not-executable";
75
+ /** A documented `canary skills run <name>` names no discoverable skill. */
76
+ SurfaceFindingKind["UnreachableReference"] = "unreachable-reference";
77
+ })(SurfaceFindingKind || (SurfaceFindingKind = {}));
78
+ /** Vendored trees are not first-party skill surfaces. */
79
+ const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage']);
80
+ /**
81
+ * A literal canary skill name. Anchored on the `canary-`/`canary:` prefix every
82
+ * first-party skill carries, which keeps a hypothetical `canary skills run
83
+ * my-skill` in prose from being reported as a dead reference.
84
+ */
85
+ const SKILL_NAME = /^canary[-:][a-z0-9:_-]*$/;
86
+ /** Every `canary skills run <name>` in the document, placeholders excluded. */
87
+ function referencedSkills(text) {
88
+ const found = new Set();
89
+ const pattern = /canary\s+skills\s+run\s+(\S+)/g;
90
+ for (const match of text.matchAll(pattern)) {
91
+ const token = match[1].replace(/[`'"]/g, '');
92
+ if (SKILL_NAME.test(token))
93
+ found.add(token);
94
+ }
95
+ return [...found].sort();
96
+ }
97
+ /** Read a file, returning null when it cannot be read. */
98
+ function readOrNull(path) {
99
+ try {
100
+ return readFileSync(path, 'utf-8');
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ function listDirs(path) {
107
+ try {
108
+ return readdirSync(path, { withFileTypes: true })
109
+ .filter((e) => e.isDirectory() && !IGNORED_DIRS.has(e.name))
110
+ .map((e) => e.name)
111
+ .sort();
112
+ }
113
+ catch {
114
+ return [];
115
+ }
116
+ }
117
+ function listMarkdown(path) {
118
+ try {
119
+ return readdirSync(path, { withFileTypes: true })
120
+ .filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== 'README.md')
121
+ .map((e) => e.name)
122
+ .sort();
123
+ }
124
+ catch {
125
+ return [];
126
+ }
127
+ }
128
+ /** Parse one declaring file into a {@link SurfaceDeclaration}. */
129
+ function declare(kind, path, fallbackName, dirName) {
130
+ const text = readOrNull(path);
131
+ if (text === null)
132
+ return null;
133
+ // The SAME frontmatter parser discovery uses, deliberately: a checker with
134
+ // its own YAML subset would disagree with the runtime it is auditing, and
135
+ // that disagreement is the bug class rather than a detail (#501).
136
+ const fm = SkillRegistry.parseFrontmatter(text);
137
+ const name = typeof fm['name'] === 'string' && fm['name'] ? fm['name'] : fallbackName;
138
+ const description = typeof fm['description'] === 'string' ? fm['description'] : '';
139
+ const cli = typeof fm['cli'] === 'string' && fm['cli'] ? fm['cli'] : null;
140
+ return {
141
+ kind,
142
+ name,
143
+ dirName,
144
+ path,
145
+ description,
146
+ cli,
147
+ references: referencedSkills(text),
148
+ text,
149
+ };
150
+ }
151
+ /** Nested `agents/skills/<host>/<dir>/SKILL.md` declarations. */
152
+ function nestedSkills(root) {
153
+ const out = [];
154
+ const skillsRoot = join(root, 'agents', 'skills');
155
+ for (const host of listDirs(skillsRoot)) {
156
+ for (const dir of listDirs(join(skillsRoot, host))) {
157
+ const path = join(skillsRoot, host, dir, 'SKILL.md');
158
+ const decl = declare(SurfaceKind.Skill, path, dir, dir);
159
+ if (decl)
160
+ out.push(decl);
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+ /** Flat `*.md` surfaces directly inside `dir`. */
166
+ function flatSurfaces(dir, kind) {
167
+ const out = [];
168
+ for (const file of listMarkdown(dir)) {
169
+ const path = join(dir, file);
170
+ const decl = declare(kind, path, basename(file, extname(file)), null);
171
+ if (decl)
172
+ out.push(decl);
173
+ }
174
+ return out;
175
+ }
176
+ /** Per-host `agents/<commands|agents>/<host>/**\/*.md` surfaces. */
177
+ function hostSurfaces(root, segment, kind) {
178
+ const out = [];
179
+ const base = join(root, 'agents', segment);
180
+ for (const host of listDirs(base)) {
181
+ const hostDir = join(base, host);
182
+ out.push(...flatSurfaces(hostDir, kind));
183
+ // Host trees nest one more level (`harness/harness/<name>.md`).
184
+ for (const sub of listDirs(hostDir)) {
185
+ out.push(...flatSurfaces(join(hostDir, sub), kind));
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+ /**
191
+ * Enumerate every surface that declares a skill name, in a stable order.
192
+ *
193
+ * Purely a read: no network, no subprocess, no writes. `root` is the repository
194
+ * root, injectable so tests build small fixture trees instead of asserting
195
+ * against the live checkout.
196
+ */
197
+ export function collectSurfaces(root) {
198
+ return [
199
+ ...nestedSkills(root),
200
+ ...flatSurfaces(join(root, 'agents', 'skills'), SurfaceKind.FlatSkill),
201
+ ...flatSurfaces(join(root, 'commands'), SurfaceKind.PluginCommand),
202
+ ...flatSurfaces(join(root, 'agents'), SurfaceKind.PluginAgent),
203
+ ...hostSurfaces(root, 'commands', SurfaceKind.HarnessCommand),
204
+ ...hostSurfaces(root, 'agents', SurfaceKind.HarnessAgent),
205
+ ];
206
+ }
207
+ /** Process-backed defaults. */
208
+ function defaultSurfaceDeps() {
209
+ return {
210
+ isExecutable: (path) => {
211
+ try {
212
+ // X_OK is exactly what `canary skills run` needs; R_OK alone is #478.
213
+ accessSync(path, constants.X_OK);
214
+ return true;
215
+ }
216
+ catch {
217
+ return false;
218
+ }
219
+ },
220
+ exists: (path) => {
221
+ try {
222
+ accessSync(path, constants.F_OK);
223
+ return true;
224
+ }
225
+ catch {
226
+ return false;
227
+ }
228
+ },
229
+ };
230
+ }
231
+ /** Findings for one declaration's `cli:` target. */
232
+ function cliFindings(decl, deps) {
233
+ if (decl.cli === null)
234
+ return [];
235
+ const target = join(decl.path, '..', decl.cli);
236
+ if (!deps.exists(target)) {
237
+ return [
238
+ {
239
+ kind: SurfaceFindingKind.CliMissing,
240
+ surface: decl.kind,
241
+ name: decl.name,
242
+ path: decl.path,
243
+ detail: `declares \`cli: ${decl.cli}\` but the target does not exist, so ` +
244
+ '`canary skills run` cannot invoke it',
245
+ },
246
+ ];
247
+ }
248
+ if (!deps.isExecutable(target)) {
249
+ return [
250
+ {
251
+ kind: SurfaceFindingKind.CliNotExecutable,
252
+ surface: decl.kind,
253
+ name: decl.name,
254
+ path: decl.path,
255
+ detail: `declares \`cli: ${decl.cli}\` but the target is not executable ` +
256
+ '(#478: mode 0644 installs, lists, and documents cleanly, then fails ' +
257
+ 'with EACCES mapped to a bare exit 1)',
258
+ },
259
+ ];
260
+ }
261
+ return [];
262
+ }
263
+ /**
264
+ * Verify every declaration against reality.
265
+ *
266
+ * Classified **advisory**: it is landing on a repository whose surfaces have
267
+ * never been checked before, so its precision is not yet known and promoting it
268
+ * to a blocking gate would teach people to ignore it (ADR 0010). The
269
+ * zero-denominator abstention is not advisory — that path is loud either way.
270
+ */
271
+ export function checkSurfaces(root, deps = defaultSurfaceDeps()) {
272
+ const surfaces = collectSurfaces(root);
273
+ const known = new Set(surfaces
274
+ .filter((s) => s.kind === SurfaceKind.Skill || s.kind === SurfaceKind.FlatSkill)
275
+ .flatMap((s) => (s.dirName ? [s.name, s.dirName] : [s.name])));
276
+ const findings = [];
277
+ for (const decl of surfaces) {
278
+ if (decl.kind === SurfaceKind.Skill &&
279
+ decl.dirName !== null &&
280
+ decl.name !== decl.dirName) {
281
+ findings.push({
282
+ kind: SurfaceFindingKind.NameMismatch,
283
+ surface: decl.kind,
284
+ name: decl.name,
285
+ path: decl.path,
286
+ detail: `frontmatter declares \`name: ${decl.name}\` but the directory is ` +
287
+ `\`${decl.dirName}\`; discovery keys off the frontmatter, docs invoke ` +
288
+ 'the directory, so one of the two paths misses',
289
+ });
290
+ }
291
+ findings.push(...cliFindings(decl, deps));
292
+ for (const ref of decl.references) {
293
+ if (!known.has(ref)) {
294
+ findings.push({
295
+ kind: SurfaceFindingKind.UnreachableReference,
296
+ surface: decl.kind,
297
+ name: decl.name,
298
+ path: decl.path,
299
+ detail: `documents \`canary skills run ${ref}\`, and no discoverable skill ` +
300
+ 'answers to that name',
301
+ });
302
+ }
303
+ }
304
+ }
305
+ return { checked: surfaces.length, findings };
306
+ }
307
+ //# sourceMappingURL=skill-surfaces.js.map