canary-test-cli 6.3.0 → 6.5.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/bin/canary-mcp.js +52 -0
- package/dist/doctor.d.ts +51 -3
- package/dist/doctor.js +76 -9
- package/dist/engine/analysis/cli.js +69 -6
- package/dist/engine/cli-commands.js +34 -1
- package/dist/engine/core/feedback.js +32 -18
- package/dist/engine/core/gate-result.js +80 -0
- package/dist/engine/core/migrator.js +83 -10
- package/dist/engine/core/skill-registry.js +95 -30
- package/dist/engine/guardian/adjudication.js +364 -0
- package/dist/engine/guardian/analysis-emit.js +2 -0
- package/dist/engine/guardian/cli.js +282 -15
- package/dist/engine/guardian/hard-gate.js +15 -2
- package/dist/engine/guardian/pr-check.js +5 -12
- package/dist/engine/history/cli.js +67 -0
- package/dist/engine/history/ndjson-store.js +4 -0
- package/dist/engine/history/store.js +3 -0
- package/dist/gate-result.d.ts +67 -0
- package/dist/gate-result.js +73 -0
- package/dist/overlay-commands.js +17 -1
- package/dist/overlay-lint.d.ts +6 -1
- package/dist/overlay-lint.js +53 -68
- package/dist/skill-frontmatter.d.ts +24 -0
- package/dist/skill-frontmatter.js +89 -0
- package/package.json +5 -2
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared gate-abstention helper (issue #508, no-silent-abstention spec).
|
|
3
|
+
*
|
|
4
|
+
* Doctrine: a check that verified zero items has ABSTAINED, not passed.
|
|
5
|
+
* Every gate reports its denominator (`checked`); zero is a distinct loud
|
|
6
|
+
* outcome. "Skipped" renders in every summary line and never aggregates
|
|
7
|
+
* into "passed" (D7).
|
|
8
|
+
*
|
|
9
|
+
* `gateOutcome` is the only path to a summary line for swept commands, so
|
|
10
|
+
* the refusal to print bare success on a zero denominator is structural.
|
|
11
|
+
* Surfaces append their own remediation text (why the denominator
|
|
12
|
+
* collapsed, first fix step) after the summary line.
|
|
13
|
+
*
|
|
14
|
+
* Output glyphs are written as `\u{...}` escapes so this source stays
|
|
15
|
+
* ASCII while the emitted bytes match the rest of the CLI (warning sign
|
|
16
|
+
* U+26A0, em dash U+2014).
|
|
17
|
+
*/
|
|
18
|
+
/** A check that was not run, and why. Always visible, never "passed". */
|
|
19
|
+
export interface SkipEntry {
|
|
20
|
+
name: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
}
|
|
23
|
+
/** What a gate actually verified: its denominator and what it found. */
|
|
24
|
+
export interface GateResult<F> {
|
|
25
|
+
/** How many items were actually verified. Skipped items do NOT count. */
|
|
26
|
+
checked: number;
|
|
27
|
+
findings: F[];
|
|
28
|
+
skipped?: SkipEntry[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Reserved CLI-wide (D4): exit 3 always means "abstained -- verified zero
|
|
32
|
+
* items", distinct from 0 (clean), 1 (findings), 2 (surface-specific).
|
|
33
|
+
*/
|
|
34
|
+
export declare const EXIT_ABSTAINED = 3;
|
|
35
|
+
/**
|
|
36
|
+
* D3: a "gate" has an exit-code contract and fails loud (exit 3) on a zero
|
|
37
|
+
* denominator; an "advisory" command warns unmissably but exits 0 -- an
|
|
38
|
+
* empty answer honestly labeled is not an error.
|
|
39
|
+
*/
|
|
40
|
+
export type GateKind = 'gate' | 'advisory';
|
|
41
|
+
export interface GateOutcome {
|
|
42
|
+
exitCode: number;
|
|
43
|
+
abstained: boolean;
|
|
44
|
+
summaryLine: string;
|
|
45
|
+
}
|
|
46
|
+
/** Copy hooks: surfaces adapt wording without re-owning the decision. */
|
|
47
|
+
export interface GateOutcomeOptions {
|
|
48
|
+
/** Unit noun for the clean-pass line. Default: `'check(s)'`. */
|
|
49
|
+
noun?: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* D7: skipped entries render in EVERY summary line.
|
|
53
|
+
*
|
|
54
|
+
* Exported so a surface with its own failure vocabulary (doctor says
|
|
55
|
+
* "check(s) failed", not "finding(s)") can render the identical skip suffix
|
|
56
|
+
* instead of re-deriving the format -- the decision still comes from
|
|
57
|
+
* {@link gateOutcome}, only the noun differs.
|
|
58
|
+
*/
|
|
59
|
+
export declare function skippedSuffix(skipped?: SkipEntry[]): string;
|
|
60
|
+
/**
|
|
61
|
+
* The single summary-line/exit-code path for swept commands.
|
|
62
|
+
*
|
|
63
|
+
* Non-abstained exit codes are helper defaults (findings -> 1 for gates);
|
|
64
|
+
* surfaces with richer contracts (e.g. freshness 2 = local edits) apply
|
|
65
|
+
* their own mapping AFTER checking `abstained`.
|
|
66
|
+
*/
|
|
67
|
+
export declare function gateOutcome<F>(result: GateResult<F>, kind: GateKind, opts?: GateOutcomeOptions): GateOutcome;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// GENERATED FILE — DO NOT EDIT.
|
|
3
|
+
// Verbatim copy of ts/src/core/gate-result.ts, mirrored into this CommonJS
|
|
4
|
+
// package by scripts/sync-gate-result.mjs because the staged engine bundle is
|
|
5
|
+
// ESM and unavailable at test time. Edit the engine source and re-run:
|
|
6
|
+
// node scripts/sync-gate-result.mjs
|
|
7
|
+
// `npm test` verifies this copy has not drifted (--check runs as pretest).
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.EXIT_ABSTAINED = void 0;
|
|
10
|
+
exports.skippedSuffix = skippedSuffix;
|
|
11
|
+
exports.gateOutcome = gateOutcome;
|
|
12
|
+
/**
|
|
13
|
+
* Reserved CLI-wide (D4): exit 3 always means "abstained -- verified zero
|
|
14
|
+
* items", distinct from 0 (clean), 1 (findings), 2 (surface-specific).
|
|
15
|
+
*/
|
|
16
|
+
exports.EXIT_ABSTAINED = 3;
|
|
17
|
+
const WARN = '\u{26A0}'; // warning sign
|
|
18
|
+
const EMDASH = '\u{2014}'; // em dash
|
|
19
|
+
// C0 controls (incl. \n, ESC) and DEL: a skip name must never be able to
|
|
20
|
+
// forge output lines or smuggle ANSI sequences into the summary.
|
|
21
|
+
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
|
|
22
|
+
/**
|
|
23
|
+
* D7: skipped entries render in EVERY summary line.
|
|
24
|
+
*
|
|
25
|
+
* Exported so a surface with its own failure vocabulary (doctor says
|
|
26
|
+
* "check(s) failed", not "finding(s)") can render the identical skip suffix
|
|
27
|
+
* instead of re-deriving the format -- the decision still comes from
|
|
28
|
+
* {@link gateOutcome}, only the noun differs.
|
|
29
|
+
*/
|
|
30
|
+
function skippedSuffix(skipped) {
|
|
31
|
+
if (!skipped || skipped.length === 0)
|
|
32
|
+
return '';
|
|
33
|
+
const names = skipped
|
|
34
|
+
.map((s) => s.name.replace(CONTROL_CHARS, ''))
|
|
35
|
+
.join(', ');
|
|
36
|
+
return ` (${skipped.length} skipped: ${names})`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The single summary-line/exit-code path for swept commands.
|
|
40
|
+
*
|
|
41
|
+
* Non-abstained exit codes are helper defaults (findings -> 1 for gates);
|
|
42
|
+
* surfaces with richer contracts (e.g. freshness 2 = local edits) apply
|
|
43
|
+
* their own mapping AFTER checking `abstained`.
|
|
44
|
+
*/
|
|
45
|
+
function gateOutcome(result, kind, opts = {}) {
|
|
46
|
+
const noun = opts.noun ?? 'check(s)';
|
|
47
|
+
const suffix = skippedSuffix(result.skipped);
|
|
48
|
+
// Findings outrank abstention: a finding proves something was checked,
|
|
49
|
+
// so it must never be masked by a collapsed/invalid denominator.
|
|
50
|
+
if (result.findings.length > 0) {
|
|
51
|
+
return {
|
|
52
|
+
exitCode: kind === 'gate' ? 1 : 0,
|
|
53
|
+
abstained: false,
|
|
54
|
+
summaryLine: `${result.findings.length} finding(s) across ` +
|
|
55
|
+
`${result.checked} checked${suffix}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// Negated comparison so 0, negatives, and NaN all abstain: an invalid
|
|
59
|
+
// denominator must never render as success.
|
|
60
|
+
if (!(result.checked > 0)) {
|
|
61
|
+
return {
|
|
62
|
+
exitCode: kind === 'gate' ? exports.EXIT_ABSTAINED : 0,
|
|
63
|
+
abstained: true,
|
|
64
|
+
summaryLine: `${WARN} Abstained ${EMDASH} verified zero items; ` +
|
|
65
|
+
`this is not a pass.${suffix}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
exitCode: 0,
|
|
70
|
+
abstained: false,
|
|
71
|
+
summaryLine: `All ${result.checked} run ${noun} passed${suffix}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
package/dist/overlay-commands.js
CHANGED
|
@@ -51,6 +51,7 @@ const registry = __importStar(require("./overlays-registry.js"));
|
|
|
51
51
|
const doctor_manifest_js_1 = require("./doctor-manifest.js");
|
|
52
52
|
const overlay_conflicts_js_1 = require("./overlay-conflicts.js");
|
|
53
53
|
const overlay_lint_js_1 = require("./overlay-lint.js");
|
|
54
|
+
const gate_result_js_1 = require("./gate-result.js");
|
|
54
55
|
const defaultGit = (args, opts = {}) => {
|
|
55
56
|
const r = (0, node_child_process_1.spawnSync)('git', args, { cwd: opts.cwd, encoding: 'utf8' });
|
|
56
57
|
if (r.error) {
|
|
@@ -373,12 +374,27 @@ function lint(nameOrPath, deps = {}, opts = {}) {
|
|
|
373
374
|
}
|
|
374
375
|
const result = (0, overlay_lint_js_1.lintOverlay)(dir);
|
|
375
376
|
const errors = result.findings.filter((f) => f.level === 'error');
|
|
377
|
+
// #508: linting zero skills is an ABSENT verdict, not a clean bill of health.
|
|
378
|
+
// Advisory (D3) -- a workflows-only overlay is legitimate, so the exit stays
|
|
379
|
+
// 0 -- but the line is unmissable and `--json` says so. Findings outrank
|
|
380
|
+
// abstention inside `gateOutcome`, so a missing skills dir still exits 1.
|
|
381
|
+
const outcome = (0, gate_result_js_1.gateOutcome)({ checked: result.skillsChecked, findings: result.findings }, 'advisory');
|
|
376
382
|
if (opts.json) {
|
|
377
|
-
out.write(`${JSON.stringify(
|
|
383
|
+
out.write(`${JSON.stringify({
|
|
384
|
+
...result,
|
|
385
|
+
checked: result.skillsChecked,
|
|
386
|
+
abstained: outcome.abstained,
|
|
387
|
+
}, null, 2)}\n`);
|
|
378
388
|
return errors.length === 0 ? 0 : 1;
|
|
379
389
|
}
|
|
380
390
|
const symbol = (f) => (f.level === 'error' ? '✗' : '⚠');
|
|
381
391
|
out.write(`canary overlay lint: ${nameOrPath}\n`);
|
|
392
|
+
if (outcome.abstained) {
|
|
393
|
+
out.write(`\n${outcome.summaryLine}\n`);
|
|
394
|
+
out.write(` No skill directories under ${dir}/.canary/skills, so nothing was ` +
|
|
395
|
+
`linted. Add a skill, or lint the overlay that actually ships them.\n`);
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
382
398
|
if (result.findings.length === 0) {
|
|
383
399
|
out.write(`\n✓ ${result.skillsChecked} skill(s) — no issues.\n`);
|
|
384
400
|
return 0;
|
package/dist/overlay-lint.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* The BUNDLED migration target shapes, plus the `all` sentinel. Not a closed
|
|
3
|
+
* set: `migrate` matches `deploy_to` against the consuming repo's resolved
|
|
4
|
+
* `canary_shape` by plain string comparison, so downstream overlays may use
|
|
5
|
+
* custom shapes. Lint warns (never errors) on a value outside this set (#501).
|
|
6
|
+
*/
|
|
2
7
|
export declare const VALID_DEPLOY_TARGETS: ReadonlySet<string>;
|
|
3
8
|
export interface LintFinding {
|
|
4
9
|
/** Skill name, or `(overlay)` for an overlay-level finding. */
|
package/dist/overlay-lint.js
CHANGED
|
@@ -43,8 +43,13 @@ exports.lintOverlay = lintOverlay;
|
|
|
43
43
|
*
|
|
44
44
|
* Checks (per skill under `<overlay>/.canary/skills/<name>/SKILL.md`):
|
|
45
45
|
* 1. frontmatter floor — `name` and `description` present and non-empty
|
|
46
|
-
* (modeled on harness's `skill validate`)
|
|
47
|
-
*
|
|
46
|
+
* (modeled on harness's `skill validate`), plus any frontmatter parse
|
|
47
|
+
* diagnostic (e.g. an unterminated flow list) reported as an error —
|
|
48
|
+
* a declared list must never silently read as empty (#501);
|
|
49
|
+
* 2. `deploy_to` values that are not bundled migration targets are a
|
|
50
|
+
* WARNING, not an error — shapes are extensible and `migrate` matches
|
|
51
|
+
* `deploy_to` against the consuming repo's resolved `canary_shape` by
|
|
52
|
+
* plain string comparison, so a custom shape is legitimate (#501);
|
|
48
53
|
* 3. `cli:` script paths exist inside the skill dir (no escape);
|
|
49
54
|
* plus one overlay-level check:
|
|
50
55
|
* 4. `.canary/doctor.json` (if present) passes manifest validation — reuses
|
|
@@ -53,7 +58,13 @@ exports.lintOverlay = lintOverlay;
|
|
|
53
58
|
const fs = __importStar(require("node:fs"));
|
|
54
59
|
const path = __importStar(require("node:path"));
|
|
55
60
|
const doctor_manifest_js_1 = require("./doctor-manifest.js");
|
|
56
|
-
|
|
61
|
+
const skill_frontmatter_js_1 = require("./skill-frontmatter.js");
|
|
62
|
+
/**
|
|
63
|
+
* The BUNDLED migration target shapes, plus the `all` sentinel. Not a closed
|
|
64
|
+
* set: `migrate` matches `deploy_to` against the consuming repo's resolved
|
|
65
|
+
* `canary_shape` by plain string comparison, so downstream overlays may use
|
|
66
|
+
* custom shapes. Lint warns (never errors) on a value outside this set (#501).
|
|
67
|
+
*/
|
|
57
68
|
exports.VALID_DEPLOY_TARGETS = new Set([
|
|
58
69
|
'api',
|
|
59
70
|
'e2e_ui',
|
|
@@ -62,40 +73,6 @@ exports.VALID_DEPLOY_TARGETS = new Set([
|
|
|
62
73
|
'performance',
|
|
63
74
|
'all',
|
|
64
75
|
]);
|
|
65
|
-
/** Parse the tiny-YAML subset canary uses (mirrors the Python loader). */
|
|
66
|
-
function parseFrontmatter(md) {
|
|
67
|
-
const fm = {};
|
|
68
|
-
if (!md.startsWith('---'))
|
|
69
|
-
return fm;
|
|
70
|
-
for (const line of md.split('\n').slice(1)) {
|
|
71
|
-
if (line.trim() === '---')
|
|
72
|
-
break;
|
|
73
|
-
const idx = line.indexOf(':');
|
|
74
|
-
if (idx === -1)
|
|
75
|
-
continue;
|
|
76
|
-
const key = line.slice(0, idx).trim();
|
|
77
|
-
const value = line.slice(idx + 1).trim();
|
|
78
|
-
if (key === 'deploy_to') {
|
|
79
|
-
fm.deploy_to =
|
|
80
|
-
value.startsWith('[') && value.endsWith(']')
|
|
81
|
-
? value
|
|
82
|
-
.slice(1, -1)
|
|
83
|
-
.split(',')
|
|
84
|
-
.map((s) => s.trim())
|
|
85
|
-
.filter(Boolean)
|
|
86
|
-
: value
|
|
87
|
-
? [value]
|
|
88
|
-
: [];
|
|
89
|
-
}
|
|
90
|
-
else if (key === 'name' ||
|
|
91
|
-
key === 'description' ||
|
|
92
|
-
key === 'cli' ||
|
|
93
|
-
key === 'entry') {
|
|
94
|
-
fm[key] = value;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
return fm;
|
|
98
|
-
}
|
|
99
76
|
/** True when `cli` resolves to a real file inside `skillDir` (no escape). */
|
|
100
77
|
function cliFinding(skill, skillDir, cli) {
|
|
101
78
|
const resolvedDir = path.resolve(skillDir);
|
|
@@ -116,45 +93,53 @@ function cliFinding(skill, skillDir, cli) {
|
|
|
116
93
|
}
|
|
117
94
|
return null;
|
|
118
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Checks 0–2: parse diagnostics (a declared-but-unreadable list is a loud
|
|
98
|
+
* error, never a silent empty), the name/description floor, and deploy_to
|
|
99
|
+
* values — unknown targets warn, since shapes are extensible (#501).
|
|
100
|
+
*/
|
|
101
|
+
function frontmatterFindings(skill, fm, parseErrors) {
|
|
102
|
+
const findings = parseErrors.map((m) => ({
|
|
103
|
+
skill,
|
|
104
|
+
level: 'error',
|
|
105
|
+
message: `frontmatter parse error: ${m}`,
|
|
106
|
+
}));
|
|
107
|
+
for (const field of ['name', 'description']) {
|
|
108
|
+
if (!(0, skill_frontmatter_js_1.scalarField)(fm, field)) {
|
|
109
|
+
findings.push({
|
|
110
|
+
skill,
|
|
111
|
+
level: 'error',
|
|
112
|
+
message: field === 'name'
|
|
113
|
+
? 'frontmatter is missing `name`'
|
|
114
|
+
: 'frontmatter is missing a non-empty `description`',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const target of (0, skill_frontmatter_js_1.listField)(fm, 'deploy_to')) {
|
|
119
|
+
if (!exports.VALID_DEPLOY_TARGETS.has(target)) {
|
|
120
|
+
findings.push({
|
|
121
|
+
skill,
|
|
122
|
+
level: 'warning',
|
|
123
|
+
message: `deploy_to value "${target}" is not a bundled target (${[...exports.VALID_DEPLOY_TARGETS].join(', ')}); fine if it matches a consuming repo's custom canary_shape, otherwise a typo`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return findings;
|
|
128
|
+
}
|
|
119
129
|
function lintSkill(name, skillDir) {
|
|
120
|
-
const findings = [];
|
|
121
|
-
const mdPath = path.join(skillDir, 'SKILL.md');
|
|
122
130
|
let text;
|
|
123
131
|
try {
|
|
124
|
-
text = fs.readFileSync(
|
|
132
|
+
text = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8');
|
|
125
133
|
}
|
|
126
134
|
catch {
|
|
127
135
|
return [{ skill: name, level: 'error', message: 'SKILL.md is unreadable' }];
|
|
128
136
|
}
|
|
129
|
-
const fm = parseFrontmatter(text);
|
|
130
|
-
|
|
131
|
-
if (!fm.name) {
|
|
132
|
-
findings.push({
|
|
133
|
-
skill: name,
|
|
134
|
-
level: 'error',
|
|
135
|
-
message: 'frontmatter is missing `name`',
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
if (!fm.description) {
|
|
139
|
-
findings.push({
|
|
140
|
-
skill: name,
|
|
141
|
-
level: 'error',
|
|
142
|
-
message: 'frontmatter is missing a non-empty `description`',
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
// 2. deploy_to targets.
|
|
146
|
-
for (const target of fm.deploy_to ?? []) {
|
|
147
|
-
if (!exports.VALID_DEPLOY_TARGETS.has(target)) {
|
|
148
|
-
findings.push({
|
|
149
|
-
skill: name,
|
|
150
|
-
level: 'error',
|
|
151
|
-
message: `deploy_to value "${target}" is not a known target (${[...exports.VALID_DEPLOY_TARGETS].join(', ')})`,
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
}
|
|
137
|
+
const { frontmatter: fm, errors } = (0, skill_frontmatter_js_1.parseFrontmatter)(text);
|
|
138
|
+
const findings = frontmatterFindings(name, fm, errors);
|
|
155
139
|
// 3. cli path (entry is a module ref, not a filesystem path — not checked here).
|
|
156
|
-
|
|
157
|
-
|
|
140
|
+
const cli = (0, skill_frontmatter_js_1.scalarField)(fm, 'cli');
|
|
141
|
+
if (cli) {
|
|
142
|
+
const f = cliFinding(name, skillDir, cli);
|
|
158
143
|
if (f)
|
|
159
144
|
findings.push(f);
|
|
160
145
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SKILL.md frontmatter parsing for `canary overlay lint` (#501). Mirror of
|
|
3
|
+
* the engine's `SkillRegistry.parseFrontmatterWithDiagnostics`
|
|
4
|
+
* (ts/src/core/skill-registry.ts) — keep in sync — so lint and `canary
|
|
5
|
+
* migrate` never disagree on what a SKILL.md declares. The packages compile
|
|
6
|
+
* separately (CJS here, ESM engine), so the rules are mirrored, not imported;
|
|
7
|
+
* parity is pinned by equivalent fixtures in both test suites. Rules: flow
|
|
8
|
+
* lists may wrap across indented lines, block sequences (`- item`) are read,
|
|
9
|
+
* indented continuations fold into the scalar above, and a list-shaped value
|
|
10
|
+
* that cannot be read (an unterminated `[`) is a recorded error — never a
|
|
11
|
+
* silent empty list.
|
|
12
|
+
*/
|
|
13
|
+
/** Parsed frontmatter entries: scalar strings or list values. */
|
|
14
|
+
export type Frontmatter = Record<string, string | string[]>;
|
|
15
|
+
export interface ParsedFrontmatter {
|
|
16
|
+
frontmatter: Frontmatter;
|
|
17
|
+
errors: string[];
|
|
18
|
+
}
|
|
19
|
+
/** Parse a SKILL.md's frontmatter, collecting diagnostics (never throws). */
|
|
20
|
+
export declare function parseFrontmatter(md: string): ParsedFrontmatter;
|
|
21
|
+
/** A scalar entry as `string | undefined` (list-valued entries are not scalars). */
|
|
22
|
+
export declare function scalarField(fm: Frontmatter, key: string): string | undefined;
|
|
23
|
+
/** An entry normalized to `string[]` (a bare scalar becomes a one-item list). */
|
|
24
|
+
export declare function listField(fm: Frontmatter, key: string): string[];
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseFrontmatter = parseFrontmatter;
|
|
4
|
+
exports.scalarField = scalarField;
|
|
5
|
+
exports.listField = listField;
|
|
6
|
+
/** Frontmatter body: comment-free lines between the `---` fences. */
|
|
7
|
+
function frontmatterBody(md) {
|
|
8
|
+
const rest = md.split('\n').slice(1);
|
|
9
|
+
const end = rest.findIndex((l) => l.trim() === '---');
|
|
10
|
+
return (end === -1 ? rest : rest.slice(0, end)).filter((l) => !l.trim().startsWith('#'));
|
|
11
|
+
}
|
|
12
|
+
/** Block-sequence items; a dash-less line folds into the item above it. */
|
|
13
|
+
function blockListItems(cont) {
|
|
14
|
+
const items = [];
|
|
15
|
+
for (const c of cont) {
|
|
16
|
+
if (c.startsWith('- '))
|
|
17
|
+
items.push(c.slice(2).trim());
|
|
18
|
+
else if (c !== '-' && items.length > 0)
|
|
19
|
+
items[items.length - 1] = `${items[items.length - 1]} ${c}`.trim();
|
|
20
|
+
}
|
|
21
|
+
return items.filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
/** Assign one entry from its inline value plus indented continuation lines. */
|
|
24
|
+
function assignValue(fm, errors, key, inline, cont) {
|
|
25
|
+
const flow = inline.startsWith('[')
|
|
26
|
+
? [inline, ...cont]
|
|
27
|
+
: inline === '' && cont[0]?.startsWith('[')
|
|
28
|
+
? cont
|
|
29
|
+
: null;
|
|
30
|
+
if (flow !== null) {
|
|
31
|
+
const joined = flow.join(' ').trim();
|
|
32
|
+
if (!joined.endsWith(']')) {
|
|
33
|
+
errors.push(`\`${key}\`: unterminated flow list (no closing \`]\`): ${joined}`);
|
|
34
|
+
fm[key] = [];
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
fm[key] = joined
|
|
38
|
+
.slice(1, -1)
|
|
39
|
+
.split(',')
|
|
40
|
+
.map((s) => s.trim())
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
else if (inline === '' && /^-( |$)/.test(cont[0] ?? '')) {
|
|
44
|
+
const items = blockListItems(cont);
|
|
45
|
+
if (items.length === 0)
|
|
46
|
+
errors.push(`\`${key}\`: block list has no parseable items`);
|
|
47
|
+
fm[key] = items;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// Scalar; indented continuation lines fold in (plain multiline YAML).
|
|
51
|
+
fm[key] = [inline, ...cont].join(' ').trim();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Parse a SKILL.md's frontmatter, collecting diagnostics (never throws). */
|
|
55
|
+
function parseFrontmatter(md) {
|
|
56
|
+
const frontmatter = {};
|
|
57
|
+
const errors = [];
|
|
58
|
+
if (!md.startsWith('---'))
|
|
59
|
+
return { frontmatter, errors };
|
|
60
|
+
const body = frontmatterBody(md);
|
|
61
|
+
let i = 0;
|
|
62
|
+
while (i < body.length) {
|
|
63
|
+
const line = body[i];
|
|
64
|
+
const idx = line.indexOf(':'); // first colon, like the engine
|
|
65
|
+
i++;
|
|
66
|
+
// A line is a key only when top-level, non-blank, and colon-bearing.
|
|
67
|
+
if (!line.trim() || /^\s/.test(line) || idx === -1)
|
|
68
|
+
continue;
|
|
69
|
+
const cont = []; // indented continuation lines for this key
|
|
70
|
+
while (i < body.length && /^\s+\S/.test(body[i])) {
|
|
71
|
+
cont.push(body[i].trim());
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
assignValue(frontmatter, errors, line.slice(0, idx).trim(), line.slice(idx + 1).trim(), cont);
|
|
75
|
+
}
|
|
76
|
+
return { frontmatter, errors };
|
|
77
|
+
}
|
|
78
|
+
/** A scalar entry as `string | undefined` (list-valued entries are not scalars). */
|
|
79
|
+
function scalarField(fm, key) {
|
|
80
|
+
const v = fm[key];
|
|
81
|
+
return typeof v === 'string' && v ? v : undefined;
|
|
82
|
+
}
|
|
83
|
+
/** An entry normalized to `string[]` (a bare scalar becomes a one-item list). */
|
|
84
|
+
function listField(fm, key) {
|
|
85
|
+
const v = fm[key];
|
|
86
|
+
if (Array.isArray(v))
|
|
87
|
+
return v;
|
|
88
|
+
return typeof v === 'string' && v ? [v.trim()] : [];
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canary-test-cli",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.5.0",
|
|
4
4
|
"description": "Canary — AI-powered test automation agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"url": "https://github.com/bop-clocktower/canary.git"
|
|
9
9
|
},
|
|
10
10
|
"bin": {
|
|
11
|
-
"canary": "./bin/canary.js"
|
|
11
|
+
"canary": "./bin/canary.js",
|
|
12
|
+
"canary-mcp": "./bin/canary-mcp.js"
|
|
12
13
|
},
|
|
13
14
|
"exports": {
|
|
14
15
|
"./reporter": {
|
|
@@ -29,6 +30,7 @@
|
|
|
29
30
|
"scripts": {
|
|
30
31
|
"build": "tsc && node scripts/build-engine.mjs",
|
|
31
32
|
"prepare": "npm run build",
|
|
33
|
+
"pretest": "node scripts/sync-gate-result.mjs --check",
|
|
32
34
|
"test": "tsc && node --test \"scripts/__tests__/*.test.js\""
|
|
33
35
|
},
|
|
34
36
|
"engines": {
|
|
@@ -36,6 +38,7 @@
|
|
|
36
38
|
},
|
|
37
39
|
"files": [
|
|
38
40
|
"bin/canary.js",
|
|
41
|
+
"bin/canary-mcp.js",
|
|
39
42
|
"dist/"
|
|
40
43
|
],
|
|
41
44
|
"dependencies": {
|