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.
@@ -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
- * - **Context shape is a contract**: the four keys `{version, os, python,
16
- * install}` (and their order) are preserved. The `python` value is the JS
17
- * *runtime* version (`process.version`) the Node analog of Python's
18
- * `platform.python_version()`; the field name is kept for shape fidelity.
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: canaryVersion(),
49
+ version,
52
50
  os: `${type()} ${release()}`.trim(),
53
- python: process.version,
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
- // Python `message[:60]` slices by code point; JS `slice` slices by UTF-16
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
- /** 0 in sync, 1 drift, 2 local edits (safety refusal wins). */
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
- // 0. Explicit override in .canary/company.json ("canary_shape" field).
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
- if (explicitShape) {
1269
- for (const [filename, framework, , confidence] of _CONFIG_PROBES) {
1270
- if (existsSync(join(root, filename))) {
1271
- return [framework, explicitShape, filename, confidence];
1272
- }
1273
- }
1274
- // Fall through to content probes.
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.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) {
@@ -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.3.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": {