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.
- package/dist/engine/analysis/cli.js +116 -54
- package/dist/engine/analysis/engine.js +34 -16
- package/dist/engine/analysis/reports.js +5 -4
- package/dist/engine/cli-commands.js +249 -41
- package/dist/engine/cli-common.js +15 -24
- package/dist/engine/cli.core.js +37 -11
- package/dist/engine/cli.js +2 -2
- package/dist/engine/company-knowledge-cli.js +2 -2
- package/dist/engine/core/adoption.js +408 -0
- package/dist/engine/core/framework-probes.js +7 -7
- package/dist/engine/core/fs-glob.js +2 -2
- package/dist/engine/core/gate-result.js +17 -0
- package/dist/engine/core/migrator.js +9 -17
- package/dist/engine/core/pattern-matcher.js +23 -5
- package/dist/engine/core/persona.js +421 -0
- package/dist/engine/core/promotion-verdict.js +261 -0
- package/dist/engine/core/reporter.js +1 -9
- package/dist/engine/core/skill-examples.js +292 -0
- package/dist/engine/core/skill-surfaces.js +307 -0
- package/dist/engine/core/static-linter.js +310 -38
- package/dist/engine/core/ticket-updater.js +1 -7
- package/dist/engine/core/vacuity-scanner.js +556 -0
- package/dist/engine/core/workflow-discovery.js +2 -8
- package/dist/engine/core/workspace-detect.js +7 -6
- package/dist/engine/data/personas/registry.json +36 -0
- package/dist/engine/guardian/adjudication.js +5 -5
- package/dist/engine/guardian/analysis-emit.js +13 -27
- package/dist/engine/guardian/cli.js +30 -43
- package/dist/engine/guardian/coverage.js +1 -1
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
- package/dist/engine/guardian/pr-check.js +5 -15
- package/dist/engine/guardian/pr-comment.js +4 -3
- package/dist/engine/history/cli.js +210 -6
- package/dist/engine/history/ndjson-store.js +9 -5
- package/dist/engine/history/record.js +34 -5
- package/dist/engine/history/run-recorder.js +165 -0
- package/dist/engine/history/schema.js +25 -7
- package/dist/engine/history/store.js +9 -0
- package/dist/engine/mcp-server.js +35 -13
- package/dist/engine/skills-cli.js +133 -11
- package/dist/engine/util/ensure-ascii.js +37 -0
- package/dist/engine/workflow-cli.js +6 -6
- package/dist/gate-result.d.ts +11 -0
- package/dist/gate-result.js +18 -0
- package/dist/uninstall.js +12 -5
- 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
|