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.
@@ -339,7 +339,7 @@ export class SkillRegistry {
339
339
  catch {
340
340
  return null;
341
341
  }
342
- const fm = SkillRegistry.parseFrontmatter(text);
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.validateExecutableFields(fm),
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.parseFrontmatter(text);
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.validateExecutableFields(fm),
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 flow-list fields between `---`
412
- * delimiters. No nesting, no block sequences, no quoting. Python:
413
- * `_parse_frontmatter`.
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
- const result = {};
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 result;
419
- const lines = text.split('\n');
420
- for (let i = 1; i < lines.length; i++) {
421
- const line = lines[i];
422
- if (line.trim() === '---')
423
- break;
424
- if (!line || line.replace(/^\s+/, '').startsWith('#'))
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
- const key = line.slice(0, idx);
430
- const value = line.slice(idx + 1);
431
- const v = value.trim();
432
- if (v.startsWith('[') && v.endsWith(']')) {
433
- const inner = v.slice(1, -1);
434
- result[key.trim()] = inner
435
- .split(',')
436
- .map((item) => item.trim())
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
- else {
440
- result[key.trim()] = v;
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 result;
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
- authored_sentinel_present; // loop-guard -- (a)
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 run ${EM_DASH} not re-authoring`;
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
- authored_sentinel_present: existsSync(authoredSentinelPath(deps, repoRoot)),
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 body = opts.path.map((p) => `${p}\n`).join('');
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
  }
@@ -1,4 +1,9 @@
1
- /** Migration target shapes a `deploy_to` entry may name, plus the `all` sentinel. */
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. */
@@ -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
- * 2. `deploy_to` values resolve to known migration targets;
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
- /** Migration target shapes a `deploy_to` entry may name, plus the `all` sentinel. */
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(mdPath, 'utf8');
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
- // 1. Frontmatter floor.
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
- if (fm.cli) {
157
- const f = cliFinding(name, skillDir, fm.cli);
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.2.0",
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": {