canary-test-cli 7.0.0 → 7.2.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 (122) hide show
  1. package/agents/skills/README.md +308 -0
  2. package/agents/skills/canary:generate.md +49 -0
  3. package/agents/skills/canary:init.md +37 -0
  4. package/agents/skills/canary:migrate.md +66 -0
  5. package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
  6. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  7. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  11. package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
  12. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  14. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
  15. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  16. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  17. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  18. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  19. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  21. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  22. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  27. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  28. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  29. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
  30. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  31. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  32. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  37. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  38. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  42. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  44. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  45. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  46. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  52. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  53. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  54. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  55. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  56. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  57. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  58. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  59. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  60. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  61. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  62. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  63. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  64. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  65. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  66. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  67. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  68. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  69. package/agents/skills/lib/parse-args.mjs +214 -0
  70. package/dist/engine/analysis/cli.js +116 -54
  71. package/dist/engine/analysis/engine.js +34 -16
  72. package/dist/engine/analysis/reports.js +5 -4
  73. package/dist/engine/cli-commands.js +252 -106
  74. package/dist/engine/cli-common.js +15 -24
  75. package/dist/engine/cli.core.js +37 -11
  76. package/dist/engine/cli.js +2 -2
  77. package/dist/engine/company-knowledge-cli.js +2 -2
  78. package/dist/engine/core/adoption.js +408 -0
  79. package/dist/engine/core/framework-probes.js +7 -7
  80. package/dist/engine/core/fs-glob.js +2 -2
  81. package/dist/engine/core/gate-result.js +17 -0
  82. package/dist/engine/core/migrator.js +9 -17
  83. package/dist/engine/core/pattern-matcher.js +23 -5
  84. package/dist/engine/core/persona.js +421 -0
  85. package/dist/engine/core/promotion-verdict.js +261 -0
  86. package/dist/engine/core/quality-scorer.js +15 -2
  87. package/dist/engine/core/reporter.js +1 -9
  88. package/dist/engine/core/skill-dispatch.js +115 -0
  89. package/dist/engine/core/skill-examples.js +392 -0
  90. package/dist/engine/core/skill-registry.js +59 -4
  91. package/dist/engine/core/skill-surfaces.js +307 -0
  92. package/dist/engine/core/static-linter.js +310 -38
  93. package/dist/engine/core/test-files.js +77 -0
  94. package/dist/engine/core/ticket-updater.js +1 -7
  95. package/dist/engine/core/vacuity-scanner.js +726 -0
  96. package/dist/engine/core/workflow-discovery.js +2 -8
  97. package/dist/engine/core/workspace-detect.js +7 -6
  98. package/dist/engine/data/personas/registry.json +36 -0
  99. package/dist/engine/guardian/adjudication.js +5 -5
  100. package/dist/engine/guardian/analysis-emit.js +19 -28
  101. package/dist/engine/guardian/cli.js +150 -51
  102. package/dist/engine/guardian/coverage.js +1 -1
  103. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
  104. package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
  105. package/dist/engine/guardian/pr-check.js +322 -33
  106. package/dist/engine/guardian/pr-comment.js +4 -3
  107. package/dist/engine/history/cli.js +210 -6
  108. package/dist/engine/history/ndjson-store.js +9 -5
  109. package/dist/engine/history/record.js +34 -5
  110. package/dist/engine/history/run-recorder.js +165 -0
  111. package/dist/engine/history/schema.js +25 -7
  112. package/dist/engine/history/store.js +9 -0
  113. package/dist/engine/mcp-server.js +35 -13
  114. package/dist/engine/skills-cli.js +249 -23
  115. package/dist/engine/util/ensure-ascii.js +37 -0
  116. package/dist/engine/workflow-cli.js +6 -6
  117. package/dist/gate-result.d.ts +11 -0
  118. package/dist/gate-result.js +18 -0
  119. package/dist/reporters/testtracker.d.ts +1 -1
  120. package/dist/reporters/testtracker.js +1 -1
  121. package/dist/uninstall.js +12 -5
  122. package/package.json +3 -2
