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.
- package/dist/engine/analysis/cli.js +116 -54
- package/dist/engine/analysis/engine.js +34 -16
- package/dist/engine/analysis/reports.js +5 -4
- package/dist/engine/cli-commands.js +249 -41
- package/dist/engine/cli-common.js +15 -24
- package/dist/engine/cli.core.js +37 -11
- package/dist/engine/cli.js +2 -2
- package/dist/engine/company-knowledge-cli.js +2 -2
- package/dist/engine/core/adoption.js +408 -0
- package/dist/engine/core/framework-probes.js +7 -7
- package/dist/engine/core/fs-glob.js +2 -2
- package/dist/engine/core/gate-result.js +17 -0
- package/dist/engine/core/migrator.js +9 -17
- package/dist/engine/core/pattern-matcher.js +23 -5
- package/dist/engine/core/persona.js +421 -0
- package/dist/engine/core/promotion-verdict.js +261 -0
- package/dist/engine/core/reporter.js +1 -9
- package/dist/engine/core/skill-examples.js +292 -0
- package/dist/engine/core/skill-surfaces.js +307 -0
- package/dist/engine/core/static-linter.js +310 -38
- package/dist/engine/core/ticket-updater.js +1 -7
- package/dist/engine/core/vacuity-scanner.js +556 -0
- package/dist/engine/core/workflow-discovery.js +2 -8
- package/dist/engine/core/workspace-detect.js +7 -6
- package/dist/engine/data/personas/registry.json +36 -0
- package/dist/engine/guardian/adjudication.js +5 -5
- package/dist/engine/guardian/analysis-emit.js +13 -27
- package/dist/engine/guardian/cli.js +30 -43
- package/dist/engine/guardian/coverage.js +1 -1
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
- package/dist/engine/guardian/pr-check.js +5 -15
- package/dist/engine/guardian/pr-comment.js +4 -3
- package/dist/engine/history/cli.js +210 -6
- package/dist/engine/history/ndjson-store.js +9 -5
- package/dist/engine/history/record.js +34 -5
- package/dist/engine/history/run-recorder.js +165 -0
- package/dist/engine/history/schema.js +25 -7
- package/dist/engine/history/store.js +9 -0
- package/dist/engine/mcp-server.js +35 -13
- package/dist/engine/skills-cli.js +133 -11
- package/dist/engine/util/ensure-ascii.js +37 -0
- package/dist/engine/workflow-cli.js +6 -6
- package/dist/gate-result.d.ts +11 -0
- package/dist/gate-result.js +18 -0
- package/dist/uninstall.js +12 -5
- 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`: `
|
|
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 {
|
|
17
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
340
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
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
|
|
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
|
|
525
|
-
|
|
526
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
838
|
+
throw new CliExitError(1);
|
|
635
839
|
}
|
|
636
840
|
}
|
|
637
|
-
// --- overlay / doctor (npm-shim pointers)
|
|
638
|
-
export function
|
|
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
|
|
844
|
+
throw new CliExitError(1);
|
|
641
845
|
}
|
|
642
|
-
export function
|
|
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
|
|
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
|
|
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
|
|
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
|
|
9
|
-
* `typer.Exit(code)`). Re-exported from `guardian/cli.ts` so the
|
|
10
|
-
* command tree (main program, its sub-apps, AND the already-ported
|
|
11
|
-
* sub-app it mounts) throws ONE class. `bin/canary.js`
|
|
12
|
-
* `
|
|
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
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
|
|
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
|
package/dist/engine/cli.core.js
CHANGED
|
@@ -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
|
-
* `
|
|
11
|
-
* subcommand so usage errors exit 2 (typer/click) not commander's
|
|
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 {
|
|
20
|
+
import { CliExitError, normalizeUsageExit } from './cli-common.js';
|
|
21
21
|
import { createAnalyzeCommand } from './analysis/cli.js';
|
|
22
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
package/dist/engine/cli.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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);
|