canary-test-cli 5.10.1 → 5.12.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/bin/canary CHANGED
Binary file
package/bin/canary.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
- "use strict";
2
+ 'use strict';
3
3
 
4
- const { execFileSync } = require("node:child_process");
5
- const path = require("node:path");
6
- const fs = require("node:fs");
7
- const { isTsCommand, route } = require("../dist/router.js");
4
+ const { execFileSync } = require('node:child_process');
5
+ const path = require('node:path');
6
+ const fs = require('node:fs');
7
+ const { isTsCommand, route } = require('../dist/router.js');
8
8
 
9
9
  function getBinaryPath(platform) {
10
- const name = platform === "win32" ? "canary.exe" : "canary";
10
+ const name = platform === 'win32' ? 'canary.exe' : 'canary';
11
11
  return path.join(__dirname, name);
12
12
  }
13
13
 
@@ -23,18 +23,18 @@ function forwardToBinary(
23
23
  existsSync = fs.existsSync,
24
24
  platform = process.platform,
25
25
  stderr = process.stderr,
26
- } = {}
26
+ } = {},
27
27
  ) {
28
28
  const binaryPath = getBinaryPath(platform);
29
29
  if (!existsSync(binaryPath)) {
30
30
  stderr.write(
31
31
  `canary binary not found at ${binaryPath}.\n` +
32
- `Try reinstalling: npm install -g canary-test-cli\n`
32
+ `Try reinstalling: npm install -g canary-test-cli\n`,
33
33
  );
34
34
  return 1;
35
35
  }
36
36
  try {
37
- execFile(binaryPath, argv, { stdio: "inherit" });
37
+ execFile(binaryPath, argv, { stdio: 'inherit' });
38
38
  return 0;
39
39
  } catch (err) {
40
40
  return err.status ?? 1;
@@ -55,7 +55,9 @@ function run(argv, deps = {}) {
55
55
 
56
56
  function main() {
57
57
  // `run` may return a number (sync commands) or a Promise<number> (doctor).
58
- Promise.resolve(run(process.argv.slice(2))).then((code) => process.exit(code));
58
+ Promise.resolve(run(process.argv.slice(2))).then((code) =>
59
+ process.exit(code),
60
+ );
59
61
  }
60
62
 
61
63
  module.exports = { getBinaryPath, forwardToBinary, run };
@@ -1,4 +1,4 @@
1
- "use strict";
1
+ 'use strict';
2
2
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
3
  if (k2 === undefined) k2 = k;
4
4
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -37,12 +37,13 @@ exports.DEFAULT_CHECK_TIMEOUT_MS = exports.CHECK_TYPES = void 0;
37
37
  exports.manifestPath = manifestPath;
38
38
  exports.commandSucceedsHash = commandSucceedsHash;
39
39
  exports.loadManifest = loadManifest;
40
- exports.filterByPersona = filterByPersona;
40
+ exports.collectAudiences = collectAudiences;
41
+ exports.filterByAudience = filterByAudience;
41
42
  exports.runCheck = runCheck;
42
43
  /**
43
44
  * Overlay check manifest — `<clone>/.canary/doctor.json` (Phase 2, tier 2).
44
45
  *
45
- * This module loads and validates the manifest and filters checks by persona.
46
+ * This module loads and validates the manifest and filters checks by audience.
46
47
  * Execution of the individual check types lives in the runner (added in a
47
48
  * later task). A malformed manifest never throws: {@link loadManifest} returns
48
49
  * a single failing {@link CheckResult} so `doctor` can report it and carry on.
@@ -53,10 +54,14 @@ const http = __importStar(require("node:http"));
53
54
  const https = __importStar(require("node:https"));
54
55
  const path = __importStar(require("node:path"));
55
56
  const node_child_process_1 = require("node:child_process");
56
- exports.CHECK_TYPES = ["file-exists", "url-reachable", "command-succeeds"];
57
+ exports.CHECK_TYPES = [
58
+ 'file-exists',
59
+ 'url-reachable',
60
+ 'command-succeeds',
61
+ ];
57
62
  /** Path to an overlay's manifest. */
58
63
  function manifestPath(cloneDir) {
59
- return path.join(cloneDir, ".canary", "doctor.json");
64
+ return path.join(cloneDir, '.canary', 'doctor.json');
60
65
  }
61
66
  /**
62
67
  * Stable fingerprint of a manifest's `command-succeeds` checks — the (id,
@@ -65,53 +70,60 @@ function manifestPath(cloneDir) {
65
70
  */
66
71
  function commandSucceedsHash(checks) {
67
72
  const cmds = checks
68
- .filter((c) => c.type === "command-succeeds")
73
+ .filter((c) => c.type === 'command-succeeds')
69
74
  .map((c) => ({ id: c.id, command: c.command ?? [] }))
70
75
  .sort((a, b) => a.id.localeCompare(b.id));
71
76
  if (cmds.length === 0) {
72
77
  return null;
73
78
  }
74
- return (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(cmds)).digest("hex");
79
+ return (0, node_crypto_1.createHash)('sha256').update(JSON.stringify(cmds)).digest('hex');
75
80
  }
76
81
  /** True when `v` is a non-empty array of strings. */
77
82
  function isStringArray(v) {
78
- return Array.isArray(v) && v.every((a) => typeof a === "string");
83
+ return Array.isArray(v) && v.every((a) => typeof a === 'string');
79
84
  }
80
85
  /**
81
86
  * Validate the type-specific field of a check. Returns an error string, or
82
87
  * null when the field is well-formed for the given type.
83
88
  */
84
89
  function validateTypeField(type, c) {
85
- if (type === "file-exists" && typeof c.path !== "string") {
90
+ if (type === 'file-exists' && typeof c.path !== 'string') {
86
91
  return `check "${c.id}" (file-exists) is missing a string "path"`;
87
92
  }
88
- if (type === "url-reachable" && typeof c.url !== "string") {
93
+ if (type === 'url-reachable' && typeof c.url !== 'string') {
89
94
  return `check "${c.id}" (url-reachable) is missing a string "url"`;
90
95
  }
91
- if (type === "command-succeeds" && !(isStringArray(c.command) && c.command.length > 0)) {
96
+ if (type === 'command-succeeds' &&
97
+ !(isStringArray(c.command) && c.command.length > 0)) {
92
98
  return `check "${c.id}" (command-succeeds) needs a non-empty string[] "command"`;
93
99
  }
94
100
  return null;
95
101
  }
96
- /** Validate the id/type/remedy/persona fields common to every check type. */
102
+ /** The audience tags on a raw check: `audience:` (canonical) or `persona:` (legacy). */
103
+ function rawAudience(c) {
104
+ return c.audience !== undefined ? c.audience : c.persona;
105
+ }
106
+ /** Validate the id/type/remedy/audience fields common to every check type. */
97
107
  function validateCommonFields(c, index) {
98
- if (typeof c.id !== "string" || c.id === "") {
108
+ if (typeof c.id !== 'string' || c.id === '') {
99
109
  return `check[${index}] is missing a string "id"`;
100
110
  }
101
- if (typeof c.type !== "string" || !exports.CHECK_TYPES.includes(c.type)) {
102
- return `check "${c.id}" has an unknown type (expected one of: ${exports.CHECK_TYPES.join(", ")})`;
111
+ if (typeof c.type !== 'string' ||
112
+ !exports.CHECK_TYPES.includes(c.type)) {
113
+ return `check "${c.id}" has an unknown type (expected one of: ${exports.CHECK_TYPES.join(', ')})`;
103
114
  }
104
- if (typeof c.remedy !== "string" || c.remedy === "") {
115
+ if (typeof c.remedy !== 'string' || c.remedy === '') {
105
116
  return `check "${c.id}" is missing a string "remedy"`;
106
117
  }
107
- if (c.persona !== undefined && !isStringArray(c.persona)) {
108
- return `check "${c.id}" has a non-string persona list`;
118
+ const audience = rawAudience(c);
119
+ if (audience !== undefined && !isStringArray(audience)) {
120
+ return `check "${c.id}" has a non-string audience list`;
109
121
  }
110
122
  return null;
111
123
  }
112
124
  /** Validate one raw check entry. Returns the typed check or an error string. */
113
125
  function validateCheck(raw, index) {
114
- if (typeof raw !== "object" || raw === null) {
126
+ if (typeof raw !== 'object' || raw === null) {
115
127
  return `check[${index}] is not an object`;
116
128
  }
117
129
  const c = raw;
@@ -128,7 +140,7 @@ function validateCheck(raw, index) {
128
140
  id: c.id,
129
141
  type,
130
142
  remedy: c.remedy,
131
- persona: c.persona,
143
+ audience: rawAudience(c),
132
144
  path: c.path,
133
145
  url: c.url,
134
146
  command: c.command,
@@ -141,7 +153,7 @@ function manifestFailure(cloneDir, detail) {
141
153
  ok: false,
142
154
  failure: {
143
155
  id: `manifest:${file}`,
144
- status: "fail",
156
+ status: 'fail',
145
157
  label: `doctor.json is invalid (${file})`,
146
158
  remedy: detail,
147
159
  },
@@ -156,10 +168,10 @@ function loadManifest(cloneDir) {
156
168
  const file = manifestPath(cloneDir);
157
169
  let raw;
158
170
  try {
159
- raw = fs.readFileSync(file, "utf8");
171
+ raw = fs.readFileSync(file, 'utf8');
160
172
  }
161
173
  catch (e) {
162
- if (e.code === "ENOENT") {
174
+ if (e.code === 'ENOENT') {
163
175
  return { ok: true, checks: [] };
164
176
  }
165
177
  return manifestFailure(cloneDir, e.message);
@@ -178,7 +190,7 @@ function loadManifest(cloneDir) {
178
190
  const checks = [];
179
191
  for (let i = 0; i < checksRaw.length; i += 1) {
180
192
  const result = validateCheck(checksRaw[i], i);
181
- if (typeof result === "string") {
193
+ if (typeof result === 'string') {
182
194
  return manifestFailure(cloneDir, result);
183
195
  }
184
196
  checks.push(result);
@@ -186,16 +198,39 @@ function loadManifest(cloneDir) {
186
198
  return { ok: true, checks };
187
199
  }
188
200
  /**
189
- * Keep checks that should run for `persona`: a null persona runs everything;
190
- * otherwise keep checks with no persona plus those whose persona list contains
191
- * the tag (case-insensitive).
201
+ * The distinct audience tags declared across a set of checks, in first-seen
202
+ * order and de-duplicated case-insensitively (original casing preserved for
203
+ * display). This is the discoverable audience *vocabulary* — the engine ships
204
+ * none of its own, so it is derived entirely from overlay manifests. Used to
205
+ * tell a user which `--audience` values actually mean something (issue #294).
206
+ */
207
+ function collectAudiences(checks) {
208
+ const seen = new Set();
209
+ const out = [];
210
+ for (const check of checks) {
211
+ for (const tag of check.audience ?? []) {
212
+ const key = tag.toLowerCase();
213
+ if (!seen.has(key)) {
214
+ seen.add(key);
215
+ out.push(tag);
216
+ }
217
+ }
218
+ }
219
+ return out;
220
+ }
221
+ /**
222
+ * Keep checks that should run for `audience`: a null audience runs everything;
223
+ * otherwise keep checks with no audience plus those whose audience list
224
+ * contains the tag (case-insensitive).
192
225
  */
193
- function filterByPersona(checks, persona) {
194
- if (persona === null) {
226
+ function filterByAudience(checks, audience) {
227
+ if (audience === null) {
195
228
  return [...checks];
196
229
  }
197
- const want = persona.toLowerCase();
198
- return checks.filter((c) => !c.persona || c.persona.length === 0 || c.persona.some((p) => p.toLowerCase() === want));
230
+ const want = audience.toLowerCase();
231
+ return checks.filter((c) => !c.audience ||
232
+ c.audience.length === 0 ||
233
+ c.audience.some((p) => p.toLowerCase() === want));
199
234
  }
200
235
  /** Default per-check timeout for url and command checks. */
201
236
  exports.DEFAULT_CHECK_TIMEOUT_MS = 10000;
@@ -203,7 +238,7 @@ function defaultProbeUrl(url, timeoutMs) {
203
238
  return new Promise((resolve) => {
204
239
  let mod;
205
240
  try {
206
- mod = new URL(url).protocol === "http:" ? http : https;
241
+ mod = new URL(url).protocol === 'http:' ? http : https;
207
242
  }
208
243
  catch {
209
244
  resolve(false);
@@ -214,7 +249,7 @@ function defaultProbeUrl(url, timeoutMs) {
214
249
  res.resume();
215
250
  resolve(s >= 200 && s < 400);
216
251
  });
217
- req.on("error", () => resolve(false));
252
+ req.on('error', () => resolve(false));
218
253
  req.setTimeout(timeoutMs, () => {
219
254
  req.destroy();
220
255
  resolve(false);
@@ -222,50 +257,68 @@ function defaultProbeUrl(url, timeoutMs) {
222
257
  });
223
258
  }
224
259
  const defaultRunCommand = (command, cwd, timeoutMs) => {
225
- const r = (0, node_child_process_1.spawnSync)(command[0], command.slice(1), { cwd, timeout: timeoutMs, encoding: "utf8" });
260
+ const r = (0, node_child_process_1.spawnSync)(command[0], command.slice(1), {
261
+ cwd,
262
+ timeout: timeoutMs,
263
+ encoding: 'utf8',
264
+ });
226
265
  if (r.error) {
227
266
  const code = r.error.code;
228
- return { ok: false, timedOut: code === "ETIMEDOUT" || r.signal === "SIGTERM", detail: String(r.error.message) };
267
+ return {
268
+ ok: false,
269
+ timedOut: code === 'ETIMEDOUT' || r.signal === 'SIGTERM',
270
+ detail: String(r.error.message),
271
+ };
229
272
  }
230
- if (r.signal === "SIGTERM") {
273
+ if (r.signal === 'SIGTERM') {
231
274
  return { ok: false, timedOut: true };
232
275
  }
233
- return { ok: r.status === 0, timedOut: false, detail: r.status === 0 ? undefined : `exit ${r.status}` };
276
+ return {
277
+ ok: r.status === 0,
278
+ timedOut: false,
279
+ detail: r.status === 0 ? undefined : `exit ${r.status}`,
280
+ };
234
281
  };
235
282
  function pass(check, label) {
236
- return { id: check.id, status: "pass", label };
283
+ return { id: check.id, status: 'pass', label };
237
284
  }
238
285
  function fail(check, label) {
239
- return { id: check.id, status: "fail", label, remedy: check.remedy };
286
+ return { id: check.id, status: 'fail', label, remedy: check.remedy };
240
287
  }
241
288
  function runFileExists(check, cloneDir) {
242
- const target = path.join(cloneDir, check.path ?? "");
289
+ const target = path.join(cloneDir, check.path ?? '');
243
290
  return fs.existsSync(target)
244
291
  ? pass(check, `${check.id}: ${check.path} exists`)
245
292
  : fail(check, `${check.id}: ${check.path} is missing`);
246
293
  }
247
294
  async function runUrlReachable(check, ctx, timeoutMs) {
248
295
  const probe = ctx.probeUrl ?? defaultProbeUrl;
249
- const reachable = await probe(check.url ?? "", timeoutMs);
296
+ const reachable = await probe(check.url ?? '', timeoutMs);
250
297
  return reachable
251
298
  ? pass(check, `${check.id}: ${check.url} reachable`)
252
299
  : fail(check, `${check.id}: ${check.url} unreachable`);
253
300
  }
254
301
  function skipped(check, reason) {
255
- return { id: check.id, status: "skip", label: `${check.id}: skipped (${reason})` };
302
+ return {
303
+ id: check.id,
304
+ status: 'skip',
305
+ label: `${check.id}: skipped (${reason})`,
306
+ };
256
307
  }
257
308
  function runCommandSucceeds(check, ctx, timeoutMs) {
258
309
  if (!ctx.consentGranted) {
259
310
  return skipped(check, "command checks need consent — re-run 'canary overlay add'");
260
311
  }
261
312
  const command = check.command ?? [];
262
- const cmd = `\`${command.join(" ")}\``;
313
+ const cmd = `\`${command.join(' ')}\``;
263
314
  const runner = ctx.runCommand ?? defaultRunCommand;
264
315
  const r = runner(command, ctx.cloneDir, timeoutMs);
265
316
  if (r.ok) {
266
317
  return pass(check, `${check.id}: ${cmd} succeeded`);
267
318
  }
268
- const why = r.timedOut ? `timed out after ${timeoutMs}ms` : (r.detail ?? "failed");
319
+ const why = r.timedOut
320
+ ? `timed out after ${timeoutMs}ms`
321
+ : (r.detail ?? 'failed');
269
322
  return fail(check, `${check.id}: ${cmd} ${why}`);
270
323
  }
271
324
  /**
@@ -275,10 +328,10 @@ function runCommandSucceeds(check, ctx, timeoutMs) {
275
328
  */
276
329
  async function runCheck(check, ctx) {
277
330
  const timeoutMs = ctx.timeoutMs ?? exports.DEFAULT_CHECK_TIMEOUT_MS;
278
- if (check.type === "file-exists") {
331
+ if (check.type === 'file-exists') {
279
332
  return runFileExists(check, ctx.cloneDir);
280
333
  }
281
- if (check.type === "url-reachable") {
334
+ if (check.type === 'url-reachable') {
282
335
  return runUrlReachable(check, ctx, timeoutMs);
283
336
  }
284
337
  return runCommandSucceeds(check, ctx, timeoutMs);
package/dist/doctor.js CHANGED
@@ -1,4 +1,4 @@
1
- "use strict";
1
+ 'use strict';
2
2
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
3
  if (k2 === undefined) k2 = k;
4
4
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -33,46 +33,97 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.parseJsonFlag = parseJsonFlag;
37
+ exports.unknownAudienceHint = unknownAudienceHint;
36
38
  exports.runDoctor = runDoctor;
37
39
  /**
38
40
  * `canary doctor` — environment self-check (Phase 2).
39
41
  *
40
42
  * Runs two tiers of checks and prints each as pass/fail with a remedy line
41
- * under every failure, adopting the `harness doctor` output shape; the process
42
- * exits non-zero when any check fails. Tier 1 is built-in engine checks; tier 2
43
- * is data-driven checks read from each tracked overlay's `.canary/doctor.json`.
43
+ * under every failure; the process exits non-zero when any check fails. Tier 1
44
+ * is built-in engine checks; tier 2 is data-driven checks read from each
45
+ * tracked overlay's `.canary/doctor.json`.
46
+ *
47
+ * Output shape (issue #318): the human text is loosely modeled on
48
+ * `harness doctor`, but the two are NOT a shared contract and have diverged —
49
+ * canary uses richer per-check fields (`id`/`label`/`remedy`) and a `skip`
50
+ * status tier that harness lacks. `--json` therefore emits a *canary-owned*
51
+ * machine contract, `{ version, checks, allPassed, warnings }`, documented in
52
+ * `JsonReport` below. Only the top-level `allPassed` boolean intentionally
53
+ * matches `harness doctor --json`; per-check fields are canary's own. Do not
54
+ * build a parser that assumes the two JSON shapes are interchangeable.
44
55
  */
45
56
  const os = __importStar(require("node:os"));
46
57
  const engine_checks_js_1 = require("./engine-checks.js");
47
58
  const doctor_manifest_js_1 = require("./doctor-manifest.js");
48
59
  const registry = __importStar(require("./overlays-registry.js"));
49
- const SYMBOL = { pass: "✓", fail: "✗", skip: "-", info: "ℹ" };
50
- /** `--persona <tag>` / `--persona=<tag>`, or null when unset. */
51
- function parsePersona(args) {
52
- for (let i = 0; i < args.length; i += 1) {
53
- const a = args[i];
54
- if (a === "--persona") {
55
- return args[i + 1] ?? null;
56
- }
57
- if (a.startsWith("--persona=")) {
58
- return a.slice("--persona=".length);
60
+ const SYMBOL = {
61
+ pass: '✓',
62
+ fail: '✗',
63
+ skip: '-',
64
+ info: 'ℹ',
65
+ };
66
+ /** `--json` requests machine output on stdout instead of the human report. */
67
+ function parseJsonFlag(args) {
68
+ return args.includes('--json');
69
+ }
70
+ /**
71
+ * `--audience <tag>` / `--audience=<tag>`, or null when unset (#319 B). The
72
+ * former name `--persona` is accepted as a legacy alias so existing invocations
73
+ * keep working; `--audience` is canonical. (Renamed to end the collision with
74
+ * harness's unrelated persona system.)
75
+ */
76
+ function parseAudience(args) {
77
+ for (const flag of ['--audience', '--persona']) {
78
+ for (let i = 0; i < args.length; i += 1) {
79
+ const a = args[i];
80
+ if (a === flag) {
81
+ return args[i + 1] ?? null;
82
+ }
83
+ if (a.startsWith(`${flag}=`)) {
84
+ return a.slice(flag.length + 1);
85
+ }
59
86
  }
60
87
  }
61
88
  return null;
62
89
  }
90
+ /**
91
+ * Fail-loud hint for an unrecognized `--audience` value (issue #294). Returns
92
+ * null when there is nothing to say — no audience was passed, or the passed
93
+ * audience is part of the known vocabulary. Otherwise returns a one-line,
94
+ * actionable message: the engine ships no audience vocabulary, so this lists
95
+ * the tags overlays actually declared (or says none are defined) instead of
96
+ * silently running only the audience-less checks and leaving the user to
97
+ * guess why their filter matched nothing.
98
+ */
99
+ function unknownAudienceHint(audience, known) {
100
+ if (audience === null) {
101
+ return null;
102
+ }
103
+ const want = audience.toLowerCase();
104
+ if (known.some((p) => p.toLowerCase() === want)) {
105
+ return null;
106
+ }
107
+ if (known.length === 0) {
108
+ return `--audience '${audience}' matched no checks: no overlay defines any audiences, so every check already runs. Drop the flag.`;
109
+ }
110
+ return `--audience '${audience}' is not a known audience. Valid options: ${known.join(', ')}. (Omit --audience to run every check.)`;
111
+ }
63
112
  function renderCheck(r) {
64
113
  const line = ` ${SYMBOL[r.status]} ${r.label}\n`;
65
- return r.status === "fail" && r.remedy ? `${line} → ${r.remedy}\n` : line;
114
+ return r.status === 'fail' && r.remedy
115
+ ? `${line} → ${r.remedy}\n`
116
+ : line;
66
117
  }
67
- /** Load, persona-filter, and run one overlay's manifest checks. */
68
- async function overlayResults(entry, deps, persona) {
118
+ /** Load, audience-filter, and run one overlay's manifest checks. */
119
+ async function overlayResults(entry, deps, audience) {
69
120
  const header = `Overlay: ${entry.name}`;
70
121
  const load = (0, doctor_manifest_js_1.loadManifest)(entry.path);
71
122
  if (!load.ok) {
72
- return { header, results: [load.failure] };
123
+ return { group: { header, results: [load.failure] }, loadedChecks: [] };
73
124
  }
74
125
  const granted = registry.consentGranted(entry, (0, doctor_manifest_js_1.commandSucceedsHash)(load.checks));
75
- const checks = (0, doctor_manifest_js_1.filterByPersona)(load.checks, persona);
126
+ const checks = (0, doctor_manifest_js_1.filterByAudience)(load.checks, audience);
76
127
  const ctx = {
77
128
  cloneDir: entry.path,
78
129
  consentGranted: granted,
@@ -84,7 +135,9 @@ async function overlayResults(entry, deps, persona) {
84
135
  for (const check of checks) {
85
136
  results.push(await (0, doctor_manifest_js_1.runCheck)(check, ctx));
86
137
  }
87
- return { header, results };
138
+ // Return the full (pre-filter) check set so the caller can build the
139
+ // audience vocabulary for a fail-loud hint on an unknown --audience.
140
+ return { group: { header, results }, loadedChecks: load.checks };
88
141
  }
89
142
  /**
90
143
  * Run `canary doctor`. Returns a process exit code: 0 when every check passed
@@ -93,7 +146,7 @@ async function overlayResults(entry, deps, persona) {
93
146
  */
94
147
  async function runDoctor(args, deps = {}) {
95
148
  const out = deps.out ?? process.stdout;
96
- const persona = parsePersona(args);
149
+ const audience = parseAudience(args);
97
150
  const homeDir = deps.homeDir ?? os.homedir();
98
151
  const engineDeps = {
99
152
  git: deps.git,
@@ -103,7 +156,9 @@ async function runDoctor(args, deps = {}) {
103
156
  getLatestVersion: deps.getLatestVersion,
104
157
  timeoutMs: deps.timeoutMs,
105
158
  };
106
- const groups = [{ header: "Engine", results: await (0, engine_checks_js_1.runEngineChecks)(engineDeps) }];
159
+ const groups = [
160
+ { header: 'Engine', results: await (0, engine_checks_js_1.runEngineChecks)(engineDeps) },
161
+ ];
107
162
  let reg;
108
163
  try {
109
164
  reg = registry.read(homeDir);
@@ -111,20 +166,46 @@ async function runDoctor(args, deps = {}) {
111
166
  catch {
112
167
  reg = registry.emptyRegistry();
113
168
  }
169
+ const allChecks = [];
114
170
  for (const entry of reg.overlays) {
115
- groups.push(await overlayResults(entry, deps, persona));
171
+ const { group, loadedChecks } = await overlayResults(entry, deps, audience);
172
+ groups.push(group);
173
+ allChecks.push(...loadedChecks);
174
+ }
175
+ // Issue #294: if the user passed a --audience that no overlay declares,
176
+ // tell them the valid vocabulary instead of silently filtering to only
177
+ // the audience-less checks and leaving them to wonder why.
178
+ const audienceHint = unknownAudienceHint(audience, (0, doctor_manifest_js_1.collectAudiences)(allChecks));
179
+ const failures = groups.reduce((n, g) => n + g.results.filter((r) => r.status === 'fail').length, 0);
180
+ // Issue #318: `--json` emits the canary-owned machine contract instead of
181
+ // the human report — nothing else is written to stdout, so the whole stream
182
+ // parses as one JSON object.
183
+ if (parseJsonFlag(args)) {
184
+ const report = {
185
+ version: 1,
186
+ checks: groups.flatMap((g) => g.results.map((r) => ({
187
+ id: r.id,
188
+ status: r.status,
189
+ label: r.label,
190
+ ...(r.remedy !== undefined ? { remedy: r.remedy } : {}),
191
+ group: g.header,
192
+ }))),
193
+ allPassed: failures === 0,
194
+ warnings: audienceHint ? [audienceHint] : [],
195
+ };
196
+ out.write(`${JSON.stringify(report, null, 2)}\n`);
197
+ return failures === 0 ? 0 : 1;
198
+ }
199
+ out.write('canary doctor\n');
200
+ if (audienceHint) {
201
+ out.write(`\n! ${audienceHint}\n`);
116
202
  }
117
- out.write("canary doctor\n");
118
- let failures = 0;
119
203
  for (const group of groups) {
120
204
  out.write(`\n${group.header}\n`);
121
205
  for (const result of group.results) {
122
- if (result.status === "fail") {
123
- failures += 1;
124
- }
125
206
  out.write(renderCheck(result));
126
207
  }
127
208
  }
128
- out.write(`\n${failures === 0 ? "All checks passed." : `${failures} check(s) failed.`}\n`);
209
+ out.write(`\n${failures === 0 ? 'All checks passed.' : `${failures} check(s) failed.`}\n`);
129
210
  return failures === 0 ? 0 : 1;
130
211
  }