@@ -16,7 +16,7 @@ export { round1 };
16
16
  // ---------------------------------------------------------------------------
17
17
  // Flaky report
18
18
  // ---------------------------------------------------------------------------
19
- export function buildFlakyReport(rows, windowRuns, minRatePct, limit = 20) {
19
+ export function buildFlakyTestsReport(rows, windowRuns, minRatePct, limit = 20) {
20
20
  if (rows.length === 0) {
21
21
  return `No tests above ${pyFloat(minRatePct)}% flake rate in the last ${windowRuns} runs.\n`;
22
22
  }
@@ -75,7 +75,7 @@ function detectSpikes(rows, deltaPp) {
75
75
  }
76
76
  return spikes;
77
77
  }
78
- export function buildSpikesReport(rows, deltaPp) {
78
+ export function buildFailureSpikesReport(rows, deltaPp) {
79
79
  if (rows.length === 0) {
80
80
  return 'No run data available for spike detection.\n';
81
81
  }
@@ -210,8 +210,9 @@ function regressionRow(r) {
210
210
  export function buildDigest(args) {
211
211
  const sections = [
212
212
  '# Fleet Health Digest\n',
213
- '## Flaky Tests\n\n' + buildFlakyReport(args.flaky, args.windowRuns, 10.0),
214
- '## Spikes\n\n' + buildSpikesReport(args.spikes, args.deltaPp),
213
+ '## Flaky Tests\n\n' +
214
+ buildFlakyTestsReport(args.flaky, args.windowRuns, 10.0),
215
+ '## Spikes\n\n' + buildFailureSpikesReport(args.spikes, args.deltaPp),
215
216
  '## Area Health\n\n' + buildAreaHealthReport(args.areaHealth, args.weeks),
216
217
  '## Common Failures\n\n' +
217
218
  buildCommonFailuresReport(args.commonFailures, args.minSuites),
@@ -5,21 +5,25 @@
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.
12
12
  */
13
- import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
13
+ import { existsSync, 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';
21
24
  import { OverlayNotFound, listOverlays, resolveOverlay, } from './core/overlays.js';
22
- import { JS_TEST_EXTENSIONS, frameworkForPath } from './core/static-linter.js';
25
+ import { frameworkForPath } from './core/static-linter.js';
26
+ import { SCANNABLE_DESC, collectTestFiles, isDir } from './core/test-files.js';
23
27
  import { RunSummary } from './core/ticket-updater.js';
24
28
  import { renderBanner } from './ui/banner.js';
25
29
  import { ARROW, CHECK, CHECK_MARK, CROSS, EM_DASH, HAMMER, NEXT, REDX, ROCKET, WARN, WRENCH, } from './main-deps.js';
@@ -39,68 +43,7 @@ function isFile(p) {
39
43
  return false;
40
44
  }
41
45
  }
42
- function isDir(p) {
43
- try {
44
- return statSync(p).isDirectory();
45
- }
46
- catch {
47
- return false;
48
- }
49
- }
50
- /**
51
- * Directories never worth walking. A dependency's own test suite is not the
52
- * consumer's to fix: before #566, `node_modules` accounted for 254 of 256
53
- * findings in one downstream run, and the only `critical` sat inside vendored
54
- * code. `pattern-matcher.ts` has carried this set since the Python port; this
55
- * walk was the copy that never got it.
56
- */
57
- const IGNORED_DIRS = new Set([
58
- 'node_modules',
59
- '.git',
60
- '__pycache__',
61
- '.venv',
62
- 'venv',
63
- 'dist',
64
- 'build',
65
- '.next',
66
- '.nuxt',
67
- ]);
68
- function walkFiles(dir) {
69
- const out = [];
70
- let entries;
71
- try {
72
- entries = readdirSync(dir, { withFileTypes: true });
73
- }
74
- catch {
75
- return out;
76
- }
77
- for (const e of entries) {
78
- const full = join(dir, e.name);
79
- if (e.isDirectory()) {
80
- if (!IGNORED_DIRS.has(e.name))
81
- out.push(...walkFiles(full));
82
- }
83
- else if (e.isFile())
84
- out.push(full);
85
- }
86
- return out;
87
- }
88
- /**
89
- * `test_*.py` plus `*.test.*` / `*.spec.*` over every extension the scanners
90
- * can actually read -- `.mjs` and `.cjs` included, which is the half of #566
91
- * that made a directory of ESM tests collect zero files.
92
- */
93
- const JS_TEST_FILE_RE = new RegExp(`\\.(test|spec)\\.(${JS_TEST_EXTENSIONS.map((e) => e.slice(1)).join('|')})$`);
94
- /** Recursive test-file glob matching Python's `rglob` union, sorted by path. */
95
- function collectTestFiles(dir) {
96
- return walkFiles(dir)
97
- .filter((p) => {
98
- const b = basename(p);
99
- return ((b.startsWith('test_') && b.endsWith('.py')) || JS_TEST_FILE_RE.test(b));
100
- })
101
- .sort();
102
- }
103
- export function recommendCmd(promptText, opts, deps) {
46
+ export function recommendFrameworkCmd(promptText, opts, deps) {
104
47
  const classifier = deps.makeClassifier();
105
48
  const recommender = deps.makeRecommender();
106
49
  const classification = classifier.classify(promptText);
@@ -149,7 +92,7 @@ const TIER_STYLE = {
149
92
  executable: pc.cyan,
150
93
  catalog: pc.yellow,
151
94
  };
152
- export function frameworksCmd(opts, deps) {
95
+ export function listFrameworksCmd(opts, deps) {
153
96
  const summaries = deps.makeRegistry().summaries();
154
97
  if (opts.json) {
155
98
  deps.out(jsonIndent2({ frameworks: summaries }));
@@ -173,11 +116,11 @@ export function frameworksCmd(opts, deps) {
173
116
  export function feedbackCmd(message, opts, deps) {
174
117
  if (!VALID_CATEGORIES.includes(opts.category)) {
175
118
  deps.out(`${pc.bold(pc.red(CROSS))} Unknown --category '${opts.category}'. Choose one of: ${VALID_CATEGORIES.join(', ')}.`);
176
- throw new CliExit(1);
119
+ throw new CliExitError(1);
177
120
  }
178
121
  if (!message || !message.trim()) {
179
122
  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);
123
+ throw new CliExitError(1);
181
124
  }
182
125
  const fb = buildFeedback(message.trim(), opts.category, resolveVersion(deps));
183
126
  if (opts.json) {
@@ -290,7 +233,7 @@ function resolveMigrateOverlay(fromOverlay, overlay, deps) {
290
233
  catch (e) {
291
234
  if (e instanceof OverlayNotFound) {
292
235
  deps.out(`\n${pc.bold(pc.red(CROSS))} ${e.message}`);
293
- throw new CliExit(1);
236
+ throw new CliExitError(1);
294
237
  }
295
238
  throw e;
296
239
  }
@@ -307,14 +250,33 @@ function resolveMigrateOverlay(fromOverlay, overlay, deps) {
307
250
  if (tracked.length > 1) {
308
251
  const names = tracked.join(', ');
309
252
  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);
253
+ throw new CliExitError(1);
311
254
  }
312
255
  return null;
313
256
  }
257
+ /**
258
+ * `--adoption-report`: which adoption pieces are in place here (#459).
259
+ *
260
+ * Read-only, and unlike `--check` it runs WITHOUT an overlay: "no overlay is
261
+ * tracked" is itself one of the answers a half-adopted repo needs, so refusing
262
+ * to report until one exists would hide the most common gap.
263
+ */
264
+ function migrateAdoption(migrator, root, overlayPath, json, deps) {
265
+ let report;
266
+ try {
267
+ report = gatherAdoptionReport(migrator, root, overlayPath, deps.home());
268
+ }
269
+ catch (e) {
270
+ deps.out(`\n${pc.bold(pc.red(CROSS))} ${e instanceof Error ? e.message : String(e)}`);
271
+ throw new CliExitError(1);
272
+ }
273
+ deps.out(json ? jsonIndent2(report.to_dict()) : report.to_markdown());
274
+ throw new CliExitError(report.exit_code());
275
+ }
314
276
  function migrateCheck(migrator, root, overlayPath, json, deps) {
315
277
  if (overlayPath === null) {
316
278
  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);
279
+ throw new CliExitError(0);
318
280
  }
319
281
  let report;
320
282
  try {
@@ -322,7 +284,7 @@ function migrateCheck(migrator, root, overlayPath, json, deps) {
322
284
  }
323
285
  catch (e) {
324
286
  deps.out(`\n${pc.bold(pc.red(CROSS))} ${e instanceof Error ? e.message : String(e)}`);
325
- throw new CliExit(1);
287
+ throw new CliExitError(1);
326
288
  }
327
289
  if (json) {
328
290
  deps.out(jsonIndent2(report.to_dict()));
@@ -330,23 +292,37 @@ function migrateCheck(migrator, root, overlayPath, json, deps) {
330
292
  else {
331
293
  deps.out(report.to_markdown());
332
294
  }
333
- throw new CliExit(report.exit_code());
295
+ throw new CliExitError(report.exit_code());
296
+ }
297
+ /**
298
+ * Dispatch the report-only modes, which each exit via `CliExitError`.
299
+ *
300
+ * One gate for both so `migrateCmd` carries a single "does this run write?"
301
+ * branch: a second report mode added inline is how the writing path acquires an
302
+ * accidental fall-through.
303
+ */
304
+ function migrateReportOnly(opts, migrator, root, overlayPath, deps) {
305
+ const json = opts.json ?? false;
306
+ if (opts.adoptionReport) {
307
+ migrateAdoption(migrator, root, overlayPath, json, deps);
308
+ }
309
+ if (opts.check) {
310
+ migrateCheck(migrator, root, overlayPath, json, deps);
311
+ }
334
312
  }
335
313
  export function migrateCmd(opts, deps) {
336
314
  const root = resolve(opts.path);
337
315
  const overlayPath = resolveMigrateOverlay(opts.from, opts.overlay, deps);
338
316
  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
- }
317
+ // Both report-only modes exit here; neither ever reaches the writing path.
318
+ migrateReportOnly(opts, migrator, root, overlayPath, deps);
343
319
  let ctx;
344
320
  try {
345
321
  ctx = migrator.detect(root);
346
322
  }
347
323
  catch (e) {
348
324
  deps.out(`\n${pc.bold(pc.red('Detection error:'))} ${e instanceof Error ? e.message : String(e)}`);
349
- throw new CliExit(1);
325
+ throw new CliExitError(1);
350
326
  }
351
327
  if (!ctx.is_harness_project) {
352
328
  if (ctx.not_test_project_reason) {
@@ -355,7 +331,7 @@ export function migrateCmd(opts, deps) {
355
331
  else {
356
332
  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
333
  }
358
- throw new CliExit(1);
334
+ throw new CliExitError(1);
359
335
  }
360
336
  const dryRun = !opts.apply;
361
337
  const modeLabel = dryRun ? pc.dim('(dry run)') : pc.green('(apply)');
@@ -373,7 +349,7 @@ export function migrateCmd(opts, deps) {
373
349
  }
374
350
  catch (e) {
375
351
  deps.out(`\n${pc.bold(pc.red('Error:'))} ${e instanceof Error ? e.message : String(e)}`);
376
- throw new CliExit(1);
352
+ throw new CliExitError(1);
377
353
  }
378
354
  if (opts.json) {
379
355
  deps.out(jsonIndent2({
@@ -417,11 +393,15 @@ function findingPayload(f) {
417
393
  suggestion: f.suggestion,
418
394
  };
419
395
  }
420
- /** Human-readable list of what the collectors look for, for remedy text. */
421
- const SCANNABLE_DESC = `test_*.py, *.test|spec.{${JS_TEST_EXTENSIONS.map((e) => e.slice(1)).join(',')}}`;
422
396
  /** 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');
397
+ function abstain(remedy, deps, json, skipped = []) {
398
+ const result = { checked: 0, findings: [] };
399
+ // Only attach a non-empty list: `skippedSuffix` renders nothing for an empty
400
+ // one, but leaving the key set would put `"skipped": []` in the shape other
401
+ // producers use to mean "nothing was dropped".
402
+ if (skipped.length > 0)
403
+ result.skipped = skipped;
404
+ const outcome = gateOutcome(result, 'gate');
425
405
  if (json) {
426
406
  deps.out(jsonIndent2([]));
427
407
  deps.err(`${outcome.summaryLine} ${remedy}`);
@@ -430,7 +410,7 @@ function abstain(remedy, deps, json) {
430
410
  deps.out(pc.bold(pc.yellow(outcome.summaryLine)));
431
411
  deps.out(` ${remedy}`);
432
412
  }
433
- throw new CliExit(outcome.exitCode);
413
+ throw new CliExitError(outcome.exitCode);
434
414
  }
435
415
  /**
436
416
  * The denominator guard shared by the file-scanning gates (#508 Wave 4a).
@@ -468,6 +448,59 @@ function abstainOnUnlintableFile(path, deps, json) {
468
448
  abstain(`Cannot lint ${ext} — no ruleset parses it, so a clean result would be ` +
469
449
  `meaningless (looked for ${SCANNABLE_DESC}).`, deps, json);
470
450
  }
451
+ /**
452
+ * Run every collected file through `scan`, turning an unreadable one into a
453
+ * skip instead of an escaped exception (#704).
454
+ *
455
+ * The third zero these commands can hit. `abstainOnZeroFiles` guards the
456
+ * COLLECTOR and `abstainOnUnlintableFile` guards the RULESET; neither sees a
457
+ * path that collects fine and then cannot be opened, so `readFileSync` threw
458
+ * straight out of the handler and the CLI printed a raw Node stack. Not false
459
+ * green -- it exited 1 -- but exit 1 means "I found findings", and a run that
460
+ * opened nothing found nothing because it looked at nothing. Same shape
461
+ * `scanVacuity` and `promotionVerdict` already use: catch an errno-coded error,
462
+ * report a zero denominator plus a skip entry naming the cause.
463
+ *
464
+ * A fault with no errno code still throws. Swallowing an unknown one is how a
465
+ * scanner learns to go quiet.
466
+ */
467
+ function sweepFiles(files, scan) {
468
+ const sweep = { findings: [], checked: 0, skipped: [] };
469
+ for (const f of files) {
470
+ try {
471
+ sweep.findings.push(...scan(f));
472
+ sweep.checked += 1;
473
+ }
474
+ catch (e) {
475
+ const code = errnoCode(e);
476
+ if (code === null)
477
+ throw e;
478
+ sweep.skipped.push({ name: f, reason: `could not be read (${code})` });
479
+ }
480
+ }
481
+ return sweep;
482
+ }
483
+ /** Every collected file was unreadable: nothing was inspected, so exit 3. */
484
+ function abstainOnUnreadable(sweep, path, deps, json) {
485
+ if (sweep.checked > 0)
486
+ return;
487
+ abstain(`Nothing under ${path} could be opened, so this is an absent result and ` +
488
+ `not a clean one. Check the path exists and is readable.`, deps, json, sweep.skipped);
489
+ }
490
+ /**
491
+ * D7 for the partial read: a file dropped from a run that still had a
492
+ * denominator stays visible, or "clean" silently covers less than it claims.
493
+ * In `--json` the notice rides stderr so stdout stays a parseable array.
494
+ */
495
+ function reportSkips(sweep, deps, json) {
496
+ if (sweep.skipped.length === 0)
497
+ return;
498
+ const line = `${WARN} Not every collected file was read${skippedSuffix(sweep.skipped)}`;
499
+ if (json)
500
+ deps.err(line);
501
+ else
502
+ deps.out(pc.yellow(line));
503
+ }
471
504
  export function reviewTestCmd(path, opts, deps) {
472
505
  const json = opts.json === true;
473
506
  const files = isDir(path) ? collectTestFiles(path) : [path];
@@ -475,16 +508,17 @@ export function reviewTestCmd(path, opts, deps) {
475
508
  if (!isDir(path) && !opts.framework)
476
509
  abstainOnUnlintableFile(path, deps, json);
477
510
  const linter = deps.makeLinter();
478
- const allFindings = [];
479
- for (const f of files)
480
- allFindings.push(...linter.lint(f, opts.framework));
511
+ const sweep = sweepFiles(files, (f) => linter.lint(f, opts.framework));
512
+ abstainOnUnreadable(sweep, path, deps, json);
513
+ reportSkips(sweep, deps, json);
514
+ const allFindings = sweep.findings;
481
515
  // `--json` renders the machine payload and then falls through to the same
482
516
  // exit-code decision as human mode. It used to `return` here, so a consumer
483
517
  // gating on `$?` saw every finding-bearing run as clean (#566).
484
518
  if (json) {
485
519
  deps.out(jsonIndent2(allFindings.map(findingPayload)));
486
520
  if (allFindings.some((f) => f.severity === 'critical')) {
487
- throw new CliExit(1);
521
+ throw new CliExitError(1);
488
522
  }
489
523
  return;
490
524
  }
@@ -512,7 +546,7 @@ export function reviewTestCmd(path, opts, deps) {
512
546
  parts.push(pc.dim(`${counts['info']} info`));
513
547
  deps.out(`${pc.bold(`${allFindings.length} finding(s):`)} ${parts.join(', ')}`);
514
548
  if (counts['critical'])
515
- throw new CliExit(1);
549
+ throw new CliExitError(1);
516
550
  }
517
551
  export function flakeCheckCmd(path, opts, deps) {
518
552
  const json = opts.json === true;
@@ -521,14 +555,15 @@ export function flakeCheckCmd(path, opts, deps) {
521
555
  if (!isDir(path))
522
556
  abstainOnUnlintableFile(path, deps, json);
523
557
  const linter = deps.makeLinter();
524
- const allFindings = [];
525
- for (const f of files)
526
- allFindings.push(...linter.flakeCheck(f));
558
+ const sweep = sweepFiles(files, (f) => linter.flakeCheck(f));
559
+ abstainOnUnreadable(sweep, path, deps, json);
560
+ reportSkips(sweep, deps, json);
561
+ const allFindings = sweep.findings;
527
562
  // Exit-code parity with human mode, same reason as `review-test` above.
528
563
  if (json) {
529
564
  deps.out(jsonIndent2(allFindings.map(findingPayload)));
530
565
  if (allFindings.length > 0)
531
- throw new CliExit(1);
566
+ throw new CliExitError(1);
532
567
  return;
533
568
  }
534
569
  if (allFindings.length === 0) {
@@ -542,12 +577,119 @@ export function flakeCheckCmd(path, opts, deps) {
542
577
  deps.out(` ${pc.dim(`${ARROW} ${f.suggestion}`)}\n`);
543
578
  }
544
579
  deps.out(pc.bold(`${allFindings.length} flakiness pattern(s) found.`));
545
- throw new CliExit(1);
580
+ throw new CliExitError(1);
581
+ }
582
+ // --- vacuity-check (canary-cassandra, #612) ----------------------------------
583
+ /**
584
+ * The CLI surface of the vacuity scanner.
585
+ *
586
+ * Registered as an **advisory** command, not a gate: this repo's established
587
+ * shape for a brand-new detector is advisory first, ratchet to strict only after
588
+ * triage (the dogfooding jobs, #485). So findings exit 0.
589
+ *
590
+ * The abstention half is NOT advisory, and the asymmetry is the whole #508
591
+ * doctrine. "I found weak tests" is information a repo absorbs over time; "I
592
+ * verified nothing" is a broken instrument, and a vacuity detector that reported
593
+ * its own silence as success would be the exact false-green class it exists to
594
+ * find. Two distinct zeros are guarded: no file matched, and files matched but
595
+ * held no tests -- a scanner that only checked the first prints a clean tick on
596
+ * the second.
597
+ */
598
+ export function vacuityCheckCmd(path, opts, deps) {
599
+ const json = opts.json === true;
600
+ const files = isDir(path) ? collectTestFiles(path) : [path];
601
+ const findings = [];
602
+ const skipped = [];
603
+ let checked = 0;
604
+ for (const f of files) {
605
+ const r = scanVacuity(f);
606
+ checked += r.checked;
607
+ findings.push(...r.findings);
608
+ if (r.skipped)
609
+ skipped.push(...r.skipped);
610
+ }
611
+ if (files.length === 0) {
612
+ skipped.push({
613
+ name: path,
614
+ reason: `no test file matched (looked for ${SCANNABLE_DESC})`,
615
+ });
616
+ }
617
+ const result = { checked, findings };
618
+ if (skipped.length > 0)
619
+ result.skipped = skipped;
620
+ const outcome = gateOutcome(result, 'advisory', { noun: 'test(s)' });
621
+ // `advisory` keeps findings at exit 0; the abstention still has to be loud, so
622
+ // the exit code for a zero denominator is taken from the gate contract.
623
+ const exitCode = outcome.abstained ? EXIT_ABSTAINED : 0;
624
+ if (json) {
625
+ deps.out(jsonIndent2({
626
+ checked,
627
+ abstained: outcome.abstained,
628
+ findings,
629
+ skipped,
630
+ }));
631
+ if (exitCode !== 0)
632
+ throw new CliExitError(exitCode);
633
+ return;
634
+ }
635
+ for (const f of findings) {
636
+ const color = f.severity === 'critical' ? pc.red : pc.yellow;
637
+ const tier = f.fidelity ? pc.dim(` [${f.fidelity}]`) : '';
638
+ deps.out(`${color(`[${f.severity.toUpperCase()}]`)} ${f.file}:${f.line} ${pc.dim(`(${f.rule})`)}${tier}`);
639
+ deps.out(` ${f.test}: ${f.message}`);
640
+ deps.out(` ${pc.dim(`${ARROW} ${f.suggestion}`)}\n`);
641
+ }
642
+ deps.out(outcome.abstained
643
+ ? pc.bold(pc.yellow(outcome.summaryLine))
644
+ : `${pc.bold(outcome.summaryLine)}`);
645
+ if (exitCode !== 0)
646
+ throw new CliExitError(exitCode);
647
+ }
648
+ // --- promote-check (#477) ----------------------------------------------------
649
+ /**
650
+ * The gate `canary-promote-test` Phase 1 consumes.
651
+ *
652
+ * Unlike `vacuity-check` this IS a gate, and deliberately so: it does not scan a
653
+ * repository, it decides whether ONE generated draft may enter the committed
654
+ * suite. A blocking finding there is not a backlog item to triage, it is a
655
+ * reason not to import the draft. Nothing existing turns red -- the command is
656
+ * new and only ever pointed at `tests/generated/`.
657
+ */
658
+ export function promoteCheckCmd(path, opts, deps) {
659
+ const verdict = promotionVerdict(path);
660
+ if (opts.json === true) {
661
+ deps.out(jsonIndent2(verdict));
662
+ if (verdict.exitCode !== 0)
663
+ throw new CliExitError(verdict.exitCode);
664
+ return;
665
+ }
666
+ for (const axis of verdict.axes) {
667
+ for (const f of axis.findings) {
668
+ const blocking = axis.gating && verdict.blocked.includes(f.rule);
669
+ const label = blocking ? pc.red('[BLOCK]') : pc.yellow('[ADVISORY]');
670
+ const tier = f.fidelity ? pc.dim(` [${f.fidelity}]`) : '';
671
+ deps.out(`${label} ${verdict.file}:${f.line} ${pc.dim(`(${f.rule}/${axis.axis})`)}${tier}`);
672
+ deps.out(` ${f.message}`);
673
+ deps.out(` ${pc.dim(`${ARROW} ${f.suggestion}`)}\n`);
674
+ }
675
+ }
676
+ // No glyph on `abstain`: `gateOutcome` already opens its summary line with the
677
+ // warning sign, and two in a row read as a rendering bug rather than emphasis.
678
+ const banner = {
679
+ promote: pc.green(`${CHECK_MARK} PROMOTE`),
680
+ block: pc.red(`${REDX} BLOCK`),
681
+ abstain: pc.yellow('ABSTAIN'),
682
+ };
683
+ deps.out(`${pc.bold(banner[verdict.decision])} ${EM_DASH} ${verdict.summaryLine}`);
684
+ if (verdict.remedy)
685
+ deps.out(` ${verdict.remedy}`);
686
+ if (verdict.exitCode !== 0)
687
+ throw new CliExitError(verdict.exitCode);
546
688
  }
547
689
  export function healTestCmd(path, opts, deps) {
548
690
  if (!isFile(path)) {
549
691
  deps.out(pc.red(`Error: ${path} is not a file.`));
550
- throw new CliExit(1);
692
+ throw new CliExitError(1);
551
693
  }
552
694
  const result = deps.makeHealer().heal(path);
553
695
  if (opts.json) {
@@ -631,17 +773,21 @@ export function upgradeCmd(opts, deps) {
631
773
  }
632
774
  else {
633
775
  deps.out(`${pc.red('Upgrade failed.')}\n${pip.stderr.trim()}`);
634
- throw new CliExit(1);
776
+ throw new CliExitError(1);
635
777
  }
636
778
  }
637
- // --- overlay / doctor (npm-shim pointers) ------------------------------------
638
- export function overlayStub(deps) {
779
+ // --- overlay / doctor / uninstall (npm-shim pointers) ------------------------
780
+ export function overlayCmd(deps) {
639
781
  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);
782
+ throw new CliExitError(1);
641
783
  }
642
- export function doctorStub(deps) {
784
+ export function doctorCmd(deps) {
643
785
  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);
786
+ throw new CliExitError(1);
787
+ }
788
+ export function uninstallCmd(deps) {
789
+ 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.`);
790
+ throw new CliExitError(1);
645
791
  }
646
792
  export async function ticketUpdateCmd(opts, deps) {
647
793
  let reportData = {};
@@ -651,7 +797,7 @@ export async function ticketUpdateCmd(opts, deps) {
651
797
  }
652
798
  catch (exc) {
653
799
  deps.out(pc.red(`Could not read result file '${opts.result}': ${exc instanceof Error ? exc.message : String(exc)}`));
654
- throw new CliExit(1);
800
+ throw new CliExitError(1);
655
801
  }
656
802
  }
657
803
  const testFile = opts.testFile;
@@ -702,7 +848,7 @@ export async function ticketUpdateCmd(opts, deps) {
702
848
  }
703
849
  }
704
850
  if (updateResult.transition.reason.startsWith(WARN)) {
705
- throw new CliExit(1);
851
+ throw new CliExitError(1);
706
852
  }
707
853
  }
708
854
  /** 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