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.
@@ -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;
@@ -344,6 +344,7 @@ export function migrateCmd(opts, deps) {
344
344
  dryRun,
345
345
  framework: opts.framework || null,
346
346
  overlayPath,
347
+ force: opts.force ?? false,
347
348
  });
348
349
  }
349
350
  catch (e) {
@@ -367,6 +368,7 @@ export function migrateCmd(opts, deps) {
367
368
  status: r.status,
368
369
  note: r.note,
369
370
  })),
371
+ installed_workflows: report.installed_workflows.map((r) => r.to_dict()),
370
372
  }));
371
373
  return;
372
374
  }
@@ -107,6 +107,7 @@ export function createCanaryCommand(depsInit = {}) {
107
107
  .option('-o, --overlay <path>', '[deprecated: use --from] Path to an overlay repo whose .canary/skills/ are deployed.')
108
108
  .option('--apply', 'Write files. Without this flag the command is a dry run.')
109
109
  .option('--check', 'Freshness gate: report drift without writing.')
110
+ .option('--force', 'Overwrite a .github/workflows/ file that differs from the overlay template. Without this flag a difference is only reported -- your CI is never rewritten behind your back.')
110
111
  .option('--json', 'Emit the report as JSON.')
111
112
  .action((opts) => {
112
113
  migrateCmd(opts, deps);
@@ -13,9 +13,11 @@
13
13
  * 2. .canary/company.json -- project-local config
14
14
  * 3. .canary/company.<env>.json -- environment override (CANARY_ENV or explicit)
15
15
  *
16
- * List fields are unioned across sources; scalar fields (dashboard_url,
17
- * dashboard_token_env, notes) are replaced by the highest-priority source that
18
- * sets them.
16
+ * List fields ({@link _LIST_FIELDS}) are unioned across sources; scalar fields
17
+ * ({@link _SCALAR_FIELDS}) are replaced by the highest-priority source that
18
+ * sets a non-empty value. Those two arrays are the single place a new field
19
+ * opts into a merge rule, and both are checked for exhaustiveness at compile
20
+ * time.
19
21
  *
20
22
  * Python->TS nuances:
21
23
  * - Python patches `Path.home()` in its tests to isolate the home tier. There
@@ -118,6 +120,12 @@ const _KNOWN_KEYS = new Set([
118
120
  // warning that it is told anyone adopting an overlay that the single field
119
121
  // driving their adoption does nothing.
120
122
  'canary_shape',
123
+ // #459: repo-relative pointers a generated workflow interpolates (the
124
+ // coverage report the guardian reads; the controllers dir it scopes SUT
125
+ // analysis to). See `validateRepoRelativePath` for why they are validated
126
+ // rather than stored verbatim.
127
+ 'coverage_report_path',
128
+ 'sut_controllers_path',
121
129
  ]);
122
130
  const _HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
123
131
  const _BRAND_TEXT_MAX = 200;
@@ -240,6 +248,50 @@ function validateOtelEndpoint(raw, fieldName, warnings) {
240
248
  }
241
249
  return raw;
242
250
  }
251
+ // A path that is absolute in any flavour git checkouts run under: POSIX
252
+ // (`/x`), UNC / Windows-separator (`\x`), or drive-qualified (`C:x`, `C:/x`).
253
+ // Drive-relative `C:x` is included deliberately -- it is not repo-relative.
254
+ const _ABSOLUTE_PATH_RE = /^(?:[/\\]|[A-Za-z]:)/;
255
+ // Newlines would break out of the scalar these values are interpolated into
256
+ // when a workflow template is generated, so they are refused outright rather
257
+ // than escaped -- no legitimate repo path contains one.
258
+ const _PATH_CONTROL_RE = /[\r\n\t\0]/;
259
+ /**
260
+ * A repo-relative path pointer (#459: `coverage_report_path`,
261
+ * `sut_controllers_path`).
262
+ *
263
+ * These are interpolated into generated GitHub Actions YAML, so validation is
264
+ * a safety boundary, not tidiness: an absolute path aims the generated CI at
265
+ * something outside the checkout, and a `..` segment escapes the repo. Both are
266
+ * dropped with a warning (the module's degrade-never-throw convention); a
267
+ * secret-like value raises so the whole layer is refused, exactly as every
268
+ * other non-notes field does.
269
+ *
270
+ * `..` is rejected as a SUBSTRING, not just as a path component. A component
271
+ * check would have to agree with the separator handling of whatever consumes
272
+ * the value later (shell, Actions expression, node `path`); refusing the two
273
+ * characters outright cannot disagree with anything. The cost is rejecting the
274
+ * vanishingly rare legitimate `report..xml`.
275
+ */
276
+ function validateRepoRelativePath(raw, fieldName, warnings) {
277
+ if (typeof raw !== 'string') {
278
+ warnings.push(`${fieldName}: expected string, got ${pyTypeName(raw)} ${EMDASH} skipped`);
279
+ return '';
280
+ }
281
+ const value = raw.trim();
282
+ if (!value)
283
+ return '';
284
+ if (looksLikeSecret(value))
285
+ throw new SecretDetected(fieldName, value);
286
+ if (_ABSOLUTE_PATH_RE.test(value) ||
287
+ value.includes('..') ||
288
+ _PATH_CONTROL_RE.test(value)) {
289
+ warnings.push(`${fieldName}: dropped invalid repo-relative path ${pyRepr(raw)} ` +
290
+ `${EMDASH} must stay inside the repo (no absolute path, no '..')`);
291
+ return '';
292
+ }
293
+ return value;
294
+ }
243
295
  /** Accept #RGB / #RRGGBB (any case); drop anything else with a warning. */
244
296
  function validateHexColor(raw, fieldName, warnings) {
245
297
  if (typeof raw !== 'string' || !raw)
@@ -432,6 +484,14 @@ function parseLayer(data, source) {
432
484
  if (Object.prototype.hasOwnProperty.call(data, 'otel_exporter_endpoint')) {
433
485
  otel_exporter_endpoint = validateOtelEndpoint(data['otel_exporter_endpoint'], 'otel_exporter_endpoint', warns);
434
486
  }
487
+ let coverage_report_path = '';
488
+ if (Object.prototype.hasOwnProperty.call(data, 'coverage_report_path')) {
489
+ coverage_report_path = validateRepoRelativePath(data['coverage_report_path'], 'coverage_report_path', warns);
490
+ }
491
+ let sut_controllers_path = '';
492
+ if (Object.prototype.hasOwnProperty.call(data, 'sut_controllers_path')) {
493
+ sut_controllers_path = validateRepoRelativePath(data['sut_controllers_path'], 'sut_controllers_path', warns);
494
+ }
435
495
  let notes = '';
436
496
  if (Object.prototype.hasOwnProperty.call(data, 'notes')) {
437
497
  const rawNotes = data['notes'];
@@ -455,6 +515,8 @@ function parseLayer(data, source) {
455
515
  dashboard_url,
456
516
  dashboard_token_env,
457
517
  otel_exporter_endpoint,
518
+ coverage_report_path,
519
+ sut_controllers_path,
458
520
  notes,
459
521
  brand,
460
522
  warnings: warns,
@@ -509,51 +571,66 @@ function union(a, b) {
509
571
  }
510
572
  return out;
511
573
  }
574
+ const _LIST_FIELDS = [
575
+ 'confluence_spaces',
576
+ 'jira_projects',
577
+ 'internal_doc_urls',
578
+ 'internal_domains',
579
+ 'mcp_servers',
580
+ 'claude_code_skills',
581
+ ];
582
+ const _SCALAR_FIELDS = [
583
+ 'dashboard_url',
584
+ 'dashboard_token_env',
585
+ 'otel_exporter_endpoint',
586
+ 'coverage_report_path',
587
+ 'sut_controllers_path',
588
+ 'notes',
589
+ ];
590
+ // Compile-time exhaustiveness: adding a field to `Layer`/`MergedFields` without
591
+ // adding it to the matching array above fails the build here (the assertion
592
+ // type collapses to `never`) rather than silently dropping the field at merge.
593
+ const _LIST_FIELDS_EXHAUSTIVE = true;
594
+ const _SCALAR_FIELDS_EXHAUSTIVE = true;
595
+ void _LIST_FIELDS_EXHAUSTIVE;
596
+ void _SCALAR_FIELDS_EXHAUSTIVE;
597
+ function mergeListFields(layers) {
598
+ const out = {};
599
+ for (const field of _LIST_FIELDS) {
600
+ let merged = [];
601
+ for (const layer of layers)
602
+ merged = union(merged, layer[field]);
603
+ out[field] = merged;
604
+ }
605
+ return out;
606
+ }
607
+ function mergeScalarFields(layers) {
608
+ const out = {};
609
+ for (const field of _SCALAR_FIELDS) {
610
+ let merged = '';
611
+ // Highest-priority non-empty wins. A layer whose value was DROPPED as
612
+ // invalid contributes '' and therefore leaves the lower layer's valid value
613
+ // standing (degrade, never blank out) -- load-bearing, and pinned by tests.
614
+ for (const layer of layers)
615
+ if (layer[field])
616
+ merged = layer[field];
617
+ out[field] = merged;
618
+ }
619
+ return out;
620
+ }
512
621
  function mergeLayers(layers) {
513
- let confluence_spaces = [];
514
- let jira_projects = [];
515
- let internal_doc_urls = [];
516
- let internal_domains = [];
517
- let mcp_servers = [];
518
- let claude_code_skills = [];
519
- let dashboard_url = '';
520
- let dashboard_token_env = '';
521
- let otel_exporter_endpoint = '';
522
- let notes = '';
523
- const warns = [];
622
+ const warnings = [];
524
623
  const sources = [];
525
624
  for (const layer of layers) {
526
- confluence_spaces = union(confluence_spaces, layer.confluence_spaces);
527
- jira_projects = union(jira_projects, layer.jira_projects);
528
- internal_doc_urls = union(internal_doc_urls, layer.internal_doc_urls);
529
- internal_domains = union(internal_domains, layer.internal_domains);
530
- mcp_servers = union(mcp_servers, layer.mcp_servers);
531
- claude_code_skills = union(claude_code_skills, layer.claude_code_skills);
532
- if (layer.dashboard_url)
533
- dashboard_url = layer.dashboard_url;
534
- if (layer.dashboard_token_env)
535
- dashboard_token_env = layer.dashboard_token_env;
536
- if (layer.otel_exporter_endpoint)
537
- otel_exporter_endpoint = layer.otel_exporter_endpoint;
538
- if (layer.notes)
539
- notes = layer.notes;
540
- warns.push(...layer.warnings);
625
+ warnings.push(...layer.warnings);
541
626
  if (layer.source)
542
627
  sources.push(layer.source);
543
628
  }
544
629
  return {
545
- confluence_spaces,
546
- jira_projects,
547
- internal_doc_urls,
548
- internal_domains,
549
- mcp_servers,
550
- claude_code_skills,
551
- dashboard_url,
552
- dashboard_token_env,
553
- otel_exporter_endpoint,
554
- notes,
630
+ ...mergeListFields(layers),
631
+ ...mergeScalarFields(layers),
555
632
  brand: mergeBrand(layers),
556
- warnings: warns,
633
+ warnings,
557
634
  sources,
558
635
  };
559
636
  }
@@ -585,6 +662,10 @@ export class CompanyKnowledge {
585
662
  dashboard_url;
586
663
  dashboard_token_env;
587
664
  otel_exporter_endpoint;
665
+ /** Repo-relative path to the coverage report a generated workflow reads. */
666
+ coverage_report_path;
667
+ /** Repo-relative path to the SUT controllers dir analysis is scoped to. */
668
+ sut_controllers_path;
588
669
  notes;
589
670
  brand;
590
671
  warnings;
@@ -600,6 +681,8 @@ export class CompanyKnowledge {
600
681
  this.dashboard_url = init.dashboard_url ?? '';
601
682
  this.dashboard_token_env = init.dashboard_token_env ?? '';
602
683
  this.otel_exporter_endpoint = init.otel_exporter_endpoint ?? '';
684
+ this.coverage_report_path = init.coverage_report_path ?? '';
685
+ this.sut_controllers_path = init.sut_controllers_path ?? '';
603
686
  this.notes = init.notes ?? '';
604
687
  this.brand = init.brand ?? new Brand();
605
688
  this.warnings = init.warnings ?? [];
@@ -733,6 +816,8 @@ export class CompanyKnowledge {
733
816
  dashboard_url: this.dashboard_url,
734
817
  dashboard_token_env: this.dashboard_token_env,
735
818
  otel_exporter_endpoint: this.otel_exporter_endpoint,
819
+ coverage_report_path: this.coverage_report_path,
820
+ sut_controllers_path: this.sut_controllers_path,
736
821
  notes: this.notes,
737
822
  brand: this.brand.toDict(),
738
823
  sources: this.sources,
@@ -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