canary-test-cli 6.3.0 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Shared gate-abstention helper (issue #508, no-silent-abstention spec).
3
+ *
4
+ * Doctrine: a check that verified zero items has ABSTAINED, not passed.
5
+ * Every gate reports its denominator (`checked`); zero is a distinct loud
6
+ * outcome. "Skipped" renders in every summary line and never aggregates
7
+ * into "passed" (D7).
8
+ *
9
+ * `gateOutcome` is the only path to a summary line for swept commands, so
10
+ * the refusal to print bare success on a zero denominator is structural.
11
+ * Surfaces append their own remediation text (why the denominator
12
+ * collapsed, first fix step) after the summary line.
13
+ *
14
+ * Output glyphs are written as `\u{...}` escapes so this source stays
15
+ * ASCII while the emitted bytes match the rest of the CLI (warning sign
16
+ * U+26A0, em dash U+2014).
17
+ */
18
+ /** A check that was not run, and why. Always visible, never "passed". */
19
+ export interface SkipEntry {
20
+ name: string;
21
+ reason: string;
22
+ }
23
+ /** What a gate actually verified: its denominator and what it found. */
24
+ export interface GateResult<F> {
25
+ /** How many items were actually verified. Skipped items do NOT count. */
26
+ checked: number;
27
+ findings: F[];
28
+ skipped?: SkipEntry[];
29
+ }
30
+ /**
31
+ * Reserved CLI-wide (D4): exit 3 always means "abstained -- verified zero
32
+ * items", distinct from 0 (clean), 1 (findings), 2 (surface-specific).
33
+ */
34
+ export declare const EXIT_ABSTAINED = 3;
35
+ /**
36
+ * D3: a "gate" has an exit-code contract and fails loud (exit 3) on a zero
37
+ * denominator; an "advisory" command warns unmissably but exits 0 -- an
38
+ * empty answer honestly labeled is not an error.
39
+ */
40
+ export type GateKind = 'gate' | 'advisory';
41
+ export interface GateOutcome {
42
+ exitCode: number;
43
+ abstained: boolean;
44
+ summaryLine: string;
45
+ }
46
+ /** Copy hooks: surfaces adapt wording without re-owning the decision. */
47
+ export interface GateOutcomeOptions {
48
+ /** Unit noun for the clean-pass line. Default: `'check(s)'`. */
49
+ noun?: string;
50
+ }
51
+ /**
52
+ * D7: skipped entries render in EVERY summary line.
53
+ *
54
+ * Exported so a surface with its own failure vocabulary (doctor says
55
+ * "check(s) failed", not "finding(s)") can render the identical skip suffix
56
+ * instead of re-deriving the format -- the decision still comes from
57
+ * {@link gateOutcome}, only the noun differs.
58
+ */
59
+ export declare function skippedSuffix(skipped?: SkipEntry[]): string;
60
+ /**
61
+ * The single summary-line/exit-code path for swept commands.
62
+ *
63
+ * Non-abstained exit codes are helper defaults (findings -> 1 for gates);
64
+ * surfaces with richer contracts (e.g. freshness 2 = local edits) apply
65
+ * their own mapping AFTER checking `abstained`.
66
+ */
67
+ export declare function gateOutcome<F>(result: GateResult<F>, kind: GateKind, opts?: GateOutcomeOptions): GateOutcome;
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ // GENERATED FILE — DO NOT EDIT.
3
+ // Verbatim copy of ts/src/core/gate-result.ts, mirrored into this CommonJS
4
+ // package by scripts/sync-gate-result.mjs because the staged engine bundle is
5
+ // ESM and unavailable at test time. Edit the engine source and re-run:
6
+ // node scripts/sync-gate-result.mjs
7
+ // `npm test` verifies this copy has not drifted (--check runs as pretest).
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.EXIT_ABSTAINED = void 0;
10
+ exports.skippedSuffix = skippedSuffix;
11
+ exports.gateOutcome = gateOutcome;
12
+ /**
13
+ * Reserved CLI-wide (D4): exit 3 always means "abstained -- verified zero
14
+ * items", distinct from 0 (clean), 1 (findings), 2 (surface-specific).
15
+ */
16
+ exports.EXIT_ABSTAINED = 3;
17
+ const WARN = '\u{26A0}'; // warning sign
18
+ const EMDASH = '\u{2014}'; // em dash
19
+ // C0 controls (incl. \n, ESC) and DEL: a skip name must never be able to
20
+ // forge output lines or smuggle ANSI sequences into the summary.
21
+ const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
22
+ /**
23
+ * D7: skipped entries render in EVERY summary line.
24
+ *
25
+ * Exported so a surface with its own failure vocabulary (doctor says
26
+ * "check(s) failed", not "finding(s)") can render the identical skip suffix
27
+ * instead of re-deriving the format -- the decision still comes from
28
+ * {@link gateOutcome}, only the noun differs.
29
+ */
30
+ function skippedSuffix(skipped) {
31
+ if (!skipped || skipped.length === 0)
32
+ return '';
33
+ const names = skipped
34
+ .map((s) => s.name.replace(CONTROL_CHARS, ''))
35
+ .join(', ');
36
+ return ` (${skipped.length} skipped: ${names})`;
37
+ }
38
+ /**
39
+ * The single summary-line/exit-code path for swept commands.
40
+ *
41
+ * Non-abstained exit codes are helper defaults (findings -> 1 for gates);
42
+ * surfaces with richer contracts (e.g. freshness 2 = local edits) apply
43
+ * their own mapping AFTER checking `abstained`.
44
+ */
45
+ function gateOutcome(result, kind, opts = {}) {
46
+ const noun = opts.noun ?? 'check(s)';
47
+ const suffix = skippedSuffix(result.skipped);
48
+ // Findings outrank abstention: a finding proves something was checked,
49
+ // so it must never be masked by a collapsed/invalid denominator.
50
+ if (result.findings.length > 0) {
51
+ return {
52
+ exitCode: kind === 'gate' ? 1 : 0,
53
+ abstained: false,
54
+ summaryLine: `${result.findings.length} finding(s) across ` +
55
+ `${result.checked} checked${suffix}`,
56
+ };
57
+ }
58
+ // Negated comparison so 0, negatives, and NaN all abstain: an invalid
59
+ // denominator must never render as success.
60
+ if (!(result.checked > 0)) {
61
+ return {
62
+ exitCode: kind === 'gate' ? exports.EXIT_ABSTAINED : 0,
63
+ abstained: true,
64
+ summaryLine: `${WARN} Abstained ${EMDASH} verified zero items; ` +
65
+ `this is not a pass.${suffix}`,
66
+ };
67
+ }
68
+ return {
69
+ exitCode: 0,
70
+ abstained: false,
71
+ summaryLine: `All ${result.checked} run ${noun} passed${suffix}`,
72
+ };
73
+ }
@@ -51,6 +51,7 @@ const registry = __importStar(require("./overlays-registry.js"));
51
51
  const doctor_manifest_js_1 = require("./doctor-manifest.js");
52
52
  const overlay_conflicts_js_1 = require("./overlay-conflicts.js");
53
53
  const overlay_lint_js_1 = require("./overlay-lint.js");
54
+ const gate_result_js_1 = require("./gate-result.js");
54
55
  const defaultGit = (args, opts = {}) => {
55
56
  const r = (0, node_child_process_1.spawnSync)('git', args, { cwd: opts.cwd, encoding: 'utf8' });
56
57
  if (r.error) {
@@ -373,12 +374,27 @@ function lint(nameOrPath, deps = {}, opts = {}) {
373
374
  }
374
375
  const result = (0, overlay_lint_js_1.lintOverlay)(dir);
375
376
  const errors = result.findings.filter((f) => f.level === 'error');
377
+ // #508: linting zero skills is an ABSENT verdict, not a clean bill of health.
378
+ // Advisory (D3) -- a workflows-only overlay is legitimate, so the exit stays
379
+ // 0 -- but the line is unmissable and `--json` says so. Findings outrank
380
+ // abstention inside `gateOutcome`, so a missing skills dir still exits 1.
381
+ const outcome = (0, gate_result_js_1.gateOutcome)({ checked: result.skillsChecked, findings: result.findings }, 'advisory');
376
382
  if (opts.json) {
377
- out.write(`${JSON.stringify(result, null, 2)}\n`);
383
+ out.write(`${JSON.stringify({
384
+ ...result,
385
+ checked: result.skillsChecked,
386
+ abstained: outcome.abstained,
387
+ }, null, 2)}\n`);
378
388
  return errors.length === 0 ? 0 : 1;
379
389
  }
380
390
  const symbol = (f) => (f.level === 'error' ? '✗' : '⚠');
381
391
  out.write(`canary overlay lint: ${nameOrPath}\n`);
392
+ if (outcome.abstained) {
393
+ out.write(`\n${outcome.summaryLine}\n`);
394
+ out.write(` No skill directories under ${dir}/.canary/skills, so nothing was ` +
395
+ `linted. Add a skill, or lint the overlay that actually ships them.\n`);
396
+ return 0;
397
+ }
382
398
  if (result.findings.length === 0) {
383
399
  out.write(`\n✓ ${result.skillsChecked} skill(s) — no issues.\n`);
384
400
  return 0;
@@ -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.5.0",
4
4
  "description": "Canary — AI-powered test automation agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -8,7 +8,8 @@
8
8
  "url": "https://github.com/bop-clocktower/canary.git"
9
9
  },
10
10
  "bin": {
11
- "canary": "./bin/canary.js"
11
+ "canary": "./bin/canary.js",
12
+ "canary-mcp": "./bin/canary-mcp.js"
12
13
  },
13
14
  "exports": {
14
15
  "./reporter": {
@@ -29,6 +30,7 @@
29
30
  "scripts": {
30
31
  "build": "tsc && node scripts/build-engine.mjs",
31
32
  "prepare": "npm run build",
33
+ "pretest": "node scripts/sync-gate-result.mjs --check",
32
34
  "test": "tsc && node --test \"scripts/__tests__/*.test.js\""
33
35
  },
34
36
  "engines": {
@@ -36,6 +38,7 @@
36
38
  },
37
39
  "files": [
38
40
  "bin/canary.js",
41
+ "bin/canary-mcp.js",
39
42
  "dist/"
40
43
  ],
41
44
  "dependencies": {