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