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
@@ -34,7 +34,7 @@
34
34
  * default for `_WORKING_DIR` uses `??` to mirror `os.environ.get(k, cwd)`
35
35
  * (an explicitly-empty env var stays `""`).
36
36
  * - **ensure_ascii.** Hand-built JSON returned to the MCP host is escaped via
37
- * {@link ensureAscii} (the reporter.ts pattern) so non-ASCII code points
37
+ * the shared {@link ensureAscii} (`util/ensure-ascii.ts`) so non-ASCII units
38
38
  * emit `\uXXXX`, matching Python's default `json.dumps`.
39
39
  * - **splitlines.** `context_snippets` uses {@link pySplitlines}, which drops
40
40
  * a single trailing-newline empty tail exactly as `str.splitlines()` does.
@@ -46,12 +46,14 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
46
46
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
47
47
  import * as z from 'zod';
48
48
  import { DomainScanner } from './core/domain-scanner.js';
49
- import { detectEnvironment } from './core/environment-detect.js';
49
+ import { detectEnvironment, detectUserLevel, } from './core/environment-detect.js';
50
+ import { effectivePersonaRegistry, personaToDict, resolvePersona, } from './core/persona.js';
50
51
  import { CanaryTestExecutor } from './core/executor.js';
51
52
  import { FrameworkRegistry } from './core/framework-registry.js';
52
53
  import { HarnessMigrator } from './core/migrator.js';
53
54
  import { findTestFiles, PatternMatcher } from './core/pattern-matcher.js';
54
55
  import { Scaffolder } from './core/scaffolder.js';
56
+ import { ensureAscii } from './util/ensure-ascii.js';
55
57
  // ---------------------------------------------------------------------------
56
58
  // Module constants (mirror agent/mcp_server.py)
57
59
  // ---------------------------------------------------------------------------
@@ -99,15 +101,6 @@ const MAX_FILE_FUNCTIONS = 20;
99
101
  // ---------------------------------------------------------------------------
100
102
  // Python-compatibility helpers
101
103
  // ---------------------------------------------------------------------------
102
- /**
103
- * Reproduce Python's `json.dumps(..., ensure_ascii=True)` (the library default):
104
- * escape every code point >= 0x80 as `\uXXXX`. Copied per-module, matching the
105
- * reporter.ts / guardian pattern (the regex range is written with `\u....`
106
- * escapes so this source stays ASCII).
107
- */
108
- function ensureAscii(json) {
109
- return json.replace(/[\u0080-\uffff]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
110
- }
111
104
  /**
112
105
  * Split like Python's `str.splitlines()` for the common line endings: splits on
113
106
  * `\r\n` / `\r` / `\n` and drops the single empty tail a trailing separator
@@ -436,9 +429,37 @@ export function analyzeFileImpl(filePath) {
436
429
  // Context-aware persona & environment detection (#341): attach the detected
437
430
  // BASE_URL, suite type, and SDET-vs-manual user level. The file under
438
431
  // analysis is itself an "open file" signal for the user-level heuristic.
439
- const environment = detectEnvironment(projectRoot, {
432
+ const detected = detectEnvironment(projectRoot, {
440
433
  openFiles: [filePath],
441
- }).toDict();
434
+ });
435
+ // Personas (#462) — and the consumer #341 never had. Detection shipped and
436
+ // was read by nobody: the level was computed, attached here, and dropped.
437
+ // Resolving it into a persona is what turns the signal into something a
438
+ // skill can consult instead of re-inventing its own audience rules.
439
+ //
440
+ // The persona re-derives the user level WITHOUT the analyzed file, and that
441
+ // is the whole point of the second call. `filePath` is this tool's argument,
442
+ // not an observation of what the caller is working on, so counting it as
443
+ // evidence about the person would let any `.ts` file in any project with a
444
+ // package.json clear the two-independent-signal floor and declare the reader
445
+ // a senior SDET. `environment` above keeps reporting it — that contract is
446
+ // unchanged — and only the persona declines to count it.
447
+ const [personaLevel, personaSignals, personaConfidence] = detectUserLevel(projectRoot, []);
448
+ // Both I/O decisions are made *here* rather than inside the resolver, which
449
+ // is pure by design: reading the environment variable, and loading the
450
+ // registry (shipped personas folded with every overlay's). `CANARY_PERSONA`
451
+ // is the audience-depth axis and is unrelated to `canary doctor --audience`,
452
+ // which tags which overlay checks run.
453
+ const persona = personaToDict(resolvePersona({
454
+ explicit: process.env['CANARY_PERSONA'] ?? null,
455
+ detected: {
456
+ level: personaLevel,
457
+ confidence: personaConfidence,
458
+ signals: personaSignals,
459
+ },
460
+ registry: effectivePersonaRegistry(),
461
+ }));
462
+ const environment = detected.toDict();
442
463
  return {
443
464
  framework,
444
465
  framework_source: frameworkSource,
@@ -452,6 +473,7 @@ export function analyzeFileImpl(filePath) {
452
473
  existing_tests: findExistingTests(projectRoot, framework),
453
474
  context_snippets: contextSnippets,
454
475
  environment,
476
+ persona,
455
477
  };
456
478
  }
457
479
  /** Python: `_write_test_file_impl`. */
@@ -10,9 +10,14 @@
10
10
  * load/attr failure -> 6) over a dynamic `import()`, which will not resolve a
