carrick 0.3.67 → 0.3.68

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,669 @@
1
+ // Re-checking, on demand, what the first run decided once.
2
+ //
3
+ // Everything the index's accuracy depends on is settled at setup time by an
4
+ // agent following prose: which directories are services, which env vars name a
5
+ // URL, which files the scan reads. The index itself does not decay — CI
6
+ // re-scans the default branch on every push — but the configuration does, and
7
+ // nothing re-reads it (carrick#1035). `carrick doctor` is that second read.
8
+ //
9
+ // Three rules hold the whole command together:
10
+ //
11
+ // 1. **Read-only.** Every check opens files, runs `carrick status`, or asks
12
+ // git a question. None of them writes, and none of them spends money. A
13
+ // command people run when they are already suspicious must not change the
14
+ // thing they are inspecting.
15
+ // 2. **A finding is something that is wrong**, not something that is unusual.
16
+ // Exit is non-zero on any finding, so a check that fires on a healthy repo
17
+ // costs the whole command its meaning: the drift check reports lines the
18
+ // template has and yours does not, never lines you added; a local index
19
+ // behind the working tree is a note, because that is what an index looks
20
+ // like while someone is working.
21
+ // 3. **Each check lives with what it checks.** The hook and MCP readers are
22
+ // in the same files as the writers whose work they audit, so the audit and
23
+ // the install cannot drift; the workflow is compared against the template
24
+ // this package renders, not against a copy.
25
+ //
26
+ // What is NOT here, and where it is: the scaffold's hook-pack scripts
27
+ // (`.claude/*.sh`) are the cloud's bytes and are compared by its own
28
+ // `hook_pack` drift tool, not offline; scan coverage and env-var declarations
29
+ // need the scanner's file walk and its extraction pass, and are carrick#1053.
30
+ import fs from "node:fs";
31
+ import path from "node:path";
32
+ import { spawnSync } from "node:child_process";
33
+ import { status as runStatus } from "../cli.js";
34
+ import { nativeEnv, resolveNativeBinary } from "../native.js";
35
+ import { DEFAULTS, renderTemplate, TEMPLATE_PATHS } from "../templates.js";
36
+ import { inspectMcpClients, MCP_LINE } from "./mcp.js";
37
+ import { createOutput, DOCS } from "./output.js";
38
+ import { repoRoots } from "./repos.js";
39
+ import { expectedCarrickHooks, hookTarget, installedCarrickHooks, ownEntryPoint, } from "./settings.js";
40
+ import { SETTINGS_FILES } from "./remove.js";
41
+ export function parseArgs(argv, cwd = process.cwd()) {
42
+ const options = { workspace: cwd };
43
+ for (let index = 0; index < argv.length; index += 1) {
44
+ const argument = argv[index];
45
+ switch (argument) {
46
+ case "--workspace":
47
+ case "-w": {
48
+ const value = argv[index + 1];
49
+ if (!value)
50
+ return "--workspace needs a directory";
51
+ options.workspace = path.resolve(cwd, value);
52
+ index += 1;
53
+ break;
54
+ }
55
+ case "--help":
56
+ case "-h":
57
+ return help();
58
+ default:
59
+ if (argument?.startsWith("-"))
60
+ return `unknown option for \`carrick doctor\`: ${argument}`;
61
+ options.workspace = path.resolve(cwd, argument ?? ".");
62
+ }
63
+ }
64
+ return options;
65
+ }
66
+ function help() {
67
+ return [
68
+ "carrick doctor [DIRECTORY]",
69
+ "",
70
+ "Re-check the setup a first run decided once: the paths every carrick.json",
71
+ "declares, the CI workflow against the current template, the agent hooks and",
72
+ "the MCP connection on this machine, and how far the index is behind. It",
73
+ "writes nothing, runs no scan, and exits non-zero when it finds something.",
74
+ "",
75
+ " -w, --workspace DIR The folder init was run in (default: this one)",
76
+ "",
77
+ `What each of those things is, and what it does: ${DOCS}`,
78
+ ].join("\n");
79
+ }
80
+ export function findingCount(lines) {
81
+ return lines.filter((line) => line.level === "warn" || line.level === "refuse").length;
82
+ }
83
+ const done = (text) => ({ level: "done", text });
84
+ const warn = (text) => ({ level: "warn", text });
85
+ const refuse = (text) => ({ level: "refuse", text });
86
+ const say = (text) => ({ level: "say", text });
87
+ export function configuredRepos(workspace) {
88
+ return repoRoots(workspace).map((root) => {
89
+ const label = path.relative(workspace, root) || path.basename(root);
90
+ const file = path.join(root, "carrick.json");
91
+ if (!fs.existsSync(file))
92
+ return { root, label, config: null, problem: null };
93
+ try {
94
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
95
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
96
+ return { root, label, config: null, problem: "carrick.json is not a JSON object" };
97
+ }
98
+ return { root, label, config: parsed, problem: null };
99
+ }
100
+ catch (error) {
101
+ return { root, label, config: null, problem: `carrick.json is not valid JSON: ${error.message}` };
102
+ }
103
+ });
104
+ }
105
+ /**
106
+ * The services a config declares: the `services` array, or the file itself.
107
+ *
108
+ * The same resolution as `Config::load_services` in src/config.rs, for the
109
+ * three fields that name a path. When `services` is present its sibling flat
110
+ * fields are ignored, which is the rule the scanner applies too.
111
+ */
112
+ export function declaredServices(config) {
113
+ const entries = Array.isArray(config["services"]) && config["services"].length > 0
114
+ ? config["services"]
115
+ : [config];
116
+ const services = [];
117
+ for (const [index, raw] of entries.entries()) {
118
+ if (typeof raw !== "object" || raw === null)
119
+ continue;
120
+ const entry = raw;
121
+ const named = entry["serviceName"] ?? entry["name"];
122
+ const directory = typeof entry["directory"] === "string" ? entry["directory"] : undefined;
123
+ const service = {
124
+ name: typeof named === "string" && named !== "" ? named : (directory ?? `service ${index + 1}`),
125
+ include: Array.isArray(entry["include"])
126
+ ? entry["include"].filter((value) => typeof value === "string")
127
+ : [],
128
+ };
129
+ if (directory !== undefined)
130
+ service.directory = directory;
131
+ if (typeof entry["tsconfig"] === "string")
132
+ service.tsconfig = entry["tsconfig"];
133
+ services.push(service);
134
+ }
135
+ return services;
136
+ }
137
+ /**
138
+ * Every path a `carrick.json` declares, and whether it is there.
139
+ *
140
+ * The bases are the scanner's, read off the code that joins them rather than
141
+ * off the field names: `directory` and every `include` are relative to the
142
+ * repository root, which is where `carrick.json` sits (`find_service_files`),
143
+ * and `tsconfig` is relative to the SERVICE directory, because that is the
144
+ * root the type sidecar is initialised at (`scope_sidecar_to_service`). A
145
+ * doctor that resolved `tsconfig` from the repo root would report a missing
146
+ * file on every monorepo that has one.
147
+ */
148
+ export function checkDeclaredPaths(repos) {
149
+ const lines = [];
150
+ // A file that exists and will not parse is not an absent one: the two states
151
+ // have different sentences, and printing both would be printing a false one.
152
+ let files = 0;
153
+ let configs = 0;
154
+ let checked = 0;
155
+ for (const repo of repos) {
156
+ if (repo.problem !== null) {
157
+ files += 1;
158
+ lines.push(refuse(`${repo.label}: ${repo.problem}`));
159
+ continue;
160
+ }
161
+ if (repo.config === null) {
162
+ lines.push(say(`${repo.label} has no carrick.json, so nothing in it is indexed.`));
163
+ continue;
164
+ }
165
+ files += 1;
166
+ configs += 1;
167
+ for (const service of declaredServices(repo.config)) {
168
+ const serviceRoot = service.directory === undefined
169
+ ? repo.root
170
+ : path.resolve(repo.root, service.directory);
171
+ if (service.directory !== undefined) {
172
+ checked += 1;
173
+ if (!isDirectory(serviceRoot)) {
174
+ lines.push(refuse(`${repo.label}: service "${service.name}" declares directory "${service.directory}", which is not a directory in this repo. Nothing under it is scanned.`));
175
+ }
176
+ }
177
+ for (const include of service.include) {
178
+ checked += 1;
179
+ if (!isDirectory(path.resolve(repo.root, include))) {
180
+ lines.push(refuse(`${repo.label}: service "${service.name}" includes "${include}", which is not a directory in this repo.`));
181
+ }
182
+ }
183
+ if (service.tsconfig !== undefined) {
184
+ checked += 1;
185
+ const tsconfig = path.resolve(serviceRoot, service.tsconfig);
186
+ if (!isFile(tsconfig)) {
187
+ lines.push(refuse(`${repo.label}: service "${service.name}" declares tsconfig "${service.tsconfig}", which is not a file at ${path.relative(repo.root, tsconfig) || service.tsconfig}. Its types resolve from whatever the sidecar finds instead.`));
188
+ }
189
+ }
190
+ }
191
+ }
192
+ if (files === 0) {
193
+ lines.push(refuse("No carrick.json anywhere in this workspace, so no service is declared. `carrick init` derives one for your agent to write."));
194
+ return lines;
195
+ }
196
+ if (findingCount(lines) === 0) {
197
+ lines.push(done(`Declared paths exist: ${checked} in ${configs} carrick.json file(s).`));
198
+ }
199
+ return lines;
200
+ }
201
+ function isDirectory(target) {
202
+ try {
203
+ return fs.statSync(target).isDirectory();
204
+ }
205
+ catch {
206
+ return false;
207
+ }
208
+ }
209
+ function isFile(target) {
210
+ try {
211
+ return fs.statSync(target).isFile();
212
+ }
213
+ catch {
214
+ return false;
215
+ }
216
+ }
217
+ /** The lines of a file that state behaviour: no comments, no blank lines. */
218
+ export function functionalLines(body) {
219
+ return body
220
+ .replace(/\r\n/g, "\n")
221
+ .split("\n")
222
+ .map((line) => line.trimEnd())
223
+ .filter((line) => line.trim() !== "" && !line.trim().startsWith("#"));
224
+ }
225
+ /**
226
+ * What one file differs from a rendered template by, comment-insensitively.
227
+ *
228
+ * Comments and blank lines are dropped from both sides first. The template is
229
+ * two fifths comment, so comparing them would report a finding for every line
230
+ * a reader trimmed and for every rewording of an explanation — neither of
231
+ * which changes what CI runs.
232
+ *
233
+ * Only `missing` is a finding. A workflow with steps of its own is the case
234
+ * the template itself invites ("If you add deploy steps to this file..."), so
235
+ * added lines are printed as context and fail nothing.
236
+ */
237
+ export function templateDrift(actual, expected) {
238
+ const have = functionalLines(actual);
239
+ const want = functionalLines(expected);
240
+ // Longest common subsequence over lines: small inputs, and the alignment is
241
+ // what makes the printed diff readable rather than "these two files differ".
242
+ const table = Array.from({ length: want.length + 1 }, () => new Array(have.length + 1).fill(0));
243
+ for (let w = want.length - 1; w >= 0; w -= 1) {
244
+ for (let h = have.length - 1; h >= 0; h -= 1) {
245
+ table[w][h] = want[w] === have[h]
246
+ ? table[w + 1][h + 1] + 1
247
+ : Math.max(table[w + 1][h], table[w][h + 1]);
248
+ }
249
+ }
250
+ const missing = [];
251
+ const added = [];
252
+ const diff = [];
253
+ let w = 0;
254
+ let h = 0;
255
+ while (w < want.length && h < have.length) {
256
+ if (want[w] === have[h]) {
257
+ w += 1;
258
+ h += 1;
259
+ }
260
+ else if (table[w + 1][h] >= table[w][h + 1]) {
261
+ missing.push(want[w]);
262
+ diff.push(`- ${want[w]}`);
263
+ w += 1;
264
+ }
265
+ else {
266
+ added.push(have[h]);
267
+ diff.push(`+ ${have[h]}`);
268
+ h += 1;
269
+ }
270
+ }
271
+ for (; w < want.length; w += 1) {
272
+ missing.push(want[w]);
273
+ diff.push(`- ${want[w]}`);
274
+ }
275
+ for (; h < have.length; h += 1) {
276
+ added.push(have[h]);
277
+ diff.push(`+ ${have[h]}`);
278
+ }
279
+ return { missing, added, diff };
280
+ }
281
+ const WORKFLOW_PATH = TEMPLATE_PATHS.workflow;
282
+ /**
283
+ * The variables a workflow on disk states, so the comparison is about drift
284
+ * rather than about the two values the template exists to parameterise.
285
+ *
286
+ * A repo pinned to an older action ref, or building on a branch that is not
287
+ * `main`, is not drift: it is the template with its variables filled in. Both
288
+ * are read back out of the file, and where one cannot be read the default is
289
+ * used and the caller says so.
290
+ */
291
+ export function workflowVariables(body) {
292
+ const variables = {};
293
+ const unread = [];
294
+ const uses = /^\s*-?\s*uses:\s*(\S*carrick-tools\/carrick@\S+)\s*$/m.exec(body);
295
+ if (uses)
296
+ variables["ACTION_REF"] = uses[1];
297
+ else
298
+ unread.push("ACTION_REF");
299
+ const branches = new Set();
300
+ for (const match of body.matchAll(/^\s*branches:\s*\[([^\]]*)\]\s*$/gm)) {
301
+ for (const name of match[1].split(",")) {
302
+ const trimmed = name.trim().replace(/^["']|["']$/g, "");
303
+ if (trimmed !== "")
304
+ branches.add(trimmed);
305
+ }
306
+ }
307
+ if (branches.size === 1)
308
+ variables["DEFAULT_BRANCH"] = [...branches][0];
309
+ else
310
+ unread.push("DEFAULT_BRANCH");
311
+ return { variables, unread };
312
+ }
313
+ /**
314
+ * The CI workflow of every configured repo, against the template this package
315
+ * renders.
316
+ *
317
+ * Only repos with a `carrick.json` are asked for one: a sibling clone that
318
+ * Carrick does not index owes CI nothing.
319
+ */
320
+ export function checkWorkflow(repos) {
321
+ const lines = [];
322
+ let matched = 0;
323
+ for (const repo of repos) {
324
+ if (repo.config === null)
325
+ continue;
326
+ const file = path.join(repo.root, WORKFLOW_PATH);
327
+ let body;
328
+ try {
329
+ body = fs.readFileSync(file, "utf8");
330
+ }
331
+ catch {
332
+ lines.push(warn(`${repo.label}: no ${WORKFLOW_PATH}, so pushes to the default branch do not re-index it. Write one with \`carrick templates workflow\`.`));
333
+ continue;
334
+ }
335
+ const { variables, unread } = workflowVariables(body);
336
+ const drift = templateDrift(body, renderTemplate("workflow", variables));
337
+ if (drift.missing.length === 0) {
338
+ matched += 1;
339
+ if (drift.added.length > 0) {
340
+ lines.push(say(`${repo.label}: ${WORKFLOW_PATH} has the current template in it, plus ${drift.added.length} line(s) of your own.`));
341
+ }
342
+ continue;
343
+ }
344
+ const caveat = unread.includes("ACTION_REF")
345
+ ? " No step in it runs the Carrick action."
346
+ : unread.includes("DEFAULT_BRANCH")
347
+ ? ` Its branch list could not be read, so it was compared against ${DEFAULTS["DEFAULT_BRANCH"]}.`
348
+ : "";
349
+ lines.push(warn(`${repo.label}: ${WORKFLOW_PATH} is missing ${drift.missing.length} line(s) of the current template.${caveat} Comments are not compared; \`-\` is the template's, \`+\` is yours:\n${drift.diff.map((line) => ` ${line}`).join("\n")}`));
350
+ }
351
+ if (matched > 0 && findingCount(lines) === 0) {
352
+ lines.push(done(`CI workflow matches the current template in ${matched} repo(s).`));
353
+ }
354
+ return lines;
355
+ }
356
+ export function realMachine() {
357
+ return {
358
+ resolveOnPath: (command) => {
359
+ const probe = spawnSync(process.platform === "win32" ? "where" : "which", [command], {
360
+ encoding: "utf8",
361
+ timeout: 5000,
362
+ });
363
+ if (probe.status !== 0 || typeof probe.stdout !== "string")
364
+ return null;
365
+ const first = probe.stdout.split("\n")[0]?.trim();
366
+ if (!first)
367
+ return null;
368
+ try {
369
+ return fs.realpathSync(first);
370
+ }
371
+ catch {
372
+ return first;
373
+ }
374
+ },
375
+ realpath: (target) => {
376
+ try {
377
+ return fs.realpathSync(target);
378
+ }
379
+ catch {
380
+ return null;
381
+ }
382
+ },
383
+ entryPoint: ownEntryPoint(),
384
+ };
385
+ }
386
+ /**
387
+ * The hook entries in this workspace's `.claude` settings.
388
+ *
389
+ * Three separate questions, and a hook fails silently on all three, because
390
+ * the hook is built never to fail an edit (carrick#837): are the entries
391
+ * there, are they the ones this version writes, and does the command they name
392
+ * still run on this machine?
393
+ *
394
+ * The last one is where an install rots. An entry written as a bare `carrick`
395
+ * needs `carrick` on PATH at every edit; an entry written under `npx` resolves
396
+ * for the length of that one command and never again; an entry naming an
397
+ * absolute path is stale the moment that install is replaced.
398
+ */
399
+ export function checkHooks(workspace, machine) {
400
+ const lines = [];
401
+ const installed = [];
402
+ let files = 0;
403
+ for (const relative of SETTINGS_FILES) {
404
+ const file = path.join(workspace, relative);
405
+ let body;
406
+ try {
407
+ body = fs.readFileSync(file, "utf8");
408
+ }
409
+ catch {
410
+ continue;
411
+ }
412
+ files += 1;
413
+ try {
414
+ installed.push(...installedCarrickHooks(body));
415
+ }
416
+ catch (error) {
417
+ lines.push(refuse(`${relative} is not valid JSON (${error.message}), so no hook in it runs.`));
418
+ }
419
+ }
420
+ if (installed.length === 0) {
421
+ if (findingCount(lines) > 0)
422
+ return lines;
423
+ lines.push(warn(files === 0
424
+ ? `No .claude settings in this folder, so no Carrick hook runs here. \`carrick init\` writes them.`
425
+ : `No Carrick hook entries in ${SETTINGS_FILES.join(" or ")}, so nothing re-checks a file when your agent edits it. \`carrick init\` writes them.`));
426
+ return lines;
427
+ }
428
+ // Every entry names the same command, so the first one states which install
429
+ // the settings file points at and the rest are compared against it. The
430
+ // prefix is taken as written, quotes and all, because that is what the
431
+ // writer put there and what the expected entries are rendered from.
432
+ const prefix = installed[0].command.split(/\s+hook\s+/)[0];
433
+ const target = hookTarget(installed[0].command);
434
+ const expected = expectedCarrickHooks(prefix);
435
+ const missing = expected.filter((want) => !installed.some((have) => have.event === want.event &&
436
+ have.command === want.command &&
437
+ have.matcher === want.matcher &&
438
+ have.timeout === want.timeout));
439
+ if (missing.length > 0) {
440
+ lines.push(warn(`The hook entries here are not the ones this version installs: ${missing
441
+ .map((entry) => `${entry.event} \`${entry.command}\`${entry.matcher ? ` (matcher ${entry.matcher})` : ""}`)
442
+ .join(", ")} ${missing.length === 1 ? "is" : "are"} not in ${SETTINGS_FILES.join(" or ")}. Re-run \`carrick init\`.`));
443
+ }
444
+ if (target === null) {
445
+ lines.push(refuse(`A hook entry here runs \`${installed[0].command}\`, which names no command.`));
446
+ return lines;
447
+ }
448
+ const resolved = path.isAbsolute(target) ? machine.realpath(target) : machine.resolveOnPath(target);
449
+ if (resolved === null) {
450
+ lines.push(refuse(path.isAbsolute(target)
451
+ ? `The hooks here run ${target}, which is not a file on this machine, so every edit fails silently. Re-run \`carrick init\`.`
452
+ : `The hooks here run \`${target}\`, which does not resolve on PATH, so every edit fails silently. Install it globally (\`npm install -g carrick\`) or re-run \`carrick init\`, which writes an absolute path when it has to.`));
453
+ return lines;
454
+ }
455
+ if (isTransient(resolved)) {
456
+ lines.push(warn(`The hooks here run \`${target}\`, which resolves to a temporary npx install (${resolved}). It will not resolve on the next edit. Install carrick globally and re-run \`carrick init\`.`));
457
+ return lines;
458
+ }
459
+ // Which install answers, and whether that question can be answered at all.
460
+ //
461
+ // Comparing the resolved path to this package's entry point only means
462
+ // something when what resolved IS an entry point. A pnpm global install and
463
+ // every Windows install put a shim on PATH — a shell script, a `.cmd` — and
464
+ // a shim does not realpath to `bin/carrick.mjs`, so a comparison would
465
+ // report two installs on a machine that has one. And when `carrick doctor`
466
+ // is itself run through `npx`, the transient copy is the one asking: the
467
+ // hooks are pointing at the real install and the sentence would blame them
468
+ // for it. In both cases the check that matters has already passed — the
469
+ // command resolves, so the hook will not fail silently — and identity is
470
+ // reported as what it is: unproven.
471
+ const own = machine.realpath(machine.entryPoint) ?? machine.entryPoint;
472
+ const entryPointName = path.basename(machine.entryPoint);
473
+ if (path.basename(resolved) !== entryPointName) {
474
+ if (findingCount(lines) === 0) {
475
+ lines.push(done(`Agent hooks are installed here and run \`${target}\`, which resolves to ${resolved}.`));
476
+ }
477
+ return lines;
478
+ }
479
+ if (isTransient(own)) {
480
+ if (findingCount(lines) === 0) {
481
+ lines.push(done(`Agent hooks are installed here and run ${resolved}. This check is running from a temporary npx install, so it cannot say whether that is the same one.`));
482
+ }
483
+ return lines;
484
+ }
485
+ if (resolved !== own) {
486
+ lines.push(warn(`The hooks here run ${resolved}; this is ${own}. Two installs answer for one machine, and the hooks use the other one.`));
487
+ return lines;
488
+ }
489
+ if (findingCount(lines) === 0) {
490
+ lines.push(done(`Agent hooks are installed here and run this package (${own}).`));
491
+ }
492
+ return lines;
493
+ }
494
+ /** A path inside npm's `_npx` cache: it exists for one command and no longer. */
495
+ function isTransient(target) {
496
+ return target.includes(`${path.sep}_npx${path.sep}`);
497
+ }
498
+ /** The MCP server entry in each agent client this machine has. */
499
+ export function checkMcp(inspections) {
500
+ if (inspections.length === 0) {
501
+ return [say("No agent client on this machine holds an MCP configuration, so there is none to check.")];
502
+ }
503
+ const lines = [];
504
+ const connected = [];
505
+ for (const client of inspections) {
506
+ if (client.state === "connected") {
507
+ connected.push(client.client);
508
+ continue;
509
+ }
510
+ if (client.state === "absent") {
511
+ lines.push(warn(`${client.client} is on this machine and is not connected to Carrick (${client.detail}), so it answers nothing across your repos. \`carrick init\` connects it; by hand it is \`${MCP_LINE}\`.`));
512
+ continue;
513
+ }
514
+ lines.push(warn(`${client.client}: ${client.detail}.`));
515
+ }
516
+ if (connected.length > 0 && findingCount(lines) === 0) {
517
+ lines.push(done(`MCP server connected for ${connected.join(", ")}.`));
518
+ }
519
+ return lines;
520
+ }
521
+ export function realGit() {
522
+ const git = (repo, args) => {
523
+ const result = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", timeout: 5000 });
524
+ if (result.error || result.status !== 0 || typeof result.stdout !== "string")
525
+ return null;
526
+ return result.stdout.trim();
527
+ };
528
+ return {
529
+ defaultRemoteBranch: (repo) => git(repo, ["rev-parse", "--abbrev-ref", "origin/HEAD"]),
530
+ commitsBetween: (repo, from, to) => {
531
+ const count = git(repo, ["rev-list", "--count", `${from}..${to}`]);
532
+ if (count === null)
533
+ return null;
534
+ const parsed = Number.parseInt(count, 10);
535
+ return Number.isFinite(parsed) ? parsed : null;
536
+ },
537
+ };
538
+ }
539
+ /**
540
+ * The states a hosted index can be in that a reader can do something about.
541
+ *
542
+ * `enriched` is the healthy one. Every other state means this machine is
543
+ * answering from less than the index holds, and the CLI's own sentence for it
544
+ * (`boundary_note`) already names the next move, so it is printed rather than
545
+ * re-worded here.
546
+ */
547
+ const HEALTHY_HOSTED_STATE = "enriched";
548
+ /**
549
+ * How far the index is behind, and whether the hosted half arrived at all.
550
+ *
551
+ * Two different questions, and only one of them is a finding. A hosted state
552
+ * that is not `enriched` is plumbing: not connected, not signed in, no index
553
+ * yet, a cache version this build cannot replay. Files changed since the index
554
+ * are not: that is what a repository looks like while someone is working in
555
+ * it, and CI re-indexes the default branch on every push. So the first is a
556
+ * finding and the second is a note with the numbers in it.
557
+ */
558
+ export function checkIndex(result, failure, git) {
559
+ if (result === null) {
560
+ return [refuse(`Could not read the local index: ${failure ?? "the scanner gave no answer"}.`)];
561
+ }
562
+ if (result.error) {
563
+ return [refuse(result.message ?? `The local index could not be read: ${result.error}.`)];
564
+ }
565
+ const lines = [];
566
+ for (const service of result.services) {
567
+ const state = service.hosted_state;
568
+ if (state === undefined || state === HEALTHY_HOSTED_STATE)
569
+ continue;
570
+ // The CLI's own sentence leads, because it is the one that names the next
571
+ // move; the state itself is the word to quote in a bug report, so it goes
572
+ // at the end rather than in front of the explanation.
573
+ const sentence = service.boundary_note ??
574
+ "This machine is answering from less than the index holds. `carrick init` connects a repo, `carrick login` signs this machine in.";
575
+ lines.push(warn(`${service.service}: ${sentence} (hosted state: ${state})`));
576
+ }
577
+ if (findingCount(lines) === 0 && result.services.length > 0) {
578
+ lines.push(done(`The hosted index answers for all ${result.services.length} indexed service(s).`));
579
+ }
580
+ for (const repo of result.repos ?? []) {
581
+ if (repo.changed_since_index > 0) {
582
+ lines.push(say(`${repo.name}: ${repo.changed_since_index} file(s) have changed since this index was built, and are answered from the tree, not the index.`));
583
+ }
584
+ // The path the CLI reported, not one this command resolved: two spellings
585
+ // of one directory are two directories to git as much as to anything else.
586
+ const commit = result.services.find((entry) => entry.repo === repo.repo)?.index_commit;
587
+ if (!commit)
588
+ continue;
589
+ const branch = git.defaultRemoteBranch(repo.repo);
590
+ if (branch === null)
591
+ continue;
592
+ const behind = git.commitsBetween(repo.repo, commit, branch);
593
+ if (behind === null) {
594
+ lines.push(say(`${repo.name}: indexed at ${short(commit)}, a commit this clone does not hold.`));
595
+ }
596
+ else if (behind > 0) {
597
+ lines.push(say(`${repo.name}: indexed at ${short(commit)}, ${behind} commit(s) behind ${branch}.`));
598
+ }
599
+ }
600
+ return lines;
601
+ }
602
+ function short(commit) {
603
+ return commit.slice(0, 7);
604
+ }
605
+ /** Print one check's lines through the shared renderer. */
606
+ function print(out, lines) {
607
+ for (const line of lines) {
608
+ if (line.level === "done")
609
+ out.done(line.text);
610
+ else if (line.level === "warn")
611
+ out.warn(line.text);
612
+ else if (line.level === "refuse")
613
+ out.refuse(line.text);
614
+ else
615
+ out.say(line.text);
616
+ }
617
+ }
618
+ export async function doctor(argv, out = createOutput()) {
619
+ const parsed = parseArgs(argv);
620
+ if (typeof parsed === "string") {
621
+ process.stdout.write(`${parsed}\n`);
622
+ return parsed.startsWith("carrick doctor") ? 0 : 2;
623
+ }
624
+ const { workspace } = parsed;
625
+ if (!fs.existsSync(workspace)) {
626
+ process.stderr.write(`carrick doctor: ${workspace} is not a directory on this machine\n`);
627
+ return 1;
628
+ }
629
+ const repos = configuredRepos(workspace);
630
+ const lines = [
631
+ ...checkDeclaredPaths(repos),
632
+ ...checkWorkflow(repos),
633
+ ...checkHooks(workspace, realMachine()),
634
+ ...checkMcp(inspectMcpClients()),
635
+ ...(await readIndex(workspace)),
636
+ ];
637
+ print(out, lines);
638
+ const findings = findingCount(lines);
639
+ if (findings === 0) {
640
+ out.say("");
641
+ out.say("Nothing to fix.");
642
+ return 0;
643
+ }
644
+ out.say("");
645
+ out.say(`${findings} finding(s) above, marked ▲ or ■. Nothing here was changed; ` +
646
+ `\`carrick doctor\` exits non-zero while any of them stand.`);
647
+ return 1;
648
+ }
649
+ /**
650
+ * `carrick status --json`, with a limit long enough for a cold read.
651
+ *
652
+ * The shared runner's default is five seconds, which is the editor's budget,
653
+ * not a person's: a status killed by it prints nothing at all and the check
654
+ * above would report a healthy index as unreadable (carrick#1036).
655
+ */
656
+ async function readIndex(workspace) {
657
+ const lookup = resolveNativeBinary();
658
+ if (!lookup.binary) {
659
+ return [refuse(lookup.problem ?? "The Carrick scanner is not installed, so the index cannot be read.")];
660
+ }
661
+ const outcome = await runStatus({
662
+ cwd: workspace,
663
+ workspace,
664
+ bin: lookup.binary,
665
+ env: { ...nativeEnv(), CARRICK_TIMEOUT_MS: "30000" },
666
+ });
667
+ return checkIndex(outcome.result, outcome.failure, realGit());
668
+ }
669
+ //# sourceMappingURL=doctor.js.map