canary-test-cli 7.0.0 → 7.1.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.
Files changed (47) hide show
  1. package/dist/engine/analysis/cli.js +116 -54
  2. package/dist/engine/analysis/engine.js +34 -16
  3. package/dist/engine/analysis/reports.js +5 -4
  4. package/dist/engine/cli-commands.js +249 -41
  5. package/dist/engine/cli-common.js +15 -24
  6. package/dist/engine/cli.core.js +37 -11
  7. package/dist/engine/cli.js +2 -2
  8. package/dist/engine/company-knowledge-cli.js +2 -2
  9. package/dist/engine/core/adoption.js +408 -0
  10. package/dist/engine/core/framework-probes.js +7 -7
  11. package/dist/engine/core/fs-glob.js +2 -2
  12. package/dist/engine/core/gate-result.js +17 -0
  13. package/dist/engine/core/migrator.js +9 -17
  14. package/dist/engine/core/pattern-matcher.js +23 -5
  15. package/dist/engine/core/persona.js +421 -0
  16. package/dist/engine/core/promotion-verdict.js +261 -0
  17. package/dist/engine/core/reporter.js +1 -9
  18. package/dist/engine/core/skill-examples.js +292 -0
  19. package/dist/engine/core/skill-surfaces.js +307 -0
  20. package/dist/engine/core/static-linter.js +310 -38
  21. package/dist/engine/core/ticket-updater.js +1 -7
  22. package/dist/engine/core/vacuity-scanner.js +556 -0
  23. package/dist/engine/core/workflow-discovery.js +2 -8
  24. package/dist/engine/core/workspace-detect.js +7 -6
  25. package/dist/engine/data/personas/registry.json +36 -0
  26. package/dist/engine/guardian/adjudication.js +5 -5
  27. package/dist/engine/guardian/analysis-emit.js +13 -27
  28. package/dist/engine/guardian/cli.js +30 -43
  29. package/dist/engine/guardian/coverage.js +1 -1
  30. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
  31. package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
  32. package/dist/engine/guardian/pr-check.js +5 -15
  33. package/dist/engine/guardian/pr-comment.js +4 -3
  34. package/dist/engine/history/cli.js +210 -6
  35. package/dist/engine/history/ndjson-store.js +9 -5
  36. package/dist/engine/history/record.js +34 -5
  37. package/dist/engine/history/run-recorder.js +165 -0
  38. package/dist/engine/history/schema.js +25 -7
  39. package/dist/engine/history/store.js +9 -0
  40. package/dist/engine/mcp-server.js +35 -13
  41. package/dist/engine/skills-cli.js +133 -11
  42. package/dist/engine/util/ensure-ascii.js +37 -0
  43. package/dist/engine/workflow-cli.js +6 -6
  44. package/dist/gate-result.d.ts +11 -0
  45. package/dist/gate-result.js +18 -0
  46. package/dist/uninstall.js +12 -5
  47. package/package.json +1 -1
@@ -5,7 +5,7 @@
5
5
  * doctor, ticket-update). The commander wiring (options/args/defaults) lives in
6
6
  * `cli.ts`; these functions are the thin handlers it dispatches to.
7
7
  *
8
- * Conventions mirror `guardian/cli.ts`: `CliExit` for business exits,
8
+ * Conventions mirror `guardian/cli.ts`: `CliExitError` for business exits,
9
9
  * `jsonIndent2` for `json.dumps(indent=2)`, picocolors for rich markup (color
10
10
  * strips on a non-TTY sink so plain text is byte-exact), and output glyphs as
11
11
  * `\u{...}` escapes emitted verbatim.
@@ -13,8 +13,11 @@
13
13
  import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
14
14
  import { basename, extname, join, resolve } from 'node:path';
15
15
  import pc from 'picocolors';
16
- import { CliExit, jsonIndent2 } from './cli-common.js';
17
- import { gateOutcome } from './core/gate-result.js';
16
+ import { CliExitError, jsonIndent2 } from './cli-common.js';
17
+ import { gatherAdoptionReport } from './core/adoption.js';
18
+ import { EXIT_ABSTAINED, errnoCode, gateOutcome, skippedSuffix, } from './core/gate-result.js';
19
+ import { promotionVerdict } from './core/promotion-verdict.js';
20
+ import { scanVacuity } from './core/vacuity-scanner.js';
18
21
  import { ckInitCmd } from './company-knowledge-cli.js';
19
22
  import { extractFrameworkHint } from './core/classifier.js';
20
23
  import { VALID_CATEGORIES, buildFeedback } from './core/feedback.js';
@@ -100,7 +103,7 @@ function collectTestFiles(dir) {
100
103
  })
101
104
  .sort();
102
105
  }