11
11
  * Python module. No Python test exercises the cli/entry execution branches.
12
12
  */
13
+ import { existsSync } from 'node:fs';
14
+ import { join, resolve } from 'node:path';
13
15
  import { Command } from 'commander';
14
16
  import pc from 'picocolors';
15
- import { CliExit, normalizeUsageExit } from './cli-common.js';
17
+ import { CliExitError, jsonIndent2, normalizeUsageExit } from './cli-common.js';
18
+ import { gateOutcome } from './core/gate-result.js';
19
+ import { checkExamples, spawnRunner, } from './core/skill-examples.js';
20
+ import { SurfaceFindingKind, checkSurfaces, collectSurfaces, } from './core/skill-surfaces.js';
16
21
  import { isExecutableSkillAllowed, resolveCliPath, } from './core/skill-registry.js';
17
22
  import { CROSS, EM_DASH } from './main-deps.js';
18
23
  function overlayName(skill) {
@@ -88,19 +93,19 @@ async function runCmd(name, args, opts, deps) {
88
93
  const skill = deps.makeSkillRegistry().find(name);
89
94
  if (skill === null) {
90
95
  deps.out(`${pc.red(CROSS)} No skill named ${pc.bold(name)} found.`);
91
- throw new CliExit(1);
96
+ throw new CliExitError(1);
92
97
  }
93
98
  if (skill.error) {
94
99
  deps.out(`${pc.red(CROSS)} Skill ${pc.bold(name)}: ${skill.error}`);
95
- throw new CliExit(2);
100
+ throw new CliExitError(2);
96
101
  }
97
102
  if (!skill.isExecutable) {
98
103
  deps.out(pc.yellow(`Skill ${pc.bold(name)} is markdown-only ${EM_DASH} no cli: or entry: field to run.`));
99
- throw new CliExit(2);
104
+ throw new CliExitError(2);
100
105
  }
101
106
  if (!isExecutableSkillAllowed(opts.allowExecutableSkills ?? false)) {
102
107
  deps.out(`${pc.red(CROSS)} Refusing to invoke executable skill in non-interactive context. Pass ${pc.bold('--allow-executable-skills')} to opt in (e.g. in trusted CI configurations).`);
103
- throw new CliExit(3);
108
+ throw new CliExitError(3);
104
109
  }
105
110
  const forwarded = args;
106
111
  if (skill.cli) {
@@ -110,7 +115,7 @@ async function runCmd(name, args, opts, deps) {
110
115
  }
111
116
  catch (exc) {
112
117
  deps.out(`${pc.red(CROSS)} ${exc instanceof Error ? exc.message : String(exc)}`);
113
- throw new CliExit(4);
118
+ throw new CliExitError(4);
114
119
  }
115
120
  const cmd = target.endsWith('.py') ? [deps.pythonExe(), target] : [target];
116
121
  const res = deps.runSubprocess(cmd[0], [...cmd.slice(1), ...forwarded], {
@@ -120,14 +125,14 @@ async function runCmd(name, args, opts, deps) {
120
125
  // A spawn failure (missing interpreter/binary) yields status=null; Python's
121
126
  // subprocess.run raises FileNotFoundError -> nonzero exit. Map null -> 1, not
122
127
  // a silent 0. A normal run passes its real exit code through.
123
- throw new CliExit(res.status ?? 1);
128
+ throw new CliExitError(res.status ?? 1);
124
129
  }
125
130
  // entry: branch -- see module docstring (Python-module semantics are not
126
131
  // portable; the exit-code ladder is preserved).
127
132
  const [moduleName, sep, attr] = partition(skill.entry, ':');
128
133
  if (!moduleName || !attr || !sep) {
129
134
  deps.out(`${pc.red(CROSS)} Skill ${pc.bold(name)} entry must be 'module:callable', got '${skill.entry}'`);
130
- throw new CliExit(5);
135
+ throw new CliExitError(5);
131
136
  }
132
137
  try {
133
138
  const mod = (await import(moduleName));
@@ -135,15 +140,122 @@ async function runCmd(name, args, opts, deps) {
135
140
  if (typeof target !== 'function')
136
141
  throw new Error('not callable');
137
142
  const rc = target();
138
- throw new CliExit(typeof rc === 'number' ? rc : 0);
143
+ throw new CliExitError(typeof rc === 'number' ? rc : 0);
139
144
  }
140
145
  catch (exc) {
141
- if (exc instanceof CliExit)
146
+ if (exc instanceof CliExitError)
142
147
  throw exc;
143
148
  deps.out(`${pc.red(CROSS)} Skill ${pc.bold(name)} entry '${skill.entry}': ${exc instanceof Error ? exc.message : String(exc)}`);
144
- throw new CliExit(6);
149
+ throw new CliExitError(6);
145
150
  }
146
151
  }
152
+ /**
153
+ * `skills verify` -- cross-surface consistency (#452) plus execution of the
154
+ * commands the docs promise (#487).
155
+ *
156
+ * **Two checks, two summary lines, deliberately.** Folding them into one
157
+ * denominator is the trap: 80 surfaces checked and 0 examples executed would
158
+ * render as a single healthy number while the half that actually executes
159
+ * something had abstained. Each check therefore reports its own denominator
160
+ * through `gateOutcome`, and the reader sees both.
161
+ *
162
+ * Classified **advisory** (exit 0): both checks are landing on a corpus nothing
163
+ * has ever swept, so their precision is unknown and ADR 0010 says promotion
164
+ * waits for the triage. Abstention is still printed loudly -- that is what the
165
+ * classification does *not* soften.
166
+ */
167
+ /**
168
+ * Run the examples half, or explain why it could not run.
169
+ *
170
+ * The examples half spawns a CLI. When there is no built one, the honest result
171
+ * is a zero denominator carrying the reason -- an unrunnable runner must never
172
+ * look like a clean corpus.
173
+ */
174
+ function exampleHalf(opts, surfaces, root) {
175
+ const bin = opts.canaryBin ?? join(root, 'ts', 'bin', 'canary.js');
176
+ if ((opts.runExamples ?? true) && existsSync(bin)) {
177
+ return checkExamples(surfaces, spawnRunner(bin), root);
178
+ }
179
+ return {
180
+ checked: 0,
181
+ findings: [],
182
+ skipped: [
183
+ {
184
+ name: 'every documented example',
185
+ reason: opts.runExamples === false
186
+ ? '--no-run-examples was passed'
187
+ : `no built CLI at ${bin} (run \`npm --prefix ts run build\`)`,
188
+ },
189
+ ],
190
+ };
191
+ }
192
+ /**
193
+ * Per-kind tally over the FULL enum, including kinds that scored zero.
194
+ *
195
+ * A triage backlog is read by category, and `cli-not-executable=0` is the line
196
+ * that says the rule ran. An absent line is indistinguishable from a rule that
197
+ * was never evaluated -- the reading trap this whole check exists to close.
198
+ */
199
+ function tallyByKind(findings) {
200
+ return Object.values(SurfaceFindingKind)
201
+ .map((kind) => `${kind}=${findings.filter((f) => f.kind === kind).length}`)
202
+ .join(' ');
203
+ }
204
+ /** Both halves as JSON, each carrying its own `checked` and `abstained`. */
205
+ function verifyJson(root, surfaceResult, exampleResult) {
206
+ return jsonIndent2({
207
+ root,
208
+ surfaces: {
209
+ checked: surfaceResult.checked,
210
+ abstained: !(surfaceResult.checked > 0),
211
+ findings: surfaceResult.findings,
212
+ },
213
+ examples: {
214
+ checked: exampleResult.checked,
215
+ abstained: !(exampleResult.checked > 0),
216
+ findings: exampleResult.findings,
217
+ skipped: exampleResult.skipped ?? [],
218
+ },
219
+ });
220
+ }
221
+ /** One summary line per half, plus the remediation text on an abstention. */
222
+ function verifyReport(root, surfaceResult, exampleResult, deps) {
223
+ for (const f of surfaceResult.findings) {
224
+ deps.err(`${pc.yellow(CROSS)} [${f.kind}] ${f.name}: ${f.detail}`);
225
+ }
226
+ for (const f of exampleResult.findings) {
227
+ deps.err(`${pc.yellow(CROSS)} [${f.kind}] ${f.skill}: ${f.detail}`);
228
+ }
229
+ const surfaceOutcome = gateOutcome(surfaceResult, 'advisory', {
230
+ noun: 'surface declaration(s)',
231
+ });
232
+ deps.out(`surfaces: ${surfaceOutcome.summaryLine}`);
233
+ deps.out(` by kind: ${tallyByKind(surfaceResult.findings)}`);
234
+ if (surfaceOutcome.abstained) {
235
+ deps.out(` no skill surface was found under ${root} ${EM_DASH} check the path, ` +
236
+ 'or that `agents/skills/` has not been renamed.');
237
+ }
238
+ const exampleOutcome = gateOutcome(exampleResult, 'advisory', {
239
+ noun: 'documented example(s)',
240
+ });
241
+ deps.out(`examples: ${exampleOutcome.summaryLine}`);
242
+ if (exampleOutcome.abstained) {
243
+ deps.out(` zero documented commands were executed ${EM_DASH} nothing here is ` +
244
+ 'proven. Add a help-shaped example in a shell fence to a SKILL.md, or ' +
245
+ 'build the engine so the runner has a CLI to spawn.');
246
+ }
247
+ }
248
+ function verifyCmd(opts, deps) {
249
+ const root = resolve(opts.root ?? deps.cwd());
250
+ const surfaces = collectSurfaces(root);
251
+ const surfaceResult = checkSurfaces(root);
252
+ const exampleResult = exampleHalf(opts, surfaces, root);
253
+ if (opts.json) {
254
+ deps.out(verifyJson(root, surfaceResult, exampleResult));
255
+ return;
256
+ }
257
+ verifyReport(root, surfaceResult, exampleResult, deps);
258
+ }
147
259
  /** Python `str.partition(sep)` -> `[before, sep, after]`. */
148
260
  function partition(s, sep) {
149
261
  const i = s.indexOf(sep);
@@ -173,6 +285,16 @@ export function buildSkillsCommand(deps) {
173
285
  .action(async (name, args, opts) => {
174
286
  await runCmd(name, args ?? [], opts, deps);
175
287
  });
288
+ program
289
+ .command('verify')
290
+ .description('Check that skill surfaces agree and that documented examples still run.')
291
+ .option('--root <path>', 'Repository root to inspect (default: cwd).')
292
+ .option('--json', 'Emit both denominators and every finding as JSON.')
293
+ .option('--no-run-examples', 'Inspect declarations only; do not execute documented examples.')
294
+ .option('--canary-bin <path>', 'CLI to spawn for documented examples.')
295
+ .action((opts) => {
296
+ verifyCmd(opts, deps);
297
+ });
176
298
  for (const sub of program.commands) {
177
299
  sub.exitOverride(normalizeUsageExit);
178
300
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The one `ensure_ascii=True` implementation (#710).
3
+ *
4
+ * Python's `json.dumps` escapes every non-ASCII character to `\uXXXX` by
5
+ * default; `JSON.stringify` emits raw UTF-8. Every module that reproduces a
6
+ * Python payload byte-for-byte therefore has to post-process its stringify
7
+ * output, and eight of them used to do it with a private copy of this function.
8
+ *
9
+ * It lives under `util/` rather than in `cli-common.ts` (which documented it
10
+ * first) because the copies were spread across the `core`, `guardian`, and
11
+ * entry layers, and the layer model in `harness.config.json` lets nothing
12
+ * depend on `cli`. `util` is the one leaf every layer is allowed to reach.
13
+ *
14
+ * `ts/test/shared-helper-single-source.test.ts` fails if a ninth copy appears.
15
+ */
16
+ /**
17
+ * Escape every non-ASCII UTF-16 code UNIT to `\uXXXX`, matching Python
18
+ * `json.dumps(ensure_ascii=True)`.
19
+ *
20
+ * Iterating by code unit rather than code point is the load-bearing detail: an
21
+ * astral character's surrogate pair emits `\udXXX\udXXX`, exactly as CPython
22
+ * writes it. A code-point walk (`for (const ch of json)`) would instead reach
23
+ * a value above `0xffff`, which does not fit a four-hex-digit `\uXXXX` escape
24
+ * at all -- so it would either truncate or leave the character raw.
25
+ *
26
+ * Only the `>= 0x80` range is touched, so the ASCII escapes `JSON.stringify`
27
+ * already produced (`\"`, `\\`, control characters) pass through intact.
28
+ */
29
+ export function ensureAscii(json) {
30
+ let out = '';
31
+ for (let i = 0; i < json.length; i++) {
32
+ const c = json.charCodeAt(i);
33
+ out += c >= 0x80 ? '\\u' + c.toString(16).padStart(4, '0') : json[i];
34
+ }
35
+ return out;
36
+ }
37
+ //# sourceMappingURL=ensure-ascii.js.map
@@ -12,7 +12,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
  import { Command } from 'commander';
14
14
  import pc from 'picocolors';
15
- import { CliExit, jsonIndent2, normalizeUsageExit } from './cli-common.js';
15
+ import { CliExitError, jsonIndent2, normalizeUsageExit } from './cli-common.js';
16
16
  import { SemanticRole, WorkflowDiscoveryError, WorkflowMapping, } from './core/workflow-discovery.js';
17
17
  import { CHECK, CROSS, MAGNIFIER, WARN, ELLIPSIS, } from './main-deps.js';
18
18
  /** ISO-8601 UTC timestamp truncated to seconds (Python `isoformat(timespec)`). */
@@ -38,7 +38,7 @@ async function discoverCmd(opts, deps) {
38
38
  }
39
39
  if (keys.length === 0) {
40
40
  deps.out(`${pc.yellow('No project keys found.')} Pass ${pc.bold('--project <key>')} or add keys to ${pc.bold('.canary/company.json')} ${'\u{2192}'} ${pc.bold('jira_projects')}.`);
41
- throw new CliExit(1);
41
+ throw new CliExitError(1);
42
42
  }
43
43
  }
44
44
  const errors = [];
@@ -76,7 +76,7 @@ async function discoverCmd(opts, deps) {
76
76
  }
77
77
  if (errors.length) {
78
78
  deps.out(`\n${pc.red(`Discovery failed for: ${errors.join(', ')}`)}`);
79
- throw new CliExit(1);
79
+ throw new CliExitError(1);
80
80
  }
81
81
  }
82
82
  function showCmd(opts, deps) {
@@ -99,7 +99,7 @@ function showCmd(opts, deps) {
99
99
  }
100
100
  if (keys.length === 0) {
101
101
  deps.out(pc.yellow('No cached workflow mappings found.'));
102
- throw new CliExit(0);
102
+ throw new CliExitError(0);
103
103
  }
104
104
  }
105
105
  let anyFound = false;
@@ -162,7 +162,7 @@ function showCmd(opts, deps) {
162
162
  }
163
163
  }
164
164
  if (!anyFound) {
165
- throw new CliExit(1);
165
+ throw new CliExitError(1);
166
166
  }
167
167
  }
168
168
  function initCmd(opts, deps) {
@@ -170,7 +170,7 @@ function initCmd(opts, deps) {
170
170
  const mappingPath = wd.mappingPath(opts.project);
171
171
  if (existsSync(mappingPath) && !opts.force) {
172
172
  deps.out(`${pc.yellow(WARN)} Mapping already exists at ${mappingPath}.\nUse ${pc.bold('--force')} to overwrite.`);
173
- throw new CliExit(1);
173
+ throw new CliExitError(1);
174
174
  }
175
175
  const resolvedUrl = (opts.atlassianUrl || deps.env['ATLASSIAN_URL'] || '').replace(/\/+$/, '') || null;
176
176
  const semanticRoles = {
@@ -48,6 +48,17 @@ export interface GateOutcomeOptions {
48
48
  /** Unit noun for the clean-pass line. Default: `'check(s)'`. */
49
49
  noun?: string;
50
50
  }
51
+ /**
52
+ * The errno code `e` carries, or `null` when it carries none.
53
+ *
54
+ * Load-bearing wherever a read failure is absorbed into a {@link SkipEntry}:
55
+ * only "the filesystem said no" may become a skip. Node PROGRAMMER errors carry
56
+ * a string `code` too, so keying off `typeof code === 'string'` reports a
57
+ * genuine defect inside the scanner as a tidy abstention with a misleading
58
+ * reason -- which is how a scanner learns to go quiet. Anything this returns
59
+ * `null` for must still throw.
60
+ */
61
+ export declare function errnoCode(e: unknown): string | null;
51
62
  /**
52
63
  * D7: skipped entries render in EVERY summary line.
53
64
  *
@@ -7,6 +7,7 @@
7
7
  // `npm test` verifies this copy has not drifted (--check runs as pretest).
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.EXIT_ABSTAINED = void 0;
10
+ exports.errnoCode = errnoCode;
10
11
  exports.skippedSuffix = skippedSuffix;
11
12
  exports.gateOutcome = gateOutcome;
12
13
  /**
@@ -19,6 +20,23 @@ const EMDASH = '\u{2014}'; // em dash
19
20
  // C0 controls (incl. \n, ESC) and DEL: a skip name must never be able to
20
21
  // forge output lines or smuggle ANSI sequences into the summary.
21
22
  const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
23
+ // A libuv/POSIX errno code (`ENOENT`, `EACCES`, `EISDIR`), as opposed to a Node
24
+ // programmer-error code (`ERR_INVALID_ARG_TYPE`).
25
+ const ERRNO = /^E[A-Z0-9]+$/;
26
+ /**
27
+ * The errno code `e` carries, or `null` when it carries none.
28
+ *
29
+ * Load-bearing wherever a read failure is absorbed into a {@link SkipEntry}:
30
+ * only "the filesystem said no" may become a skip. Node PROGRAMMER errors carry
31
+ * a string `code` too, so keying off `typeof code === 'string'` reports a
32
+ * genuine defect inside the scanner as a tidy abstention with a misleading
33
+ * reason -- which is how a scanner learns to go quiet. Anything this returns
34
+ * `null` for must still throw.
35
+ */
36
+ function errnoCode(e) {
37
+ const code = e?.code;
38
+ return typeof code === 'string' && ERRNO.test(code) ? code : null;
39
+ }
22
40
  /**
23
41
  * D7: skipped entries render in EVERY summary line.
24
42
  *
package/dist/uninstall.js CHANGED
@@ -128,9 +128,20 @@ const KNOWN_FLAGS = new Set([
128
128
  '--apply',
129
129
  '--include-generated',
130
130
  ]);
131
+ /** The scope menu, shared by `--help` and the no-scope refusal. */
132
+ const SCOPE_HELP = ' --global overlays and orphaned Claude Code plugin caches\n' +
133
+ ' --project .canary/, generated reports, and the .mcp.json entry\n' +
134
+ ' --all both\n' +
135
+ 'Nothing is removed without --apply.\n';
131
136
  function run(argv, deps = {}) {
132
137
  const out = deps.out ?? process.stdout;
133
138
  const err = deps.err ?? process.stderr;
139
+ // Routed before the engine's commander sees argv (#730), so `--help` lands
140
+ // here as a plain token. Asking how a command works is not a usage error.
141
+ if (argv.includes('--help') || argv.includes('-h')) {
142
+ out.write(`usage: canary uninstall <scope> [--apply]\n${SCOPE_HELP}`);
143
+ return 0;
144
+ }
134
145
  for (const a of argv) {
135
146
  if (!KNOWN_FLAGS.has(a)) {
136
147
  err.write(`canary uninstall: unknown option "${a}".\n`);
@@ -145,11 +156,7 @@ function run(argv, deps = {}) {
145
156
  if (argv.includes('--all'))
146
157
  scopes.push('all');
147
158
  if (scopes.length === 0) {
148
- err.write('canary uninstall: choose a scope.\n' +
149
- ' --global overlays and orphaned Claude Code plugin caches\n' +
150
- ' --project .canary/, generated reports, and the .mcp.json entry\n' +
151
- ' --all both\n' +
152
- 'Nothing is removed without --apply.\n');
159
+ err.write(`canary uninstall: choose a scope.\n${SCOPE_HELP}`);
153
160
  return 1;
154
161
  }
155
162
  if (scopes.length > 1) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canary-test-cli",
3
- "version": "7.0.0",
3
+ "version": "7.1.0",
4
4
  "description": "Canary — AI-powered test automation agent",
5
5
  "license": "MIT",
6
6
  "repository": {