canary-test-cli 6.2.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 +3 -1
- package/dist/engine/cli.core.js +1 -0
- package/dist/engine/core/company-knowledge.js +125 -40
- package/dist/engine/core/feedback.js +32 -18
- package/dist/engine/core/gate-result.js +73 -0
- package/dist/engine/core/migrator.js +400 -30
- package/dist/engine/core/skill-registry.js +95 -30
- package/dist/engine/guardian/agent-tier.js +7 -2
- package/dist/engine/guardian/cli.js +70 -2
- 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
|
@@ -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) {
|
|
@@ -181,7 +181,11 @@ export class AuthoringContext {
|
|
|
181
181
|
effective_tier; // from resolveTier (2 == can author)
|
|
182
182
|
is_fork; // reuse Phase-2 fork/403 detection -- (b)
|
|
183
183
|
repo_root; // collision + sentinel base
|
|
184
|
-
|
|
184
|
+
// loop-guard -- (a). True only when a sentinel stamped at the CURRENT HEAD
|
|
185
|
+
// exists (#456): the caller resolves the stamp, and every unverifiable state
|
|
186
|
+
// (missing/unreadable/malformed sentinel, unresolvable HEAD) passes `false`
|
|
187
|
+
// so authoring fails OPEN.
|
|
188
|
+
authored_sentinel_present;
|
|
185
189
|
constructor(author_tests_optin, effective_tier, init = {}) {
|
|
186
190
|
this.author_tests_optin = author_tests_optin;
|
|
187
191
|
this.effective_tier = effective_tier;
|
|
@@ -256,7 +260,8 @@ function authoringSkipReason(gap, ctx) {
|
|
|
256
260
|
return `fork: read-only ${EM_DASH} guardian never writes on a fork PR`;
|
|
257
261
|
}
|
|
258
262
|
if (ctx.authored_sentinel_present) {
|
|
259
|
-
return `loop-guard: guardian tests already authored this
|
|
263
|
+
return (`loop-guard: guardian tests already authored at this HEAD ${EM_DASH} ` +
|
|
264
|
+
`review and commit them to re-enable authoring`);
|
|
260
265
|
}
|
|
261
266
|
const target = joinPosix(ctx.repo_root, targetTestPath(gap));
|
|
262
267
|
if (existsSync(target)) {
|
|
@@ -258,6 +258,61 @@ const AUTHORED_SENTINEL_NAME = 'canary-guardian-authored';
|
|
|
258
258
|
function authoredSentinelPath(deps, root) {
|
|
259
259
|
return join(gitDir(deps, root), AUTHORED_SENTINEL_NAME);
|
|
260
260
|
}
|
|
261
|
+
// The sentinel's FIRST line stamps the HEAD the guardian authored at:
|
|
262
|
+
// `HEAD <sha>`. Every line after it is one authored path. Anchored at the start
|
|
263
|
+
// of the body and hex-only, with a trailing `[ \t\r]*` so a CRLF-written file
|
|
264
|
+
// still parses -- anything else reads as malformed, which fails OPEN.
|
|
265
|
+
const SENTINEL_HEAD_RE = /^HEAD ([0-9a-fA-F]{7,64})[ \t\r]*(?:\n|$)/;
|
|
266
|
+
/**
|
|
267
|
+
* Parse the `HEAD <sha>` stamp off a sentinel body; `null` when malformed.
|
|
268
|
+
*
|
|
269
|
+
* Malformed covers empty, headerless (the pre-#456 paths-only format), and any
|
|
270
|
+
* unparseable first line. Callers MUST treat `null` as "cannot verify" and fail
|
|
271
|
+
* OPEN -- an unreadable sentinel must never wedge authoring off (#456).
|
|
272
|
+
*/
|
|
273
|
+
function sentinelHeadStamp(text) {
|
|
274
|
+
const match = SENTINEL_HEAD_RE.exec(text);
|
|
275
|
+
return match === null ? null : match[1].toLowerCase();
|
|
276
|
+
}
|
|
277
|
+
/** Current `HEAD` sha for `root`, or `null` when git/HEAD is unavailable. */
|
|
278
|
+
function headSha(deps, root) {
|
|
279
|
+
const res = deps.runGit(['rev-parse', 'HEAD'], root);
|
|
280
|
+
if (res === null || res.code !== 0)
|
|
281
|
+
return null; // no git / no commits
|
|
282
|
+
return res.stdout.trim().toLowerCase() || null;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Is the loop guard live -- i.e. does a sentinel stamped at the CURRENT `HEAD`
|
|
286
|
+
* exist?
|
|
287
|
+
*
|
|
288
|
+
* This is the surviving half of the stage-and-block-once contract (#456). The
|
|
289
|
+
* component that CLEARED the sentinel on the next commit
|
|
290
|
+
* (`hooks/guardian_precommit.py`) was deleted as dead code in #449, which left
|
|
291
|
+
* `author-plan` fail-closed forever: author once in a clone and Tier-2 authoring
|
|
292
|
+
* never ran again. Stamping HEAD makes the guard self-expiring -- once the human
|
|
293
|
+
* reviews and commits the staged tests, `HEAD` moves, the stamp stops matching,
|
|
294
|
+
* and authoring re-enables itself with no manual step and no hook.
|
|
295
|
+
*
|
|
296
|
+
* Every unverifiable state FAILS OPEN (returns `false`, authoring allowed):
|
|
297
|
+
* missing or unreadable sentinel, a malformed/absent `HEAD` header, or a `HEAD`
|
|
298
|
+
* we cannot resolve. Fail-closed here is exactly the bug being fixed.
|
|
299
|
+
*/
|
|
300
|
+
function authoredSentinelActive(deps, root) {
|
|
301
|
+
let body;
|
|
302
|
+
try {
|
|
303
|
+
body = readFileSync(authoredSentinelPath(deps, root), 'utf-8');
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return false; // absent or unreadable -> fail open
|
|
307
|
+
}
|
|
308
|
+
const stamp = sentinelHeadStamp(body);
|
|
309
|
+
if (stamp === null)
|
|
310
|
+
return false; // malformed -> fail open
|
|
311
|
+
const head = headSha(deps, root);
|
|
312
|
+
if (head === null)
|
|
313
|
+
return false; // unverifiable -> fail open
|
|
314
|
+
return head === stamp;
|
|
315
|
+
}
|
|
261
316
|
/**
|
|
262
317
|
* Return raw unified-diff text from a source.
|
|
263
318
|
*
|
|
@@ -785,7 +840,8 @@ function authorPlanCmd(opts, deps) {
|
|
|
785
840
|
const ctx = new AuthoringContext(config.precommit_author_tests, effective, {
|
|
786
841
|
is_fork: isForkContext(deps.env),
|
|
787
842
|
repo_root: repoRoot,
|
|
788
|
-
|
|
843
|
+
// #456: HEAD-stamped, so the guard expires on the next commit by itself.
|
|
844
|
+
authored_sentinel_present: authoredSentinelActive(deps, repoRoot),
|
|
789
845
|
});
|
|
790
846
|
const results = deps.makeAgentTier().author_tests(gaps, ctx);
|
|
791
847
|
const decision = decideBlock(results);
|
|
@@ -799,11 +855,23 @@ function authorPlanCmd(opts, deps) {
|
|
|
799
855
|
};
|
|
800
856
|
deps.out(ensureAscii(JSON.stringify(payload, null, 2)));
|
|
801
857
|
}
|
|
858
|
+
/**
|
|
859
|
+
* Record the authored paths in the loop-guard sentinel, stamped with the HEAD
|
|
860
|
+
* they were authored at (#456).
|
|
861
|
+
*
|
|
862
|
+
* The `HEAD <sha>` header is what makes the guard self-expiring: `author-plan`
|
|
863
|
+
* honors it only while `HEAD` still matches, so the human's review commit clears
|
|
864
|
+
* it implicitly. When `HEAD` cannot be resolved (a repo with no commits, or no
|
|
865
|
+
* git at all) the header is omitted -- an unstamped sentinel reads as malformed
|
|
866
|
+
* and FAILS OPEN, which is the safe direction.
|
|
867
|
+
*/
|
|
802
868
|
function markAuthoredCmd(opts, deps) {
|
|
803
869
|
const root = gitToplevel(deps);
|
|
804
870
|
const sentinel = authoredSentinelPath(deps, root);
|
|
805
871
|
mkdirSync(dirname(sentinel), { recursive: true });
|
|
806
|
-
const
|
|
872
|
+
const head = headSha(deps, root);
|
|
873
|
+
const header = head === null ? '' : `HEAD ${head}\n`;
|
|
874
|
+
const body = header + opts.path.map((p) => `${p}\n`).join('');
|
|
807
875
|
writeFileSync(sentinel, body, 'utf-8');
|
|
808
876
|
deps.out(`guardian: recorded ${opts.path.length} authored path(s) ${RIGHT_ARROW} ${sentinel}`);
|
|
809
877
|
}
|
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": {
|