canary-test-cli 6.3.0 → 6.4.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/engine/cli-commands.js +1 -1
- package/dist/engine/core/feedback.js +32 -18
- package/dist/engine/core/gate-result.js +73 -0
- package/dist/engine/core/migrator.js +83 -10
- package/dist/engine/core/skill-registry.js +95 -30
- 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 +4 -2
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Console-script entry for `canary-mcp` (#507): starts the Canary MCP server
|
|
5
|
+
// over stdio from the bundled TypeScript engine (dist/engine/mcp-server.js,
|
|
6
|
+
// staged by scripts/build-engine.mjs). The engine bundle is ESM while this
|
|
7
|
+
// package is CommonJS, so the server module is loaded via dynamic import().
|
|
8
|
+
// stdout carries the JSON-RPC stream and must never be polluted -- every
|
|
9
|
+
// failure path writes to stderr only.
|
|
10
|
+
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const { pathToFileURL } = require('node:url');
|
|
14
|
+
|
|
15
|
+
/** Absolute path to the bundled engine's MCP server module. */
|
|
16
|
+
function getServerPath() {
|
|
17
|
+
return path.join(__dirname, '..', 'dist', 'engine', 'mcp-server.js');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Start the server. Returns the exit code (0 when runStdio resolves, 1 when
|
|
22
|
+
* the bundle is missing). Dependencies are injectable for testing.
|
|
23
|
+
*/
|
|
24
|
+
async function main({
|
|
25
|
+
serverPath = getServerPath(),
|
|
26
|
+
existsSync = fs.existsSync,
|
|
27
|
+
stderr = process.stderr,
|
|
28
|
+
} = {}) {
|
|
29
|
+
if (!existsSync(serverPath)) {
|
|
30
|
+
stderr.write(
|
|
31
|
+
`canary MCP server not found at ${serverPath}.\n` +
|
|
32
|
+
`The package looks incomplete; try reinstalling: npm install -g canary-test-cli\n`,
|
|
33
|
+
);
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
const { runStdio } = await import(pathToFileURL(serverPath).href);
|
|
37
|
+
await runStdio();
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (require.main === module) {
|
|
42
|
+
main()
|
|
43
|
+
.then((code) => {
|
|
44
|
+
if (code !== 0) process.exit(code);
|
|
45
|
+
})
|
|
46
|
+
.catch((err) => {
|
|
47
|
+
console.error(err);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { getServerPath, main };
|
|
@@ -155,7 +155,7 @@ export function feedbackCmd(message, opts, deps) {
|
|
|
155
155
|
deps.out(`${pc.bold(pc.red(CROSS))} A feedback message is required.\nUsage: ${pc.bold('canary feedback "<message>" [--category bug|ux|docs|idea]')}`);
|
|
156
156
|
throw new CliExit(1);
|
|
157
157
|
}
|
|
158
|
-
const fb = buildFeedback(message.trim(), opts.category);
|
|
158
|
+
const fb = buildFeedback(message.trim(), opts.category, resolveVersion(deps));
|
|
159
159
|
if (opts.json) {
|
|
160
160
|
deps.out(jsonIndent2(fb));
|
|
161
161
|
return;
|
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
* never reads environment variables or file contents.
|
|
13
13
|
*
|
|
14
14
|
* Python→TS nuances:
|
|
15
|
-
* -
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* - The context keys are `{version, os, runtime, install}` (insertion order
|
|
16
|
+
* preserved). `runtime` carries `process.version`; the key was named
|
|
17
|
+
* `python` for golden-parity shape fidelity until #506 — in filed issues
|
|
18
|
+
* it misled triage ("user on python v22?") once the Python engine retired.
|
|
19
19
|
* - `urlencode(...)` (which uses `quote_plus`, space -> `+`) maps to
|
|
20
20
|
* `URLSearchParams`, which also form-encodes with space -> `+` and preserves
|
|
21
21
|
* insertion order. Exotic-character percent-encoding can differ byte-for-byte
|
|
@@ -26,12 +26,6 @@ import { release, type } from 'node:os';
|
|
|
26
26
|
/** The public issue tracker (from npm/package.json `repository`). */
|
|
27
27
|
export const TRACKER_URL = 'https://github.com/bop-clocktower/canary';
|
|
28
28
|
export const VALID_CATEGORIES = ['bug', 'ux', 'docs', 'idea'];
|
|
29
|
-
function canaryVersion() {
|
|
30
|
-
// Python reads `importlib.metadata.version("canary-test-ai")`, falling back to
|
|
31
|
-
// "unknown". The TS pilot has no equivalent package-metadata lookup wired in,
|
|
32
|
-
// so we return the same best-effort "unknown" sentinel.
|
|
33
|
-
return 'unknown';
|
|
34
|
-
}
|
|
35
29
|
/** Best-effort install-method label — never fails, never inspects secrets. */
|
|
36
30
|
function installMethod() {
|
|
37
31
|
const exe = (process.execPath || '').toLowerCase();
|
|
@@ -45,23 +39,43 @@ function installMethod() {
|
|
|
45
39
|
*
|
|
46
40
|
* Deliberately excludes environment variables and file contents — only the
|
|
47
41
|
* coarse runtime facts a maintainer needs to triage a CLI report.
|
|
42
|
+
*
|
|
43
|
+
* `version` comes from the caller (#506): this module is pure and cannot know
|
|
44
|
+
* which package it shipped in, but the CLI layer does (`deps.pkgVersion()`),
|
|
45
|
+
* and the version is the single most useful triage field.
|
|
48
46
|
*/
|
|
49
|
-
export function collectContext() {
|
|
47
|
+
export function collectContext(version = 'unknown') {
|
|
50
48
|
return {
|
|
51
|
-
version
|
|
49
|
+
version,
|
|
52
50
|
os: `${type()} ${release()}`.trim(),
|
|
53
|
-
|
|
51
|
+
runtime: process.version,
|
|
54
52
|
install: installMethod(),
|
|
55
53
|
};
|
|
56
54
|
}
|
|
55
|
+
// Horizontal ellipsis, kept as an escape so this source stays ASCII.
|
|
56
|
+
const ELLIPSIS = '\u{2026}';
|
|
57
|
+
/**
|
|
58
|
+
* Cap the title at 60 code points (`Array.from` slices by code point, not
|
|
59
|
+
* UTF-16 unit, so astral chars do not truncate early). When the cap bites,
|
|
60
|
+
* break on the last word boundary inside the budget (falling back to a hard
|
|
61
|
+
* cut for an unbreakable token) and append an ellipsis so the truncation is
|
|
62
|
+
* visible instead of ending mid-word (#506).
|
|
63
|
+
*/
|
|
64
|
+
function truncateTitle(message) {
|
|
65
|
+
const points = Array.from(message);
|
|
66
|
+
if (points.length <= 60)
|
|
67
|
+
return message;
|
|
68
|
+
const hard = points.slice(0, 60).join('');
|
|
69
|
+
const lastBreak = hard.search(/\s+\S*$/);
|
|
70
|
+
const cut = lastBreak > 0 ? hard.slice(0, lastBreak) : hard;
|
|
71
|
+
return `${cut}${ELLIPSIS}`;
|
|
72
|
+
}
|
|
57
73
|
/**
|
|
58
74
|
* A pre-filled GitHub 'new issue' URL: category in the title, message + context
|
|
59
75
|
* in the body, category as a label. All parts are URL-encoded.
|
|
60
76
|
*/
|
|
61
77
|
export function buildIssueUrl(category, message, context) {
|
|
62
|
-
|
|
63
|
-
// unit, so an astral char would truncate the title early. Match the oracle.
|
|
64
|
-
const title = `[${category}] ${Array.from(message).slice(0, 60).join('')}`.trim();
|
|
78
|
+
const title = `[${category}] ${truncateTitle(message)}`.trim();
|
|
65
79
|
const bodyLines = [
|
|
66
80
|
message,
|
|
67
81
|
'',
|
|
@@ -81,8 +95,8 @@ export function buildIssueUrl(category, message, context) {
|
|
|
81
95
|
return `${TRACKER_URL}/issues/new?${query.toString()}`;
|
|
82
96
|
}
|
|
83
97
|
/** Bundle a report: message, category, context, and the pre-filled URL. */
|
|
84
|
-
export function buildFeedback(message, category) {
|
|
85
|
-
const context = collectContext();
|
|
98
|
+
export function buildFeedback(message, category, version = 'unknown') {
|
|
99
|
+
const context = collectContext(version);
|
|
86
100
|
return {
|
|
87
101
|
message,
|
|
88
102
|
category,
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
/**
|
|
19
|
+
* Reserved CLI-wide (D4): exit 3 always means "abstained -- verified zero
|
|
20
|
+
* items", distinct from 0 (clean), 1 (findings), 2 (surface-specific).
|
|
21
|
+
*/
|
|
22
|
+
export const EXIT_ABSTAINED = 3;
|
|
23
|
+
const WARN = '\u{26A0}'; // warning sign
|
|
24
|
+
const EMDASH = '\u{2014}'; // em dash
|
|
25
|
+
// C0 controls (incl. \n, ESC) and DEL: a skip name must never be able to
|
|
26
|
+
// forge output lines or smuggle ANSI sequences into the summary.
|
|
27
|
+
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
|
|
28
|
+
/** D7: skipped entries render in EVERY summary line. */
|
|
29
|
+
function skippedSuffix(skipped) {
|
|
30
|
+
if (!skipped || skipped.length === 0)
|
|
31
|
+
return '';
|
|
32
|
+
const names = skipped
|
|
33
|
+
.map((s) => s.name.replace(CONTROL_CHARS, ''))
|
|
34
|
+
.join(', ');
|
|
35
|
+
return ` (${skipped.length} skipped: ${names})`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The single summary-line/exit-code path for swept commands.
|
|
39
|
+
*
|
|
40
|
+
* Non-abstained exit codes are helper defaults (findings -> 1 for gates);
|
|
41
|
+
* surfaces with richer contracts (e.g. freshness 2 = local edits) apply
|
|
42
|
+
* their own mapping AFTER checking `abstained`.
|
|
43
|
+
*/
|
|
44
|
+
export function gateOutcome(result, kind, opts = {}) {
|
|
45
|
+
const noun = opts.noun ?? 'check(s)';
|
|
46
|
+
const suffix = skippedSuffix(result.skipped);
|
|
47
|
+
// Findings outrank abstention: a finding proves something was checked,
|
|
48
|
+
// so it must never be masked by a collapsed/invalid denominator.
|
|
49
|
+
if (result.findings.length > 0) {
|
|
50
|
+
return {
|
|
51
|
+
exitCode: kind === 'gate' ? 1 : 0,
|
|
52
|
+
abstained: false,
|
|
53
|
+
summaryLine: `${result.findings.length} finding(s) across ` +
|
|
54
|
+
`${result.checked} checked${suffix}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Negated comparison so 0, negatives, and NaN all abstain: an invalid
|
|
58
|
+
// denominator must never render as success.
|
|
59
|
+
if (!(result.checked > 0)) {
|
|
60
|
+
return {
|
|
61
|
+
exitCode: kind === 'gate' ? EXIT_ABSTAINED : 0,
|
|
62
|
+
abstained: true,
|
|
63
|
+
summaryLine: `${WARN} Abstained ${EMDASH} verified zero items; ` +
|
|
64
|
+
`this is not a pass.${suffix}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
exitCode: 0,
|
|
69
|
+
abstained: false,
|
|
70
|
+
summaryLine: `All ${result.checked} run ${noun} passed${suffix}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=gate-result.js.map
|
|
@@ -43,6 +43,7 @@ import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
|
43
43
|
import { readJsonWithWarning } from './config-validation.js';
|
|
44
44
|
import { uncertainDetectionMessage } from './detection.js';
|
|
45
45
|
import { FrameworkRegistry } from './framework-registry.js';
|
|
46
|
+
import { EXIT_ABSTAINED, gateOutcome } from './gate-result.js';
|
|
46
47
|
import { Scaffolder, scaffoldableFrameworks, TEMPLATES } from './scaffolder.js';
|
|
47
48
|
import { SkillRegistry } from './skill-registry.js';
|
|
48
49
|
// ---------------------------------------------------------------------------
|
|
@@ -594,8 +595,34 @@ export class FreshnessReport {
|
|
|
594
595
|
get in_sync() {
|
|
595
596
|
return !this.has_drift && !this.has_local_edits;
|
|
596
597
|
}
|
|
597
|
-
/**
|
|
598
|
+
/**
|
|
599
|
+
* The freshness gate as a {@link GateResult}: denominator = skills
|
|
600
|
+
* verified, findings = drift + local edits. Feeds the shared abstention
|
|
601
|
+
* helper (#508) so "verified zero skills" can never render as a pass.
|
|
602
|
+
*/
|
|
603
|
+
gateResult() {
|
|
604
|
+
return {
|
|
605
|
+
checked: this.results.length,
|
|
606
|
+
findings: [...this.stale, ...this.local_edits],
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* A gate that verified zero skills has abstained, not passed (#503): the
|
|
611
|
+
* shape matched nothing, so nothing was checked and "in sync" would be a
|
|
612
|
+
* silent false pass -- the #456 class. Reported as its own exit code and
|
|
613
|
+
* flagged in every output surface. Delegates to the shared helper (#508).
|
|
614
|
+
*/
|
|
615
|
+
get abstained() {
|
|
616
|
+
return gateOutcome(this.gateResult(), 'gate').abstained;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* 0 in sync, 1 drift, 2 local edits (safety refusal wins), 3 abstained.
|
|
620
|
+
* The abstention path comes from the shared helper; the 1/2 mapping is
|
|
621
|
+
* this surface's own contract (local edits outrank drift).
|
|
622
|
+
*/
|
|
598
623
|
exit_code() {
|
|
624
|
+
if (this.abstained)
|
|
625
|
+
return EXIT_ABSTAINED;
|
|
599
626
|
if (this.has_local_edits)
|
|
600
627
|
return 2;
|
|
601
628
|
if (this.has_drift)
|
|
@@ -609,6 +636,8 @@ export class FreshnessReport {
|
|
|
609
636
|
in_sync: this.in_sync,
|
|
610
637
|
has_drift: this.has_drift,
|
|
611
638
|
has_local_edits: this.has_local_edits,
|
|
639
|
+
checked: this.results.length,
|
|
640
|
+
abstained: this.abstained,
|
|
612
641
|
exit_code: this.exit_code(),
|
|
613
642
|
skills: this.results.map((r) => ({
|
|
614
643
|
skill_name: r.skill_name,
|
|
@@ -628,6 +657,13 @@ export class FreshnessReport {
|
|
|
628
657
|
];
|
|
629
658
|
if (this.results.length === 0) {
|
|
630
659
|
lines.push("_No overlay skills match this project's shape._", '');
|
|
660
|
+
lines.push(`${WARN} **Abstained** ${EMDASH} the gate verified zero skills, so this is not a pass.`, '');
|
|
661
|
+
if (this.shape === 'unknown') {
|
|
662
|
+
lines.push('The shape could not be detected. Set `canary_shape` in', '`.canary/company.json` or pass `--framework <name>`.', '');
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
lines.push(`The overlay ships no skills with \`deploy_to\` covering \`${this.shape}\`.`, "Check the overlay's `deploy_to` lists or the resolved `canary_shape`.", '');
|
|
666
|
+
}
|
|
631
667
|
lines.push(...workflowMarkdown(this.workflows, false));
|
|
632
668
|
return lines.join('\n');
|
|
633
669
|
}
|
|
@@ -719,6 +755,17 @@ export class MigrationReport {
|
|
|
719
755
|
this.installed_workflows = init.installed_workflows ?? [];
|
|
720
756
|
this.config_warnings = init.config_warnings ?? [];
|
|
721
757
|
}
|
|
758
|
+
/**
|
|
759
|
+
* The dry run's denominator (#504): config files that would be created,
|
|
760
|
+
* skills that would deploy, workflows that would install. Zero means the
|
|
761
|
+
* dry run has nothing to apply -- an advisory abstention, not a
|
|
762
|
+
* completed migration.
|
|
763
|
+
*/
|
|
764
|
+
get would_migrate_count() {
|
|
765
|
+
return (this.would_create.length +
|
|
766
|
+
this.deployed_skills.filter((r) => r.status === 'dry_run').length +
|
|
767
|
+
this.installed_workflows.filter((r) => r.status === 'dry_run').length);
|
|
768
|
+
}
|
|
722
769
|
to_markdown() {
|
|
723
770
|
const lines = ['# Canary Migration Report', ''];
|
|
724
771
|
if (this.dry_run) {
|
|
@@ -822,6 +869,28 @@ export class MigrationReport {
|
|
|
822
869
|
lines.push(`- ${item}`);
|
|
823
870
|
lines.push('');
|
|
824
871
|
}
|
|
872
|
+
else if (this.dry_run) {
|
|
873
|
+
// #504 abstention half: a dry run never completed anything. Zero
|
|
874
|
+
// pending work is an advisory abstention (D3) -- gateOutcome is the
|
|
875
|
+
// only summary-line path AND the only decision point (no local
|
|
876
|
+
// n === 0 arithmetic), so the refusal is structural.
|
|
877
|
+
const n = this.would_migrate_count;
|
|
878
|
+
const outcome = gateOutcome({ checked: n, findings: [] }, 'advisory', {
|
|
879
|
+
noun: 'item(s)',
|
|
880
|
+
});
|
|
881
|
+
lines.push('## Status', '');
|
|
882
|
+
if (outcome.abstained) {
|
|
883
|
+
lines.push(outcome.summaryLine, '', 'This dry run would migrate zero item(s) ' +
|
|
884
|
+
EMDASH +
|
|
885
|
+
' the project already carries everything this migration would ' +
|
|
886
|
+
'produce. If you expected changes, check `--from <overlay>` and ' +
|
|
887
|
+
'the detected framework/shape above.', '');
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
lines.push(`Dry run ${EMDASH} would migrate ${n} item(s). ` +
|
|
891
|
+
'Re-run with `--apply` to write them.', '');
|
|
892
|
+
}
|
|
893
|
+
}
|
|
825
894
|
else {
|
|
826
895
|
lines.push('## Status', '', 'Migration complete. Run `canary recommend "<test description>"` to verify framework detection.', '');
|
|
827
896
|
}
|
|
@@ -1260,19 +1329,23 @@ export class HarnessMigrator {
|
|
|
1260
1329
|
this.installWorkflows(shape, overlayPath, projectRoot, true, false));
|
|
1261
1330
|
}
|
|
1262
1331
|
detectFramework(root, config) {
|
|
1263
|
-
//
|
|
1332
|
+
// Explicit override in .canary/company.json ("canary_shape" field) is
|
|
1333
|
+
// user intent: it wins over every probe tier's shape, including a total
|
|
1334
|
+
// probe miss (#502 — monorepos often have no root framework config).
|
|
1335
|
+
// Framework detection still runs so framework-dependent behavior keeps
|
|
1336
|
+
// working when a probe does match.
|
|
1264
1337
|
const rawShape = config['canary_shape'];
|
|
1265
1338
|
const explicitShape = (rawShape == null ? '' : String(rawShape))
|
|
1266
1339
|
.trim()
|
|
1267
1340
|
.toLowerCase();
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1341
|
+
const [framework, shape, source, confidence] = this.probeFramework(root, config);
|
|
1342
|
+
if (!explicitShape)
|
|
1343
|
+
return [framework, shape, source, confidence];
|
|
1344
|
+
return framework === null
|
|
1345
|
+
? [null, explicitShape, 'canary_shape (.canary/company.json)', 'explicit']
|
|
1346
|
+
: [framework, explicitShape, source, confidence];
|
|
1347
|
+
}
|
|
1348
|
+
probeFramework(root, config) {
|
|
1276
1349
|
// 1. Dedicated config file (highest confidence).
|
|
1277
1350
|
for (const [filename, framework, shape, confidence] of _CONFIG_PROBES) {
|
|
1278
1351
|
if (existsSync(join(root, filename))) {
|
|
@@ -339,7 +339,7 @@ export class SkillRegistry {
|
|
|
339
339
|
catch {
|
|
340
340
|
return null;
|
|
341
341
|
}
|
|
342
|
-
const fm = SkillRegistry.
|
|
342
|
+
const { frontmatter: fm, errors } = SkillRegistry.parseFrontmatterWithDiagnostics(text);
|
|
343
343
|
const stem = basename(path, extname(path));
|
|
344
344
|
const name = pyTruthy(fm['name']) ? fm['name'] : stem;
|
|
345
345
|
return new SkillInfo({
|
|
@@ -351,7 +351,7 @@ export class SkillRegistry {
|
|
|
351
351
|
entry: SkillRegistry.scalar(fm['entry']),
|
|
352
352
|
deploy_to: SkillRegistry.parseDeployTo(fm),
|
|
353
353
|
requires: SkillRegistry.parseStrList(fm, 'requires'),
|
|
354
|
-
error: SkillRegistry.
|
|
354
|
+
error: SkillRegistry.discoveryError(fm, errors),
|
|
355
355
|
});
|
|
356
356
|
}
|
|
357
357
|
// Public (Python `_parse_nested` is underscore-private but used cross-module):
|
|
@@ -365,7 +365,7 @@ export class SkillRegistry {
|
|
|
365
365
|
catch {
|
|
366
366
|
return null;
|
|
367
367
|
}
|
|
368
|
-
const fm = SkillRegistry.
|
|
368
|
+
const { frontmatter: fm, errors } = SkillRegistry.parseFrontmatterWithDiagnostics(text);
|
|
369
369
|
const name = pyTruthy(fm['name']) ? fm['name'] : dirName;
|
|
370
370
|
// Python: `fm.get("description") or self._blockquote_tagline(text)`.
|
|
371
371
|
const description = pyTruthy(fm['description'])
|
|
@@ -380,7 +380,7 @@ export class SkillRegistry {
|
|
|
380
380
|
entry: SkillRegistry.scalar(fm['entry']),
|
|
381
381
|
deploy_to: SkillRegistry.parseDeployTo(fm),
|
|
382
382
|
requires: SkillRegistry.parseStrList(fm, 'requires'),
|
|
383
|
-
error: SkillRegistry.
|
|
383
|
+
error: SkillRegistry.discoveryError(fm, errors),
|
|
384
384
|
});
|
|
385
385
|
}
|
|
386
386
|
/** Python `dict.get(key, default)`: default only on a missing key. */
|
|
@@ -408,39 +408,104 @@ export class SkillRegistry {
|
|
|
408
408
|
return [];
|
|
409
409
|
}
|
|
410
410
|
/**
|
|
411
|
-
* Tiny YAML-subset parser: top-level scalar and
|
|
412
|
-
* delimiters.
|
|
413
|
-
*
|
|
411
|
+
* Tiny YAML-subset parser: top-level scalar and list fields between `---`
|
|
412
|
+
* delimiters. Convenience wrapper over
|
|
413
|
+
* {@link parseFrontmatterWithDiagnostics} that drops the diagnostics.
|
|
414
414
|
*/
|
|
415
415
|
static parseFrontmatter(text) {
|
|
416
|
-
|
|
416
|
+
return SkillRegistry.parseFrontmatterWithDiagnostics(text).frontmatter;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* YAML-subset parser with parse diagnostics (#501). The historical
|
|
420
|
+
* one-line-per-key subset read formatter-emitted YAML — wrapped flow lists,
|
|
421
|
+
* block sequences, indented scalar continuations — as silently EMPTY, so
|
|
422
|
+
* `migrate` skipped declared `deploy_to`/`install_workflows` entries while
|
|
423
|
+
* everything stayed green. Those shapes now parse, and a list-shaped value
|
|
424
|
+
* that still cannot be read (an unterminated `[`) is a recorded error,
|
|
425
|
+
* never a silent empty list. Still a deliberate subset: no nested mappings,
|
|
426
|
+
* no quoting, and top-level lines without a colon are skipped (pinned).
|
|
427
|
+
* Mirrored by npm/src/skill-frontmatter.ts for `overlay lint` — keep in sync.
|
|
428
|
+
*/
|
|
429
|
+
static parseFrontmatterWithDiagnostics(text) {
|
|
430
|
+
const frontmatter = {};
|
|
431
|
+
const errors = [];
|
|
417
432
|
if (!text.startsWith('---'))
|
|
418
|
-
return
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
continue;
|
|
426
|
-
if (!line.includes(':'))
|
|
427
|
-
continue;
|
|
433
|
+
return { frontmatter, errors };
|
|
434
|
+
const rest = text.split('\n').slice(1);
|
|
435
|
+
const end = rest.findIndex((l) => l.trim() === '---');
|
|
436
|
+
const body = (end === -1 ? rest : rest.slice(0, end)).filter((l) => !l.trim().startsWith('#'));
|
|
437
|
+
let i = 0;
|
|
438
|
+
while (i < body.length) {
|
|
439
|
+
const line = body[i];
|
|
428
440
|
const idx = line.indexOf(':'); // Python str.partition -> first colon.
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
.filter((item) => item);
|
|
441
|
+
i++;
|
|
442
|
+
// A line is a key only when top-level, non-blank, and colon-bearing.
|
|
443
|
+
if (!line.trim() || /^\s/.test(line) || idx === -1)
|
|
444
|
+
continue;
|
|
445
|
+
const cont = []; // indented continuation lines for this key
|
|
446
|
+
while (i < body.length && /^\s+\S/.test(body[i])) {
|
|
447
|
+
cont.push(body[i].trim());
|
|
448
|
+
i++;
|
|
438
449
|
}
|
|
439
|
-
|
|
440
|
-
|
|
450
|
+
SkillRegistry.assignFrontmatterValue(frontmatter, errors, line.slice(0, idx).trim(), line.slice(idx + 1).trim(), cont);
|
|
451
|
+
}
|
|
452
|
+
return { frontmatter, errors };
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Assign one entry from its inline value plus indented continuations: flow
|
|
456
|
+
* lists (inline, wrapped mid-list, or entirely on a continuation line —
|
|
457
|
+
* prettier's rewrite), block sequences, and folded plain scalars.
|
|
458
|
+
*/
|
|
459
|
+
static assignFrontmatterValue(fm, errors, key, inline, cont) {
|
|
460
|
+
const flow = inline.startsWith('[')
|
|
461
|
+
? [inline, ...cont]
|
|
462
|
+
: inline === '' && cont[0]?.startsWith('[')
|
|
463
|
+
? cont
|
|
464
|
+
: null;
|
|
465
|
+
if (flow !== null) {
|
|
466
|
+
const joined = flow.join(' ').trim();
|
|
467
|
+
if (!joined.endsWith(']')) {
|
|
468
|
+
errors.push(`\`${key}\`: unterminated flow list (no closing \`]\`): ${joined}`);
|
|
469
|
+
fm[key] = [];
|
|
470
|
+
return;
|
|
441
471
|
}
|
|
472
|
+
fm[key] = joined
|
|
473
|
+
.slice(1, -1)
|
|
474
|
+
.split(',')
|
|
475
|
+
.map((s) => s.trim())
|
|
476
|
+
.filter(Boolean);
|
|
477
|
+
}
|
|
478
|
+
else if (inline === '' && /^-( |$)/.test(cont[0] ?? '')) {
|
|
479
|
+
const items = SkillRegistry.blockListItems(cont);
|
|
480
|
+
if (items.length === 0)
|
|
481
|
+
errors.push(`\`${key}\`: block list has no parseable items`);
|
|
482
|
+
fm[key] = items;
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
// Scalar; indented continuation lines fold in (plain multiline YAML).
|
|
486
|
+
fm[key] = [inline, ...cont].join(' ').trim();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/** Block-sequence items; a dash-less line folds into the item above it. */
|
|
490
|
+
static blockListItems(cont) {
|
|
491
|
+
const items = [];
|
|
492
|
+
for (const c of cont) {
|
|
493
|
+
if (c.startsWith('- '))
|
|
494
|
+
items.push(c.slice(2).trim());
|
|
495
|
+
else if (c !== '-' && items.length > 0)
|
|
496
|
+
items[items.length - 1] = `${items[items.length - 1]} ${c}`.trim();
|
|
497
|
+
}
|
|
498
|
+
return items.filter(Boolean);
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Frontmatter parse diagnostics + executable-field validation combined into
|
|
502
|
+
* the `SkillInfo.error` channel; parse errors win (#501: loud, never silent).
|
|
503
|
+
*/
|
|
504
|
+
static discoveryError(fm, parseErrors) {
|
|
505
|
+
if (parseErrors.length > 0) {
|
|
506
|
+
return `frontmatter parse error: ${parseErrors.join('; ')}`;
|
|
442
507
|
}
|
|
443
|
-
return
|
|
508
|
+
return SkillRegistry.validateExecutableFields(fm);
|
|
444
509
|
}
|
|
445
510
|
/** Return an error string if the cli/entry combination is invalid. */
|
|
446
511
|
static validateExecutableFields(fm) {
|
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.4.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": {
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
},
|
|
37
38
|
"files": [
|
|
38
39
|
"bin/canary.js",
|
|
40
|
+
"bin/canary-mcp.js",
|
|
39
41
|
"dist/"
|
|
40
42
|
],
|
|
41
43
|
"dependencies": {
|