103
- export function recommendCmd(promptText, opts, deps) {
106
+ export function recommendFrameworkCmd(promptText, opts, deps) {
104
107
  const classifier = deps.makeClassifier();
105
108
  const recommender = deps.makeRecommender();
106
109
  const classification = classifier.classify(promptText);
@@ -149,7 +152,7 @@ const TIER_STYLE = {
149
152
  executable: pc.cyan,
150
153
  catalog: pc.yellow,
151
154
  };
152
- export function frameworksCmd(opts, deps) {
155
+ export function listFrameworksCmd(opts, deps) {
153
156
  const summaries = deps.makeRegistry().summaries();
154
157
  if (opts.json) {
155
158
  deps.out(jsonIndent2({ frameworks: summaries }));
@@ -173,11 +176,11 @@ export function frameworksCmd(opts, deps) {
173
176
  export function feedbackCmd(message, opts, deps) {
174
177
  if (!VALID_CATEGORIES.includes(opts.category)) {
175
178
  deps.out(`${pc.bold(pc.red(CROSS))} Unknown --category '${opts.category}'. Choose one of: ${VALID_CATEGORIES.join(', ')}.`);
176
- throw new CliExit(1);
179
+ throw new CliExitError(1);
177
180
  }
178
181
  if (!message || !message.trim()) {
179
182
  deps.out(`${pc.bold(pc.red(CROSS))} A feedback message is required.\nUsage: ${pc.bold('canary feedback "<message>" [--category bug|ux|docs|idea]')}`);
180
- throw new CliExit(1);
183
+ throw new CliExitError(1);
181
184
  }
182
185
  const fb = buildFeedback(message.trim(), opts.category, resolveVersion(deps));
183
186
  if (opts.json) {
@@ -290,7 +293,7 @@ function resolveMigrateOverlay(fromOverlay, overlay, deps) {
290
293
  catch (e) {
291
294
  if (e instanceof OverlayNotFound) {
292
295
  deps.out(`\n${pc.bold(pc.red(CROSS))} ${e.message}`);
293
- throw new CliExit(1);
296
+ throw new CliExitError(1);
294
297
  }
295
298
  throw e;
296
299
  }
@@ -307,14 +310,33 @@ function resolveMigrateOverlay(fromOverlay, overlay, deps) {
307
310
  if (tracked.length > 1) {
308
311
  const names = tracked.join(', ');
309
312
  deps.out(`\n${pc.bold(pc.red(CROSS))} ${tracked.length} tracked overlays registered (${names}).\nChoose one with ${pc.bold('--from <name>')}.`);
310
- throw new CliExit(1);
313
+ throw new CliExitError(1);
311
314
  }
312
315
  return null;
313
316
  }
317
+ /**
318
+ * `--adoption-report`: which adoption pieces are in place here (#459).
319
+ *
320
+ * Read-only, and unlike `--check` it runs WITHOUT an overlay: "no overlay is
321
+ * tracked" is itself one of the answers a half-adopted repo needs, so refusing
322
+ * to report until one exists would hide the most common gap.
323
+ */
324
+ function migrateAdoption(migrator, root, overlayPath, json, deps) {
325
+ let report;
326
+ try {
327
+ report = gatherAdoptionReport(migrator, root, overlayPath, deps.home());
328
+ }
329
+ catch (e) {
330
+ deps.out(`\n${pc.bold(pc.red(CROSS))} ${e instanceof Error ? e.message : String(e)}`);
331
+ throw new CliExitError(1);
332
+ }
333
+ deps.out(json ? jsonIndent2(report.to_dict()) : report.to_markdown());
334
+ throw new CliExitError(report.exit_code());
335
+ }
314
336
  function migrateCheck(migrator, root, overlayPath, json, deps) {
315
337
  if (overlayPath === null) {
316
338
  deps.out(`\n${pc.yellow('No overlay to check against.')} Track one with ${pc.bold('canary overlay add')} or pass ${pc.bold('--from <overlay>')}.`);
317
- throw new CliExit(0);
339
+ throw new CliExitError(0);
318
340
  }
319
341
  let report;
320
342
  try {
@@ -322,7 +344,7 @@ function migrateCheck(migrator, root, overlayPath, json, deps) {
322
344
  }
323
345
  catch (e) {
324
346
  deps.out(`\n${pc.bold(pc.red(CROSS))} ${e instanceof Error ? e.message : String(e)}`);
325
- throw new CliExit(1);
347
+ throw new CliExitError(1);
326
348
  }
327
349
  if (json) {
328
350
  deps.out(jsonIndent2(report.to_dict()));
@@ -330,23 +352,37 @@ function migrateCheck(migrator, root, overlayPath, json, deps) {
330
352
  else {
331
353
  deps.out(report.to_markdown());
332
354
  }
333
- throw new CliExit(report.exit_code());
355
+ throw new CliExitError(report.exit_code());
356
+ }
357
+ /**
358
+ * Dispatch the report-only modes, which each exit via `CliExitError`.
359
+ *
360
+ * One gate for both so `migrateCmd` carries a single "does this run write?"
361
+ * branch: a second report mode added inline is how the writing path acquires an
362
+ * accidental fall-through.
363
+ */
364
+ function migrateReportOnly(opts, migrator, root, overlayPath, deps) {
365
+ const json = opts.json ?? false;
366
+ if (opts.adoptionReport) {
367
+ migrateAdoption(migrator, root, overlayPath, json, deps);
368
+ }
369
+ if (opts.check) {
370
+ migrateCheck(migrator, root, overlayPath, json, deps);
371
+ }
334
372
  }
335
373
  export function migrateCmd(opts, deps) {
336
374
  const root = resolve(opts.path);
337
375
  const overlayPath = resolveMigrateOverlay(opts.from, opts.overlay, deps);
338
376
  const migrator = deps.makeMigrator();
339
- if (opts.check) {
340
- migrateCheck(migrator, root, overlayPath, opts.json ?? false, deps);
341
- return; // unreachable -- migrateCheck always throws CliExit
342
- }
377
+ // Both report-only modes exit here; neither ever reaches the writing path.
378
+ migrateReportOnly(opts, migrator, root, overlayPath, deps);
343
379
  let ctx;
344
380
  try {
345
381
  ctx = migrator.detect(root);
346
382
  }
347
383
  catch (e) {
348
384
  deps.out(`\n${pc.bold(pc.red('Detection error:'))} ${e instanceof Error ? e.message : String(e)}`);
349
- throw new CliExit(1);
385
+ throw new CliExitError(1);
350
386
  }
351
387
  if (!ctx.is_harness_project) {
352
388
  if (ctx.not_test_project_reason) {
@@ -355,7 +391,7 @@ export function migrateCmd(opts, deps) {
355
391
  else {
356
392
  deps.out(`\n${pc.bold(pc.red(CROSS))} No harness project detected at ${pc.bold(root)}.\nExpected ${pc.dim('harness.config.json')} and ${pc.dim('.harness/')} directory.`);
357
393
  }
358
- throw new CliExit(1);
394
+ throw new CliExitError(1);
359
395
  }
360
396
  const dryRun = !opts.apply;
361
397
  const modeLabel = dryRun ? pc.dim('(dry run)') : pc.green('(apply)');
@@ -373,7 +409,7 @@ export function migrateCmd(opts, deps) {
373
409
  }
374
410
  catch (e) {
375
411
  deps.out(`\n${pc.bold(pc.red('Error:'))} ${e instanceof Error ? e.message : String(e)}`);
376
- throw new CliExit(1);
412
+ throw new CliExitError(1);
377
413
  }
378
414
  if (opts.json) {
379
415
  deps.out(jsonIndent2({
@@ -420,8 +456,14 @@ function findingPayload(f) {
420
456
  /** Human-readable list of what the collectors look for, for remedy text. */
421
457
  const SCANNABLE_DESC = `test_*.py, *.test|spec.{${JS_TEST_EXTENSIONS.map((e) => e.slice(1)).join(',')}}`;
422
458
  /** Emit the abstention notice in the caller's output mode, then exit 3. */
423
- function abstain(remedy, deps, json) {
424
- const outcome = gateOutcome({ checked: 0, findings: [] }, 'gate');
459
+ function abstain(remedy, deps, json, skipped = []) {
460
+ const result = { checked: 0, findings: [] };
461
+ // Only attach a non-empty list: `skippedSuffix` renders nothing for an empty
462
+ // one, but leaving the key set would put `"skipped": []` in the shape other
463
+ // producers use to mean "nothing was dropped".
464
+ if (skipped.length > 0)
465
+ result.skipped = skipped;
466
+ const outcome = gateOutcome(result, 'gate');
425
467
  if (json) {
426
468
  deps.out(jsonIndent2([]));
427
469
  deps.err(`${outcome.summaryLine} ${remedy}`);
@@ -430,7 +472,7 @@ function abstain(remedy, deps, json) {
430
472
  deps.out(pc.bold(pc.yellow(outcome.summaryLine)));
431
473
  deps.out(` ${remedy}`);
432
474
  }
433
- throw new CliExit(outcome.exitCode);
475
+ throw new CliExitError(outcome.exitCode);
434
476
  }
435
477
  /**
436
478
  * The denominator guard shared by the file-scanning gates (#508 Wave 4a).
@@ -468,6 +510,59 @@ function abstainOnUnlintableFile(path, deps, json) {
468
510
  abstain(`Cannot lint ${ext} — no ruleset parses it, so a clean result would be ` +
469
511
  `meaningless (looked for ${SCANNABLE_DESC}).`, deps, json);
470
512
  }
513
+ /**
514
+ * Run every collected file through `scan`, turning an unreadable one into a
515
+ * skip instead of an escaped exception (#704).
516
+ *
517
+ * The third zero these commands can hit. `abstainOnZeroFiles` guards the
518
+ * COLLECTOR and `abstainOnUnlintableFile` guards the RULESET; neither sees a
519
+ * path that collects fine and then cannot be opened, so `readFileSync` threw
520
+ * straight out of the handler and the CLI printed a raw Node stack. Not false
521
+ * green -- it exited 1 -- but exit 1 means "I found findings", and a run that
522
+ * opened nothing found nothing because it looked at nothing. Same shape
523
+ * `scanVacuity` and `promotionVerdict` already use: catch an errno-coded error,
524
+ * report a zero denominator plus a skip entry naming the cause.
525
+ *
526
+ * A fault with no errno code still throws. Swallowing an unknown one is how a
527
+ * scanner learns to go quiet.
528
+ */
529
+ function sweepFiles(files, scan) {
530
+ const sweep = { findings: [], checked: 0, skipped: [] };
531
+ for (const f of files) {
532
+ try {
533
+ sweep.findings.push(...scan(f));
534
+ sweep.checked += 1;
535
+ }
536
+ catch (e) {
537
+ const code = errnoCode(e);
538
+ if (code === null)
539
+ throw e;
540
+ sweep.skipped.push({ name: f, reason: `could not be read (${code})` });
541
+ }
542
+ }
543
+ return sweep;
544
+ }
545
+ /** Every collected file was unreadable: nothing was inspected, so exit 3. */
546
+ function abstainOnUnreadable(sweep, path, deps, json) {
547
+ if (sweep.checked > 0)
548
+ return;
549
+ abstain(`Nothing under ${path} could be opened, so this is an absent result and ` +
550
+ `not a clean one. Check the path exists and is readable.`, deps, json, sweep.skipped);
551
+ }
552
+ /**
553
+ * D7 for the partial read: a file dropped from a run that still had a
554
+ * denominator stays visible, or "clean" silently covers less than it claims.
555
+ * In `--json` the notice rides stderr so stdout stays a parseable array.
556
+ */
557
+ function reportSkips(sweep, deps, json) {
558
+ if (sweep.skipped.length === 0)
559
+ return;
560
+ const line = `${WARN} Not every collected file was read${skippedSuffix(sweep.skipped)}`;
561
+ if (json)
562
+ deps.err(line);
563
+ else
564
+ deps.out(pc.yellow(line));
565
+ }
471
566
  export function reviewTestCmd(path, opts, deps) {
472
567
  const json = opts.json === true;
473
568
  const files = isDir(path) ? collectTestFiles(path) : [path];
@@ -475,16 +570,17 @@ export function reviewTestCmd(path, opts, deps) {
475
570
  if (!isDir(path) && !opts.framework)
476
571
  abstainOnUnlintableFile(path, deps, json);
477
572
  const linter = deps.makeLinter();
478
- const allFindings = [];
479
- for (const f of files)
480
- allFindings.push(...linter.lint(f, opts.framework));
573
+ const sweep = sweepFiles(files, (f) => linter.lint(f, opts.framework));
574
+ abstainOnUnreadable(sweep, path, deps, json);
575
+ reportSkips(sweep, deps, json);
576
+ const allFindings = sweep.findings;
481
577
  // `--json` renders the machine payload and then falls through to the same
482
578
  // exit-code decision as human mode. It used to `return` here, so a consumer
483
579
  // gating on `$?` saw every finding-bearing run as clean (#566).
484
580
  if (json) {
485
581
  deps.out(jsonIndent2(allFindings.map(findingPayload)));
486
582
  if (allFindings.some((f) => f.severity === 'critical')) {
487
- throw new CliExit(1);
583
+ throw new CliExitError(1);
488
584
  }
489
585
  return;
490
586
  }
@@ -512,7 +608,7 @@ export function reviewTestCmd(path, opts, deps) {
512
608
  parts.push(pc.dim(`${counts['info']} info`));
513
609
  deps.out(`${pc.bold(`${allFindings.length} finding(s):`)} ${parts.join(', ')}`);
514
610
  if (counts['critical'])
515
- throw new CliExit(1);
611
+ throw new CliExitError(1);
516
612
  }
517
613
  export function flakeCheckCmd(path, opts, deps) {
518
614
  const json = opts.json === true;
@@ -521,14 +617,15 @@ export function flakeCheckCmd(path, opts, deps) {
521
617
  if (!isDir(path))
522
618
  abstainOnUnlintableFile(path, deps, json);
523
619
  const linter = deps.makeLinter();
524
- const allFindings = [];
525
- for (const f of files)
526
- allFindings.push(...linter.flakeCheck(f));
620
+ const sweep = sweepFiles(files, (f) => linter.flakeCheck(f));
621
+ abstainOnUnreadable(sweep, path, deps, json);
622
+ reportSkips(sweep, deps, json);
623
+ const allFindings = sweep.findings;
527
624
  // Exit-code parity with human mode, same reason as `review-test` above.
528
625
  if (json) {
529
626
  deps.out(jsonIndent2(allFindings.map(findingPayload)));
530
627
  if (allFindings.length > 0)
531
- throw new CliExit(1);
628
+ throw new CliExitError(1);
532
629
  return;
533
630
  }
534
631
  if (allFindings.length === 0) {
@@ -542,12 +639,119 @@ export function flakeCheckCmd(path, opts, deps) {
542
639
  deps.out(` ${pc.dim(`${ARROW} ${f.suggestion}`)}\n`);
543
640
  }
544
641
  deps.out(pc.bold(`${allFindings.length} flakiness pattern(s) found.`));
545
- throw new CliExit(1);
642
+ throw new CliExitError(1);
643
+ }
644
+ // --- vacuity-check (canary-cassandra, #612) ----------------------------------
645
+ /**
646
+ * The CLI surface of the vacuity scanner.
647
+ *
648
+ * Registered as an **advisory** command, not a gate: this repo's established
649
+ * shape for a brand-new detector is advisory first, ratchet to strict only after
650
+ * triage (the dogfooding jobs, #485). So findings exit 0.
651
+ *
652
+ * The abstention half is NOT advisory, and the asymmetry is the whole #508
653
+ * doctrine. "I found weak tests" is information a repo absorbs over time; "I
654
+ * verified nothing" is a broken instrument, and a vacuity detector that reported
655
+ * its own silence as success would be the exact false-green class it exists to
656
+ * find. Two distinct zeros are guarded: no file matched, and files matched but
657
+ * held no tests -- a scanner that only checked the first prints a clean tick on
658
+ * the second.
659
+ */
660
+ export function vacuityCheckCmd(path, opts, deps) {
661
+ const json = opts.json === true;
662
+ const files = isDir(path) ? collectTestFiles(path) : [path];
663
+ const findings = [];
664
+ const skipped = [];
665
+ let checked = 0;
666
+ for (const f of files) {
667
+ const r = scanVacuity(f);
668
+ checked += r.checked;
669
+ findings.push(...r.findings);
670
+ if (r.skipped)
671
+ skipped.push(...r.skipped);
672
+ }
673
+ if (files.length === 0) {
674
+ skipped.push({
675
+ name: path,
676
+ reason: `no test file matched (looked for ${SCANNABLE_DESC})`,
677
+ });
678
+ }
679
+ const result = { checked, findings };
680
+ if (skipped.length > 0)
681
+ result.skipped = skipped;
682
+ const outcome = gateOutcome(result, 'advisory', { noun: 'test(s)' });
683
+ // `advisory` keeps findings at exit 0; the abstention still has to be loud, so
684
+ // the exit code for a zero denominator is taken from the gate contract.
685
+ const exitCode = outcome.abstained ? EXIT_ABSTAINED : 0;
686
+ if (json) {
687
+ deps.out(jsonIndent2({
688
+ checked,
689
+ abstained: outcome.abstained,
690
+ findings,
691
+ skipped,
692
+ }));
693
+ if (exitCode !== 0)
694
+ throw new CliExitError(exitCode);
695
+ return;
696
+ }
697
+ for (const f of findings) {
698
+ const color = f.severity === 'critical' ? pc.red : pc.yellow;
699
+ const tier = f.fidelity ? pc.dim(` [${f.fidelity}]`) : '';
700
+ deps.out(`${color(`[${f.severity.toUpperCase()}]`)} ${f.file}:${f.line} ${pc.dim(`(${f.rule})`)}${tier}`);
701
+ deps.out(` ${f.test}: ${f.message}`);
702
+ deps.out(` ${pc.dim(`${ARROW} ${f.suggestion}`)}\n`);
703
+ }
704
+ deps.out(outcome.abstained
705
+ ? pc.bold(pc.yellow(outcome.summaryLine))
706
+ : `${pc.bold(outcome.summaryLine)}`);
707
+ if (exitCode !== 0)
708
+ throw new CliExitError(exitCode);
709
+ }
710
+ // --- promote-check (#477) ----------------------------------------------------
711
+ /**
712
+ * The gate `canary-promote-test` Phase 1 consumes.
713
+ *
714
+ * Unlike `vacuity-check` this IS a gate, and deliberately so: it does not scan a
715
+ * repository, it decides whether ONE generated draft may enter the committed
716
+ * suite. A blocking finding there is not a backlog item to triage, it is a
717
+ * reason not to import the draft. Nothing existing turns red -- the command is
718
+ * new and only ever pointed at `tests/generated/`.
719
+ */
720
+ export function promoteCheckCmd(path, opts, deps) {
721
+ const verdict = promotionVerdict(path);
722
+ if (opts.json === true) {
723
+ deps.out(jsonIndent2(verdict));
724
+ if (verdict.exitCode !== 0)
725
+ throw new CliExitError(verdict.exitCode);
726
+ return;
727
+ }
728
+ for (const axis of verdict.axes) {
729
+ for (const f of axis.findings) {
730
+ const blocking = axis.gating && verdict.blocked.includes(f.rule);
731
+ const label = blocking ? pc.red('[BLOCK]') : pc.yellow('[ADVISORY]');
732
+ const tier = f.fidelity ? pc.dim(` [${f.fidelity}]`) : '';
733
+ deps.out(`${label} ${verdict.file}:${f.line} ${pc.dim(`(${f.rule}/${axis.axis})`)}${tier}`);
734
+ deps.out(` ${f.message}`);
735
+ deps.out(` ${pc.dim(`${ARROW} ${f.suggestion}`)}\n`);
736
+ }
737
+ }
738
+ // No glyph on `abstain`: `gateOutcome` already opens its summary line with the
739
+ // warning sign, and two in a row read as a rendering bug rather than emphasis.
740
+ const banner = {
741
+ promote: pc.green(`${CHECK_MARK} PROMOTE`),
742
+ block: pc.red(`${REDX} BLOCK`),
743
+ abstain: pc.yellow('ABSTAIN'),
744
+ };
745
+ deps.out(`${pc.bold(banner[verdict.decision])} ${EM_DASH} ${verdict.summaryLine}`);
746
+ if (verdict.remedy)
747
+ deps.out(` ${verdict.remedy}`);
748
+ if (verdict.exitCode !== 0)
749
+ throw new CliExitError(verdict.exitCode);
546
750
  }
547
751
  export function healTestCmd(path, opts, deps) {
548
752
  if (!isFile(path)) {
549
753
  deps.out(pc.red(`Error: ${path} is not a file.`));
550
- throw new CliExit(1);
754
+ throw new CliExitError(1);
551
755
  }
552
756
  const result = deps.makeHealer().heal(path);
553
757
  if (opts.json) {
@@ -631,17 +835,21 @@ export function upgradeCmd(opts, deps) {
631
835
  }
632
836
  else {
633
837
  deps.out(`${pc.red('Upgrade failed.')}\n${pip.stderr.trim()}`);
634
- throw new CliExit(1);
838
+ throw new CliExitError(1);
635
839
  }
636
840
  }
637
- // --- overlay / doctor (npm-shim pointers) ------------------------------------
638
- export function overlayStub(deps) {
841
+ // --- overlay / doctor / uninstall (npm-shim pointers) ------------------------
842
+ export function overlayCmd(deps) {
639
843
  deps.out(`${pc.yellow('`canary overlay` is provided by the npm install of Canary.')}\nInstall it with: ${pc.bold('npm install -g canary-test-cli')}\nThe pipx/Python entry point does not include the overlay commands.`);
640
- throw new CliExit(1);
844
+ throw new CliExitError(1);
641
845
  }
642
- export function doctorStub(deps) {
846
+ export function doctorCmd(deps) {
643
847
  deps.out(`${pc.yellow('`canary doctor` is provided by the npm install of Canary.')}\nInstall it with: ${pc.bold('npm install -g canary-test-cli')}\nThe pipx/Python entry point does not include the doctor command.`);
644
- throw new CliExit(1);
848
+ throw new CliExitError(1);
849
+ }
850
+ export function uninstallCmd(deps) {
851
+ deps.out(`${pc.yellow('`canary uninstall` is provided by the npm install of Canary.')}\nInstall it with: ${pc.bold('npm install -g canary-test-cli')}\nThe pipx/Python entry point does not include the uninstall command.`);
852
+ throw new CliExitError(1);
645
853
  }
646
854
  export async function ticketUpdateCmd(opts, deps) {
647
855
  let reportData = {};
@@ -651,7 +859,7 @@ export async function ticketUpdateCmd(opts, deps) {
651
859
  }
652
860
  catch (exc) {
653
861
  deps.out(pc.red(`Could not read result file '${opts.result}': ${exc instanceof Error ? exc.message : String(exc)}`));
654
- throw new CliExit(1);
862
+ throw new CliExitError(1);
655
863
  }
656
864
  }
657
865
  const testFile = opts.testFile;
@@ -702,7 +910,7 @@ export async function ticketUpdateCmd(opts, deps) {
702
910
  }
703
911
  }
704
912
  if (updateResult.transition.reason.startsWith(WARN)) {
705
- throw new CliExit(1);
913
+ throw new CliExitError(1);
706
914
  }
707
915
  }
708
916
  /** Python string `<`/`>` comparison (stable code-unit order). */
@@ -5,21 +5,26 @@
5
5
  *
6
6
  * The three concerns every command tree needs:
7
7
  *
8
- * - {@link CliExit} -- the business-exit signal thrown from a handler (Python
9
- * `typer.Exit(code)`). Re-exported from `guardian/cli.ts` so the ENTIRE
10
- * command tree (main program, its sub-apps, AND the already-ported guardian
11
- * sub-app it mounts) throws ONE class. `bin/canary.js` then catches a single
12
- * `CliExit` regardless of which sub-app raised it.
8
+ * - {@link CliExitError} -- the business-exit signal thrown from a handler
9
+ * (Python `typer.Exit(code)`). Re-exported from `guardian/cli.ts` so the
10
+ * ENTIRE command tree (main program, its sub-apps, AND the already-ported
11
+ * guardian sub-app it mounts) throws ONE class. `bin/canary.js` catches one
12
+ * `CliExitError` regardless of which sub-app raised it.
13
13
  * - {@link normalizeUsageExit} -- the `.exitOverride()` callback that maps
14
14
  * commander's usage-error exit code (1) to typer/click's 2, while leaving an
15
15
  * explicit `--help`/`--version` at 0. Applied to the program AND every
16
16
  * subcommand.
17
- * - {@link ensureAscii} / {@link jsonIndent2} -- Python `json.dumps` fidelity:
18
- * `ensure_ascii=True` (per-UTF-16-unit escaping, so astral chars emit a
19
- * surrogate pair exactly like CPython) and the `indent=2` pretty form (whose
20
- * separators match `JSON.stringify(x, null, 2)` byte-for-byte).
17
+ * - {@link jsonIndent2} -- Python `json.dumps(x, indent=2)` fidelity: the
18
+ * separators match `JSON.stringify(x, null, 2)` byte-for-byte, and the
19
+ * `ensure_ascii=True` default is restored by `ensureAscii`.
20
+ *
21
+ * `ensureAscii` itself used to be declared and exported here, and was imported
22
+ * by nobody while eight modules carried private copies (#710). It now lives in
23
+ * `util/ensure-ascii.ts` -- the one layer `core`, `guardian`, and the entry
24
+ * modules are all permitted to depend on, which `cli` is not.
21
25
  */
22
- export { CliExit } from './guardian/cli.js';
26
+ import { ensureAscii } from './util/ensure-ascii.js';
27
+ export { CliExitError } from './guardian/cli.js';
23
28
  /**
24
29
  * Map commander's usage-error exit code to typer/click's `2`. Commander defaults
25
30
  * unknown/missing-option, bad-command and no-args-help errors to exit 1; typer
@@ -33,20 +38,6 @@ export function normalizeUsageExit(err) {
33
38
  }
34
39
  throw err;
35
40
  }
36
- /**
37
- * Escape every non-ASCII UTF-16 code UNIT to `\uXXXX`, matching Python
38
- * `json.dumps(ensure_ascii=True)`. Iterating by unit (not code point) means an
39
- * astral char's surrogate pair emits `\udXXX\udXXX`, exactly like CPython; a
40
- * code-point regex would stop at U+FFFF and leave astral chars raw.
41
- */
42
- export function ensureAscii(json) {
43
- let out = '';
44
- for (let i = 0; i < json.length; i++) {
45
- const c = json.charCodeAt(i);
46
- out += c >= 0x80 ? '\\u' + c.toString(16).padStart(4, '0') : json[i];
47
- }
48
- return out;
49
- }
50
41
  /**
51
42
  * `json.dumps(x, indent=2)` fidelity: `JSON.stringify(x, null, 2)` reproduces
52
43
  * Python's 2-space indent and `(',', ': ')` separators byte-for-byte (including
@@ -7,8 +7,8 @@
7
7
  *
8
8
  * Conventions follow `guardian/cli.ts` (see `cli-common.ts`): a
9
9
  * {@link createCanaryCommand} factory wired to an injectable {@link MainDeps},
10
- * `CliExit` for business exits, `normalizeUsageExit` on the program AND every
11
- * subcommand so usage errors exit 2 (typer/click) not commander's default 1, and
10
+ * `CliExitError` for business exits, `normalizeUsageExit` on the program AND
11
+ * every subcommand so usage errors exit 2 (typer/click) not commander's 1, and
12
12
  * an eager `-V/--version` global option + `_main` callback analog.
13
13
  *
14
14
  * Sub-app mounting note: `guardian`/`history`/`analyze` are mounted as FRESH
@@ -17,9 +17,9 @@
17
17
  * mount into multiple `createCanaryCommand()` calls, e.g. across tests).
18
18
  */
19
19
  import { Command, Option } from 'commander';
20
- import { CliExit, normalizeUsageExit } from './cli-common.js';
20
+ import { CliExitError, normalizeUsageExit } from './cli-common.js';
21
21
  import { createAnalyzeCommand } from './analysis/cli.js';
22
- import { doctorStub, feedbackCmd, flakeCheckCmd, frameworksCmd, healTestCmd, initCmd, migrateCmd, overlayStub, recommendCmd, reviewTestCmd, runCmd, setupCmd, ticketUpdateCmd, upgradeCmd, versionCmd, } from './cli-commands.js';
22
+ import { doctorCmd, feedbackCmd, flakeCheckCmd, listFrameworksCmd, healTestCmd, initCmd, migrateCmd, overlayCmd, promoteCheckCmd, recommendFrameworkCmd, reviewTestCmd, runCmd, setupCmd, ticketUpdateCmd, uninstallCmd, upgradeCmd, vacuityCheckCmd, versionCmd, } from './cli-commands.js';
23
23
  import { buildCompanyKnowledgeCommand } from './company-knowledge-cli.js';
24
24
  import { createGuardianCommand } from './guardian/cli.js';
25
25
  import { createHistoryCommand } from './history/cli.js';
@@ -38,18 +38,18 @@ export function createCanaryCommand(depsInit = {}) {
38
38
  .hook('preAction', () => {
39
39
  if (program.opts()['version']) {
40
40
  versionCmd(deps);
41
- throw new CliExit(0);
41
+ throw new CliExitError(0);
42
42
  }
43
43
  })
44
44
  .action(() => {
45
45
  if (program.opts()['version']) {
46
46
  versionCmd(deps);
47
- throw new CliExit(0);
47
+ throw new CliExitError(0);
48
48
  }
49
49
  // Python `typer.Typer(no_args_is_help=True)` prints help and exits 2 on a
50
50
  // bare `canary` invocation (a usage exit), NOT 0.
51
51
  program.outputHelp();
52
- throw new CliExit(2);
52
+ throw new CliExitError(2);
53
53
  });
54
54
  program
55
55
  .command('recommend')
@@ -57,14 +57,14 @@ export function createCanaryCommand(depsInit = {}) {
57
57
  .argument('<prompt>')
58
58
  .option('--json', 'Output as JSON for tool integration.')
59
59
  .action((prompt, opts) => {
60
- recommendCmd(prompt, opts, deps);
60
+ recommendFrameworkCmd(prompt, opts, deps);
61
61
  });
62
62
  program
63
63
  .command('frameworks')
64
64
  .description('List the supported testing frameworks and how to run each.')
65
65
  .option('--json', 'Dump the registry as JSON for tool integration.')
66
66
  .action((opts) => {
67
- frameworksCmd(opts, deps);
67
+ listFrameworksCmd(opts, deps);
68
68
  });
69
69
  program
70
70
  .command('feedback')
@@ -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('--adoption-report', 'Report which adoption pieces (company.json, shape, overlay, skills, manifest, workflows) are present or missing in this repo. Writes nothing. Exits 0 fully adopted, 1 pieces missing, 3 nothing verifiable.')
110
111
  .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.')
111
112
  .option('--json', 'Emit the report as JSON.')
112
113
  .action((opts) => {
@@ -131,6 +132,22 @@ export function createCanaryCommand(depsInit = {}) {
131
132
  .action((path, opts) => {
132
133
  flakeCheckCmd(path, opts, deps);
133
134
  });
135
+ program
136
+ .command('promote-check')
137
+ .description('Decide whether a generated test may be promoted into the committed suite.')
138
+ .argument('<path>', 'The generated test file to judge.')
139
+ .option('--json', 'Emit the structured verdict as JSON.')
140
+ .action((path, opts) => {
141
+ promoteCheckCmd(path, opts, deps);
142
+ });
143
+ program
144
+ .command('vacuity-check')
145
+ .description('Find tests that pass without proving anything -- advisory, no LLM required.')
146
+ .argument('<path>', 'Test file or directory to scan.')
147
+ .option('--json', 'Output the verdict and its denominator as JSON.')
148
+ .action((path, opts) => {
149
+ vacuityCheckCmd(path, opts, deps);
150
+ });
134
151
  program
135
152
  .command('heal-test')
136
153
  .description('Apply deterministic pattern fixes to a test file -- no LLM required.')
@@ -162,7 +179,7 @@ export function createCanaryCommand(depsInit = {}) {
162
179
  .allowExcessArguments(true)
163
180
  .argument('[args...]')
164
181
  .action(() => {
165
- overlayStub(deps);
182
+ overlayCmd(deps);
166
183
  });
167
184
  program
168
185
  .command('doctor')
@@ -171,7 +188,16 @@ export function createCanaryCommand(depsInit = {}) {
171
188
  .allowExcessArguments(true)
172
189
  .argument('[args...]')
173
190
  .action(() => {
174
- doctorStub(deps);
191
+ doctorCmd(deps);
192
+ });
193
+ program
194
+ .command('uninstall')
195
+ .description('Remove installed Canary artifacts (requires the npm install of Canary).')
196
+ .allowUnknownOption(true)
197
+ .allowExcessArguments(true)
198
+ .argument('[args...]')
199
+ .action(() => {
200
+ uninstallCmd(deps);
175
201
  });
176
202
  program
177
203
  .command('ticket-update')
@@ -7,7 +7,7 @@ import { createRequire } from 'node:module';
7
7
  import { CommanderError } from 'commander';
8
8
 
9
9
  import { createCanaryCommand } from './cli.core.js';
10
- import { CliExit } from './cli-common.js';
10
+ import { CliExitError } from './cli-common.js';
11
11
 
12
12
  const require = createRequire(import.meta.url);
13
13
 
@@ -24,7 +24,7 @@ const program = createCanaryCommand({ pkgVersion: () => readVersion() });
24
24
  try {
25
25
  await program.parseAsync(process.argv.slice(2), { from: 'user' });
26
26
  } catch (err) {
27
- if (err instanceof CliExit) process.exit(err.code);
27
+ if (err instanceof CliExitError) process.exit(err.code);
28
28
  if (err instanceof CommanderError) process.exit(err.exitCode);
29
29
  console.error(err);
30
30
  process.exit(1);