canary-test-cli 6.4.0 → 6.5.0

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