@sdsrs/code-graph 0.107.0 → 0.108.1

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/README.md CHANGED
@@ -155,11 +155,11 @@ Then reconnect the MCP server in Claude Code with `/mcp`.
155
155
 
156
156
  By default, every user prompt the plugin deems code-related gets a small context injection from `code-graph` CLI output. If you'd rather rely on MEMORY.md + explicit tool calls, opt into invited-memory mode:
157
157
 
158
- 1. Adopt the plugin contract into your project's memory index (idempotent, self-heals):
158
+ 1. Adopt the plugin contract into your project (idempotent, self-heals):
159
159
  ```bash
160
160
  code-graph-mcp adopt
161
161
  ```
162
- This writes `plugin_code_graph_mcp.md` (decision rules) into `~/.claude/projects/<slug>/memory/` and links it from `MEMORY.md` inside a sentinel block. Run `code-graph-mcp unadopt` to remove.
162
+ This writes a sentinel-wrapped managed block into `<cwd>/CLAUDE.md` (auto-loaded each session) plus `<cwd>/.claude/plugin_code_graph_mcp.md` (the full decision table, opened on demand). Run `code-graph-mcp unadopt` to remove both; content outside the managed block is kept.
163
163
  2. Set the activation env var in `~/.claude/settings.json`:
164
164
  ```json
165
165
  {
package/bin/cli.js CHANGED
@@ -8,9 +8,26 @@ const path = require("path");
8
8
  process.env._FIND_BINARY_ROOT = path.resolve(__dirname, "..");
9
9
 
10
10
  // Intercept adopt / unadopt before forwarding — they're node-only concerns
11
- // (write to ~/.claude/projects/<slug>/memory/) and have no Rust counterpart.
11
+ // (write <cwd>/CLAUDE.md + <cwd>/.claude/) and have no Rust counterpart.
12
12
  // Lets `code-graph-mcp adopt` / `unadopt` work uniformly across plugin / npm / npx.
13
13
  const sub = process.argv[2];
14
+
15
+ // Reject unknown flags on the intercepted subcommands BEFORE doing their work.
16
+ // `--help` was already guarded, but every OTHER token was ignored, so
17
+ // `code-graph-mcp adopt --helpp` ran adopt and wrote the user's CLAUDE.md — a
18
+ // typo away from the very side effect the --help guard exists to prevent. This
19
+ // is the fourth entry point onto the same "ignore what you don't recognise"
20
+ // idiom (doctor.js, lifecycle.js doctor, src/main.rs were the first three); it
21
+ // is the npm/npx surface, so it is the one most users reach.
22
+ function rejectUnknownFlags(name, known) {
23
+ const unknown = process.argv.slice(3).filter((a) => !known.has(a));
24
+ if (unknown.length) {
25
+ process.stderr.write(
26
+ `code-graph-mcp ${name}: unknown argument(s): ${unknown.join(" ")}\n` +
27
+ `Run \`code-graph-mcp ${name} --help\` for usage.\n`);
28
+ process.exit(2);
29
+ }
30
+ }
14
31
  if (sub === "adopt" || sub === "unadopt") {
15
32
  // `--help`/`-h` must be side-effect-free: adopt() writes the memory file +
16
33
  // MEMORY.md sentinel, unadopt() removes them. The Rust binary guards this for
@@ -19,17 +36,23 @@ if (sub === "adopt" || sub === "unadopt") {
19
36
  // `code-graph-mcp adopt --help` rewrites MEMORY.md (the common new-user path).
20
37
  if (process.argv.slice(3).some((a) => a === "--help" || a === "-h")) {
21
38
  process.stdout.write(sub === "adopt"
22
- ? "code-graph-mcp adopt install the code-graph memory file + MEMORY.md sentinel\n\n" +
39
+ // Kept in sync with src/main.rs's adopt/unadopt help. This text described
40
+ // the pre-v0.74 scheme (a sentinel in the ~/.claude memory dir) for three
41
+ // releases after the target moved to the project's own CLAUDE.md, so npm
42
+ // users were told this command edits a file it has not touched since.
43
+ ? "code-graph-mcp adopt — install the code-graph steering block into the project CLAUDE.md\n\n" +
23
44
  "USAGE:\n code-graph-mcp adopt\n\n" +
24
- "Writes plugin_code_graph_mcp.md and a sentinel block into this project's\n" +
25
- "~/.claude memory so Claude Code auto-loads the decision table. Run\n" +
26
- "`code-graph-mcp unadopt` to remove it.\n"
27
- : "code-graph-mcp unadopt — remove the code-graph memory file + sentinel\n\n" +
45
+ "Writes a sentinel-wrapped managed block into <cwd>/CLAUDE.md plus a\n" +
46
+ "<cwd>/.claude/plugin_code_graph_mcp.md detail doc, so Claude Code loads the\n" +
47
+ "decision table each session. Run `code-graph-mcp unadopt` to remove it.\n"
48
+ : "code-graph-mcp unadopt — remove the code-graph steering block\n\n" +
28
49
  "USAGE:\n code-graph-mcp unadopt\n\n" +
29
- "Reverses `code-graph-mcp adopt`: deletes the memory file and the MEMORY.md\n" +
30
- "sentinel block. User content outside the sentinel is kept.\n");
50
+ "Reverses `code-graph-mcp adopt`: strips the managed block from\n" +
51
+ "<cwd>/CLAUDE.md and deletes <cwd>/.claude/plugin_code_graph_mcp.md.\n" +
52
+ "User content outside the sentinel is kept.\n");
31
53
  process.exit(0);
32
54
  }
55
+ rejectUnknownFlags(sub, new Set(["--help", "-h"]));
33
56
  const { adopt, unadopt, formatResult } = require("../claude-plugin/scripts/adopt");
34
57
  const result = sub === "unadopt" ? unadopt() : adopt();
35
58
  process.stdout.write(formatResult(sub, result) + "\n");
@@ -54,6 +77,7 @@ if (sub === "uninstall") {
54
77
  "`/plugin uninstall code-graph-mcp` in Claude Code to sync its UI.\n");
55
78
  process.exit(0);
56
79
  }
80
+ rejectUnknownFlags("uninstall", new Set(["--help", "-h", "--unadopt-all", "--purge-global"]));
57
81
  const lifecycle = require("../claude-plugin/scripts/lifecycle");
58
82
  const { unadopt } = require("../claude-plugin/scripts/adopt");
59
83
  const r = lifecycle.uninstall({
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.107.0",
7
+ "version": "0.108.1",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -20,7 +20,11 @@ const { PROJECT_MARKERS, isProjectRoot, isNonProjectCwd } = require('./project-d
20
20
  const SENTINEL_VERSION = 'v2';
21
21
  const SENTINEL_BEGIN = `<!-- code-graph-mcp:begin ${SENTINEL_VERSION} -->`;
22
22
  const SENTINEL_END = '<!-- code-graph-mcp:end -->';
23
- const SENTINEL_BEGIN_SRC = '<!-- code-graph-mcp:begin[^>]*-->';
23
+ // `[^>\n]*`, not `[^>]*`: an HTML comment marker cannot span lines, but the
24
+ // permissive class could — a marker truncated mid-write (`…:begin v2 --`) let
25
+ // the match run across newlines to whatever `-->` came next, swallowing every
26
+ // user line in between before any of the guards in stripSentinelBlock applied.
27
+ const SENTINEL_BEGIN_SRC = '<!-- code-graph-mcp:begin[^>\\n]*-->';
24
28
  // Marker on the first line of the installed .claude/plugin_code_graph_mcp.md so
25
29
  // unadopt/needsRefresh can distinguish our generated copy from a user's own file
26
30
  // of the same name (and so needsRefresh strips it before the bytewise compare).
@@ -32,11 +36,28 @@ const LEGACY_ADOPTED_BY = '<!-- adopted-by:';
32
36
  // half-written MEMORY.md / detail file — the dir is shared with claude-mem-lite,
33
37
  // which reads MEMORY.md on every keyword match. Mirrors lifecycle.js
34
38
  // writeJsonAtomic / auto-update.js binary promote; accepts a string or Buffer.
35
- function writeFileAtomic(filePath, data) {
36
- const tmp = filePath + '.tmp.' + process.pid;
39
+ // `followLink` is opt-in, and ONLY the CLAUDE.md read-modify-write passes it.
40
+ //
41
+ // `rename` REPLACES a symlink with a regular file. For CLAUDE.md that is wrong:
42
+ // we READ through the link, strip the block from what we read, and must write
43
+ // the result back to the same file — otherwise the shared target keeps the block
44
+ // while we report `blockPruned: true`, and the project silently gets a detached
45
+ // private copy.
46
+ //
47
+ // For every other caller it is the opposite. The detail file and the registry
48
+ // are whole-file REPLACEMENTS of content we own; following a link there turns
49
+ // "replace our symlink" into "overwrite whatever the user pointed it at". A
50
+ // first pass applied realpath to all four callers and created exactly that loss
51
+ // path — the fix for one caller became a bug in the others.
52
+ function writeFileAtomic(filePath, data, { followLink = false } = {}) {
53
+ let target = filePath;
54
+ if (followLink) {
55
+ try { target = fs.realpathSync(filePath); } catch { /* new file — no link to follow */ }
56
+ }
57
+ const tmp = target + '.tmp.' + process.pid;
37
58
  fs.writeFileSync(tmp, data);
38
59
  try {
39
- fs.renameSync(tmp, filePath);
60
+ fs.renameSync(tmp, target);
40
61
  } catch (e) {
41
62
  // rename can fail (ENOSPC / EACCES / EROFS on the dir). Don't orphan the
42
63
  // temp in the shared memory dir — mirror auto-update.js's binary promote,
@@ -304,23 +325,54 @@ function escapeRegex(s) {
304
325
 
305
326
  // Strip our sentinel block — well-formed first, then self-heal orphan begin/end.
306
327
  // Shared by adopt (so re-adopt rewrites a stale/malformed block) and unadopt.
328
+ //
329
+ // Every rule below exists because its absence deleted a user's prose. The file
330
+ // this runs against is the user's own CLAUDE.md, `unadopt` runs it over EVERY
331
+ // registered project via `uninstall({unadoptAll:true})`, and the only thing that
332
+ // distinguishes our block from their text is a string they are *invited* to
333
+ // write down — the block itself tells them not to edit inside it. So: never
334
+ // start a match at a marker we cannot prove we wrote, and when in doubt delete
335
+ // less. A stale block left behind is visible and removable; deleted prose is not.
307
336
  function stripSentinelBlock(text) {
337
+ // Line-anchored. A marker mentioned mid-sentence ("the block starts with
338
+ // `<!-- code-graph-mcp:begin v2 -->` — don't edit inside it") is prose, not a
339
+ // block opener; matching it ate everything from that sentence to the end of
340
+ // the real block below it.
341
+ const BEGIN_LINE = `^[ \\t]*${SENTINEL_BEGIN_SRC}[ \\t]*$`;
342
+ const END_LINE = `^[ \\t]*${escapeRegex(SENTINEL_END)}[ \\t]*$`;
343
+
308
344
  // Match ANY begin version (v1 legacy MEMORY.md block, v2 CLAUDE.md block) so a
309
345
  // single strip handles both the new target and the legacy migration cleanup.
346
+ //
347
+ // `(?:(?!BEGIN)[\s\S])*?` — the body may not contain another BEGIN. Without it
348
+ // a lazy `[\s\S]*?` still ANCHORS at the earliest BEGIN in the file, so a
349
+ // marker quoted on its own line (a fenced example, a note to a teammate) made
350
+ // the match start there and run through everything up to our real END.
310
351
  const wellFormed = new RegExp(
311
- `${SENTINEL_BEGIN_SRC}[\\s\\S]*?${escapeRegex(SENTINEL_END)}\\n?`, 'g'
352
+ `${BEGIN_LINE}(?:(?!${SENTINEL_BEGIN_SRC})[\\s\\S])*?${END_LINE}\\n?`,
353
+ 'gm'
312
354
  );
313
355
  let out = text.replace(wellFormed, '');
314
- // Orphan BEGIN with no matching END (truncation / partial edit).
315
- // Strip from BEGIN to the next blank line or EOF — the file may be shared with
316
- // claude-mem-lite (legacy MEMORY.md), so we must not eat past a blank-line boundary.
317
- if (new RegExp(SENTINEL_BEGIN_SRC).test(out)) {
318
- out = out.replace(
319
- new RegExp(`${SENTINEL_BEGIN_SRC}[\\s\\S]*?(?=\\n\\n|$)`, 'g'),
320
- ''
321
- );
322
- }
323
- // Orphan END line by itself.
356
+ // Orphan BEGIN with no matching END (truncation / partial edit): remove the
357
+ // MARKER LINE ONLY, never the content after it.
358
+ //
359
+ // This used to strip from the marker to the next blank line, on the theory
360
+ // that the content below a stray begin marker was our truncated block. It is
361
+ // not decidable. These two files are byte-for-byte the same shape:
362
+ //
363
+ // BEGIN / "- stale partial block" / "" / "Also keep me." <- our leftovers
364
+ // BEGIN / "KEEP: on-call rotation" / "" / "tail prose" <- their notes
365
+ //
366
+ // and the second one is what a user writes after reading "the block opens
367
+ // with <marker>". The blank-line bound protects nothing when their next line
368
+ // is prose. Since the two cases cannot be told apart, the tie goes to the
369
+ // outcome that is recoverable: a stale fragment left in the file is visible
370
+ // and the user can delete it; prose we deleted is gone. `isAdopted` requires a
371
+ // line-anchored BEGIN *and* END, so a leftover fragment does not block
372
+ // re-adoption — the next adopt writes a fresh, well-formed block.
373
+ const orphanBegin = new RegExp(`${BEGIN_LINE}\\n?`, 'gm');
374
+ out = out.replace(orphanBegin, '');
375
+ // Orphan END line by itself — same rule, one line, nothing around it.
324
376
  if (out.includes(SENTINEL_END)) {
325
377
  out = out.split('\n').filter(l => l.trim() !== SENTINEL_END).join('\n');
326
378
  }
@@ -422,7 +474,8 @@ function adopt({ cwd, templatePath, home } = {}) {
422
474
  const healed = exists && cleaned !== current;
423
475
  const base = cleaned.replace(/\n+$/, '');
424
476
  const prefix = base ? base + '\n\n' : '';
425
- writeFileAtomic(cPath, prefix + block + '\n');
477
+ // followLink: read-modify-write of a file the user may have symlinked.
478
+ writeFileAtomic(cPath, prefix + block + '\n', { followLink: true });
426
479
  recordAdopted(effectiveCwd, home);
427
480
  return { ok: true, detailPath: dPath, claudeMdPath: cPath, detailWritten, claudeMdWritten: true, created: !exists, healed };
428
481
  }
@@ -435,7 +488,11 @@ function isAdopted({ cwd } = {}) {
435
488
  const dPath = detailPath(effectiveCwd);
436
489
  if (!fs.existsSync(dPath) || !fs.existsSync(cPath)) return false;
437
490
  const c = fs.readFileSync(cPath, 'utf8');
438
- return new RegExp(SENTINEL_BEGIN_SRC).test(c) && c.includes(SENTINEL_END);
491
+ // Line-anchored for the same reason stripSentinelBlock is: a user quoting the
492
+ // markers in prose would otherwise read as adopted, and this gates the
493
+ // idempotent auto-adopt — so the block would never actually be written.
494
+ return new RegExp(`^[ \\t]*${SENTINEL_BEGIN_SRC}[ \\t]*$`, 'm').test(c)
495
+ && new RegExp(`^[ \\t]*${escapeRegex(SENTINEL_END)}[ \\t]*$`, 'm').test(c);
439
496
  }
440
497
 
441
498
  // shipped template / 管理块 与已落地版本出现漂移时返回 true。让已 install 的项目
@@ -454,7 +511,11 @@ function needsRefresh({ cwd, templatePath } = {}) {
454
511
  const current = fs.readFileSync(dPath);
455
512
  let body = current;
456
513
  const nl = current.indexOf(0x0a);
457
- if (nl > 0 && current.subarray(0, nl).toString().includes('managed-by: code-graph-mcp')) {
514
+ // Equality on the trimmed line, matching the unadopt guard. `includes` here
515
+ // was harmless in isolation (worst case: a spurious refresh) but it is the
516
+ // third spelling of the same predicate in this file, and the other two both
517
+ // turned out to be wrong — a loose one left behind is the next audit's finding.
518
+ if (nl > 0 && current.subarray(0, nl).toString().trim() === MANAGED_BY) {
458
519
  body = current.subarray(nl + 1);
459
520
  }
460
521
  if (!shipped.equals(body)) return true;
@@ -495,7 +556,15 @@ function migrateLegacyMemoryDir({ cwd, home } = {}) {
495
556
  if (fs.existsSync(legacyDetail)) {
496
557
  try {
497
558
  const head = fs.readFileSync(legacyDetail, 'utf8').split('\n', 1)[0];
498
- if (head.startsWith(LEGACY_ADOPTED_BY)) {
559
+ // Same guard as unadopt's (:636), and it must be — this is the MORE
560
+ // frequently executed of the two: maybeAutoAdopt calls migrate on every
561
+ // SessionStart, while unadopt is explicit. Tightening only unadopt's copy
562
+ // left the hot path deleting any user file whose first line merely began
563
+ // with `<!-- adopted-by:` — e.g. a note from someone else's tooling.
564
+ // The legacy marker carries a payload after the prefix, so it is matched
565
+ // as a whole-line HTML comment rather than by equality.
566
+ const h = head.trim();
567
+ if (h.startsWith(LEGACY_ADOPTED_BY) && h.endsWith('-->')) {
499
568
  fs.unlinkSync(legacyDetail);
500
569
  result.legacyDetailRemoved = true;
501
570
  }
@@ -507,7 +576,7 @@ function migrateLegacyMemoryDir({ cwd, home } = {}) {
507
576
  const before = fs.readFileSync(legacyIndex, 'utf8');
508
577
  const after = stripSentinelBlock(before);
509
578
  if (after !== before) {
510
- writeFileAtomic(legacyIndex, after);
579
+ writeFileAtomic(legacyIndex, after, { followLink: true });
511
580
  result.memoryIndexPruned = true;
512
581
  }
513
582
  } catch { /* unreadable → leave it */ }
@@ -561,7 +630,18 @@ function unadopt({ cwd, home } = {}) {
561
630
  let mine = false;
562
631
  try {
563
632
  const head = fs.readFileSync(dPath, 'utf8').split('\n', 1)[0];
564
- mine = head.includes(MANAGED_BY) || head.startsWith(LEGACY_ADOPTED_BY);
633
+ // `===`, not `includes`. adopt writes the marker as the ENTIRE first line
634
+ // (`${MANAGED_BY}\n` + template, :403), so equality is exactly as capable
635
+ // here — while `includes` unlinked any user file whose first line merely
636
+ // quoted the marker, e.g. a note about what this plugin writes.
637
+ // BOTH arms anchored. Tightening only the current marker left the legacy
638
+ // one as a loose startsWith, so a user file opening `<!-- adopted-by: …`
639
+ // was still unlinked — the same half-applied shape this batch keeps
640
+ // producing. The legacy marker carries a payload after the prefix, so it
641
+ // is matched as a whole-line HTML comment rather than by equality.
642
+ const h = head.trim();
643
+ mine = h === MANAGED_BY
644
+ || (h.startsWith(LEGACY_ADOPTED_BY) && h.endsWith('-->'));
565
645
  } catch { mine = false; }
566
646
  if (mine) { fs.unlinkSync(dPath); fileRemoved = true; }
567
647
  }
@@ -573,11 +653,15 @@ function unadopt({ cwd, home } = {}) {
573
653
  const after = stripSentinelBlock(before);
574
654
  if (after !== before) {
575
655
  blockPruned = true;
576
- if (after.trim() === '') {
656
+ // Only delete a file we could have created. A symlinked CLAUDE.md points
657
+ // at something the user owns (a shared team file, a dotfiles repo);
658
+ // unlinking it removes their link, and the "file we created" rationale
659
+ // does not apply. Write the stripped text through the link instead.
660
+ if (after.trim() === '' && !fs.lstatSync(cPath).isSymbolicLink()) {
577
661
  fs.unlinkSync(cPath);
578
662
  claudeMdRemoved = true;
579
663
  } else {
580
- writeFileAtomic(cPath, after);
664
+ writeFileAtomic(cPath, after, { followLink: true });
581
665
  }
582
666
  }
583
667
  }
@@ -20,7 +20,22 @@
20
20
 
21
21
  const { spawnSync } = require('child_process');
22
22
 
23
- const DEFAULT_TIMEOUT_MS = 2000;
23
+ // 2000 ms is a product decision, not a tuning knob: a PreToolUse hook that
24
+ // stalls longer than this costs the user more than the answer is worth, so the
25
+ // answer degrades instead.
26
+ //
27
+ // `_CG_ANSWER_TIMEOUT_MS` exists ONLY as a test seam, mirroring the
28
+ // `_CG_ANSWER_BINARY` override already used by the same tests. Without it the
29
+ // hint tests spawn a real `node` process under whatever load the machine
30
+ // happens to be under — a full cargo build saturating every core pushed cold
31
+ // node startup past 2 s and reddened `trackReadAndMaybeHint: fires on 5th read`
32
+ // roughly one run in seven, while 12/12 isolated runs passed. An intermittently
33
+ // red suite teaches people to re-run instead of read, which is the one habit
34
+ // this whole audit is about.
35
+ const DEFAULT_TIMEOUT_MS = (() => {
36
+ const override = Number(process.env._CG_ANSWER_TIMEOUT_MS);
37
+ return Number.isFinite(override) && override > 0 ? override : 2000;
38
+ })();
24
39
  // ~1000 tokens. A deny reason carrying more than this stops being an answer
25
40
  // and starts being a context tax.
26
41
  const DEFAULT_MAX_BYTES = 4000;
@@ -6,7 +6,7 @@ const path = require('path');
6
6
  const os = require('os');
7
7
  const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
8
8
  const {
9
- getPluginVersion, readJson, healthCheck, CACHE_DIR,
9
+ getPluginVersion, readJson, readJsonResult, healthCheck, scanForBrokenPaths, CACHE_DIR,
10
10
  settingsPath, surveyHookCoverage,
11
11
  installedGlobalPkgs, GLOBAL_INSTALL_MARKER, SHELL_PKG,
12
12
  } = require('./lifecycle');
@@ -57,7 +57,15 @@ function classifyEmbeddings(hc) {
57
57
  * Run all diagnostic checks. Returns an array of:
58
58
  * { name: string, status: 'ok'|'warn'|'error'|'skip', detail: string, fixId?: string }
59
59
  */
60
- function runDiagnostics() {
60
+ // `checkOnly` must reach here, not just formatReport. `--check-only` is a
61
+ // SHIPPED read-only contract (CHANGELOG v0.82.1: "it never reaches runRepairs"),
62
+ // but the write was never in runRepairs — `healthCheck()` below calls
63
+ // `install()`, which REBUILDS an unusable settings.json. Reproduced: under
64
+ // `--check-only`, a settings.json holding `{"model":"opus",}` went 36 B -> 3318 B
65
+ // with the model key gone, and the report then said "Run without --check-only to
66
+ // fix." A read-only mode that rewrites the user's config is worse than one that
67
+ // lies about it.
68
+ function runDiagnostics({ checkOnly = false } = {}) {
61
69
  const results = [];
62
70
  const binary = findBinary();
63
71
 
@@ -214,9 +222,28 @@ function runDiagnostics() {
214
222
  // returning clean. If repaired:false despite install() running, the
215
223
  // re-scan still found broken paths — surfacing 'remaining' makes that
216
224
  // honest instead of telling the user we fixed nothing.
217
- const hookResult = healthCheck();
225
+ // In check-only mode, SCAN without the auto-repair half of healthCheck().
226
+ const hookResult = checkOnly
227
+ ? (() => {
228
+ const issues = scanForBrokenPaths();
229
+ return { healthy: issues.length === 0, issues, repaired: false, rebuiltFrom: null };
230
+ })()
231
+ : healthCheck();
218
232
  if (hookResult.healthy) {
219
233
  results.push({ name: 'Hooks', status: 'ok', detail: 'all paths valid' });
234
+ } else if (hookResult.repaired && hookResult.rebuiltFrom) {
235
+ // The repair WORKED, but it worked by replacing an unusable settings.json
236
+ // with a freshly built one — the user's model / env / permissions / own
237
+ // hooks now exist only in the backup. Reporting that as `✅ auto-repaired`
238
+ // (which this did) describes a destructive event as a clean one and never
239
+ // names the file that holds their config.
240
+ results.push({
241
+ name: 'Hooks',
242
+ status: 'warn',
243
+ detail:
244
+ `settings.json was unusable and has been REBUILT — your original is at ` +
245
+ `${hookResult.rebuiltFrom}. Merge anything you need back by hand.`,
246
+ });
220
247
  } else if (hookResult.repaired) {
221
248
  results.push({
222
249
  name: 'Hooks',
@@ -224,13 +251,20 @@ function runDiagnostics() {
224
251
  detail: `${hookResult.issues.length} issue(s) auto-repaired`,
225
252
  });
226
253
  } else {
227
- const remainingCount = Array.isArray(hookResult.remaining)
228
- ? hookResult.remaining.length
229
- : hookResult.issues.length;
254
+ const remaining = Array.isArray(hookResult.remaining)
255
+ ? hookResult.remaining
256
+ : hookResult.issues;
257
+ // An unusable settings.json is not a broken PATH — auto-repair correctly
258
+ // refuses to touch the file, so "invalid path(s)" would send the user
259
+ // hunting for a missing script instead of at the file that actually needs
260
+ // repairing.
261
+ const unusable = remaining.find((i) => i.type === 'settings-unusable');
230
262
  results.push({
231
263
  name: 'Hooks',
232
264
  status: 'warn',
233
- detail: `${remainingCount} invalid path(s) — auto-repair did not resolve`,
265
+ detail: unusable
266
+ ? `settings.json unusable (${unusable.reason}) — hooks cannot be verified or repaired`
267
+ : `${remaining.length} invalid path(s) — auto-repair did not resolve`,
234
268
  fixId: 'hooks-invalid',
235
269
  });
236
270
  }
@@ -241,9 +275,24 @@ function runDiagnostics() {
241
275
  // registering them in settings.json. "Missing" is the bug (previously
242
276
  // "present" was treated as legacy debris — that was wrong).
243
277
  try {
244
- const settings = readJson(settingsPath()) || {};
278
+ // Sibling of the `scanForBrokenPaths` read above, and it was left on the old
279
+ // collapsed-`null` idiom: an unusable settings.json became `{}`, which has no
280
+ // hooks, so this reported "missing 6/6 settings.json entries" — a confident,
281
+ // wrong diagnosis sitting in the SAME table as the correct "settings.json
282
+ // unusable" line two rows up.
283
+ const settingsRead = readJsonResult(settingsPath());
284
+ const settings = settingsRead.value || {};
245
285
  const cov = surveyHookCoverage(settings);
246
- if (cov.missing.length === 0 && cov.stale.length === 0) {
286
+ if (settingsRead.corrupt) {
287
+ // No `fixId`: the repair is `install()`, which is already driven by the
288
+ // `Hooks` row above. Raising `missing-hooks-in-settings` here too would
289
+ // make doctor attempt the same repair twice and count the issue twice.
290
+ results.push({
291
+ name: 'Hook coverage',
292
+ status: 'warn',
293
+ detail: 'not determinable — settings.json could not be read or parsed',
294
+ });
295
+ } else if (cov.missing.length === 0 && cov.stale.length === 0) {
247
296
  results.push({
248
297
  name: 'Hook coverage',
249
298
  status: 'ok',
@@ -266,7 +315,17 @@ function runDiagnostics() {
266
315
  fixId: 'missing-hooks-in-settings',
267
316
  });
268
317
  }
269
- } catch { /* probe failed — skip */ }
318
+ } catch (err) {
319
+ // Do NOT swallow silently: this catch hid a plain ReferenceError (a helper
320
+ // that was not imported) by simply dropping the whole Hook-coverage row, so
321
+ // the table looked complete while a check had not run at all. A probe that
322
+ // cannot run is itself a finding.
323
+ results.push({
324
+ name: 'Hook coverage',
325
+ status: 'warn',
326
+ detail: `probe failed: ${err && err.message ? err.message : err}`,
327
+ });
328
+ }
270
329
 
271
330
  // 8. Hook firing (v0.67.0) — coverage (#7) proves the hook is WIRED into
272
331
  // settings.json; this proves the script actually RUNS. Spawns each
@@ -554,7 +613,7 @@ function runRepairs(results) {
554
613
  console.log('\n Repairing hooks...');
555
614
  if (relicRepairGuard()) break;
556
615
  const { install, scanForBrokenPaths } = require('./lifecycle');
557
- install();
616
+ const installResult = install();
558
617
  // Diagnosis already ran install()+re-scan and the paths were STILL
559
618
  // broken (that `repaired:false` is what raised hooks-invalid). Verify
560
619
  // this second attempt actually cleared them before counting it fixed \u2014
@@ -564,6 +623,24 @@ function runRepairs(results) {
564
623
  if (remaining.length === 0) {
565
624
  console.log(' \u2705 Hooks repaired \u2014 restart Claude Code to apply');
566
625
  fixed++;
626
+ } else if (installResult && installResult.settingsUnwritable) {
627
+ // `scanForBrokenPaths` cannot surface this one: the file READS fine, so
628
+ // it reports no `settings-unusable` issue and the branch below is
629
+ // skipped. Without this arm a chmod on ~/.claude was diagnosed as
630
+ // "plugin scripts may be missing — reinstall the npm package". The
631
+ // sibling arm at `missing-hooks-in-settings` learned the unwritable
632
+ // case in the previous round and this one did not; the two arms print
633
+ // about the same install() call and have to agree about why it failed.
634
+ console.log(' ❌ settings.json is not writable — hooks NOT repaired');
635
+ console.log(' Fix the permissions on it (or on ~/.claude) and re-run; see the error above.');
636
+ } else if (remaining.some((i) => i.type === 'settings-unusable')) {
637
+ // Same branch runDiagnostics needs, for the same reason. This arm only
638
+ // became REACHABLE for unusable settings once scanForBrokenPaths began
639
+ // reporting them, so it inherited a diagnosis written for a different
640
+ // cause \u2014 telling the user to reinstall an npm package because their
641
+ // settings.json has a permissions problem or a trailing comma.
642
+ console.log(' \u274c settings.json could not be read or parsed \u2014 hooks cannot be verified or repaired');
643
+ console.log(' Repair it (or move it aside) and re-run; see the error above.');
567
644
  } else {
568
645
  console.log(` \u274c ${remaining.length} hook path(s) still invalid \u2014 plugin scripts may be missing.`);
569
646
  console.log(' Reinstall: npm install -g @sdsrs/code-graph (or re-run the plugin installer)');
@@ -579,6 +656,21 @@ function runRepairs(results) {
579
656
  if (r.hooksRegistered) {
580
657
  console.log(' \u2705 settings.json updated — restart Claude Code to apply');
581
658
  fixed++;
659
+ } else if (r.settingsUnwritable) {
660
+ // Symmetric with the unreadable arm below. Round-5 finding: the
661
+ // unwritable case was wired into lifecycle's CLI but not here, so
662
+ // doctor printed "install reported no change (settings already had
663
+ // entries)" for a settings.json it had just failed to write.
664
+ console.log(' \u274c settings.json is not writable \u2014 hooks NOT registered');
665
+ console.log(' Fix the permissions on it (or on ~/.claude) and re-run; see the error above.');
666
+ } else if (r.settingsUnreadable) {
667
+ // install() refused because settings.json exists but cannot be turned
668
+ // into an object (unparseable / unreadable / not an object). Reporting
669
+ // "already had entries" here states the exact opposite of the truth,
670
+ // at the one moment the user most needs the real cause \u2014 which
671
+ // otherwise appears only on stderr, contradicted by this very line.
672
+ console.log(' \u274c settings.json could not be read or parsed \u2014 hooks NOT registered');
673
+ console.log(' Repair it (or move it aside) and re-run; see the error above.');
582
674
  } else {
583
675
  console.log(' \u2796 install reported no change (settings already had entries)');
584
676
  }
@@ -615,7 +707,7 @@ function unresolvedCount({ checkOnly, issueCount, fixed }) {
615
707
  }
616
708
 
617
709
  function runDoctor(opts = {}) {
618
- const results = runDiagnostics();
710
+ const results = runDiagnostics({ checkOnly: opts.checkOnly });
619
711
  console.log(formatReport(results, { checkOnly: opts.checkOnly }));
620
712
 
621
713
  const issues = results.filter(r => r.status === 'warn' || r.status === 'error');
@@ -632,11 +724,57 @@ function runDoctor(opts = {}) {
632
724
  return { results, issueCount: issues.length, unresolved };
633
725
  }
634
726
 
635
- module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, unresolvedCount, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, detectEmbedModel, devBuildCommand };
727
+ module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, runDoctorCli, parseDoctorArgs, unresolvedCount, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, detectEmbedModel, devBuildCommand };
728
+
729
+ // Shared by BOTH doctor entry points: `node doctor.js …` and `node lifecycle.js
730
+ // doctor …`. It exists as one function because the first version of this guard
731
+ // lived only in doctor.js's `require.main` block, leaving lifecycle's arm on the
732
+ // original `process.argv.includes('--check-only')` — so the exact bug being
733
+ // fixed (a typo'd flag running the repair pass) survived on the sibling entry
734
+ // point. Same half-applied shape this whole batch keeps producing.
735
+ //
736
+ // Returns `{ checkOnly }` to run, `{ help: true }` to print usage, or
737
+ // `{ error }` naming the offending arguments.
738
+ const DOCTOR_KNOWN_FLAGS = new Set(['--check-only', '--help', '-h']);
739
+ // Kept in sync with the `doctor` help text in src/main.rs, which intercepts
740
+ // `--help` before this script is spawned so that help stays side-effect-free.
741
+ // Two texts for one command drift silently; a user who reaches this one (direct
742
+ // `node doctor.js`) should read the same thing as one who reaches the other.
743
+ const DOCTOR_USAGE = [
744
+ 'code-graph-mcp doctor — diagnose and repair environment issues',
745
+ '',
746
+ 'USAGE:',
747
+ ' code-graph-mcp doctor [--check-only]',
748
+ '',
749
+ 'By default doctor repairs detected issues (re-registers hooks in',
750
+ '~/.claude/settings.json, fixes stale binary/model paths). Pass',
751
+ '--check-only to report issues without changing anything.',
752
+ ].join('\n');
753
+
754
+ function parseDoctorArgs(args) {
755
+ const unknown = args.filter((a) => !DOCTOR_KNOWN_FLAGS.has(a));
756
+ if (unknown.length) return { error: `doctor: unknown argument(s): ${unknown.join(' ')}` };
757
+ if (args.includes('--help') || args.includes('-h')) return { help: true };
758
+ return { checkOnly: args.includes('--check-only') };
759
+ }
760
+
761
+ // Run the CLI for a parsed argv tail and return the process exit code. Shared so
762
+ // the two entry points cannot drift on exit-code semantics either.
763
+ function runDoctorCli(args) {
764
+ const parsed = parseDoctorArgs(args);
765
+ if (parsed.error) {
766
+ console.error(parsed.error);
767
+ console.error(DOCTOR_USAGE);
768
+ return 2;
769
+ }
770
+ if (parsed.help) {
771
+ console.log(DOCTOR_USAGE);
772
+ return 0;
773
+ }
774
+ const { unresolved } = runDoctor({ checkOnly: parsed.checkOnly });
775
+ return unresolved > 0 ? 1 : 0;
776
+ }
636
777
 
637
778
  if (require.main === module) {
638
- const args = process.argv.slice(2);
639
- const checkOnly = args.includes('--check-only');
640
- const { unresolved } = runDoctor({ checkOnly });
641
- process.exit(unresolved > 0 ? 1 : 0);
779
+ process.exit(runDoctorCli(process.argv.slice(2)));
642
780
  }
@@ -39,10 +39,131 @@ function pluginsCacheDir() { return path.join(claudeHome(), 'plugins', 'cache');
39
39
 
40
40
  // --- Helpers ---
41
41
 
42
+ // Read JSON while keeping *why* it failed. The distinction that matters to a
43
+ // caller about to REBUILD the file is exactly one bit — "may I treat this as a
44
+ // fresh install?" — and only a genuine ENOENT earns a yes.
45
+ //
46
+ // missing: true ENOENT only. Nothing is there; rebuilding destroys nothing.
47
+ // corrupt: true Everything else: the file EXISTS and we could not turn it
48
+ // into a settings object — unparseable (trailing comma),
49
+ // unreadable (EACCES after a stray `sudo`, EPERM, EIO), a
50
+ // directory (EISDIR), or valid JSON that isn't an object
51
+ // (`null` / `[]` / `123` / `"str"`).
52
+ //
53
+ // Collapsing ANY of those into "absent" is the bug: `readJson(...) || {}` then
54
+ // hands install() an empty object and the next atomic write replaces the user's
55
+ // whole settings.json. The first version of this fix split out only the
56
+ // unparseable case and left the unreadable one behind — a `chmod 000`
57
+ // settings.json was still destroyed, silently, with no backup. `err.code` is the
58
+ // whole gate; do not widen it back to a bare `catch`.
59
+ function readJsonResult(filePath) {
60
+ // Read BYTES, decode separately. `readFileSync(p, 'utf8')` replaces every
61
+ // invalid byte with U+FFFD, and `raw` is what backupCorruptFile writes to the
62
+ // `.corrupt-*` copy before the original is overwritten — so a settings.json
63
+ // containing any non-UTF-8 byte (a latin-1 path, a stray BOM pair) was
64
+ // "preserved" as a lossy transcription and the true bytes were destroyed. The
65
+ // backup is the user's only copy; it has to be byte-exact.
66
+ let bytes;
67
+ let raw;
68
+ try {
69
+ bytes = fs.readFileSync(filePath);
70
+ raw = bytes.toString('utf8');
71
+ } catch (err) {
72
+ const missing = err && err.code === 'ENOENT';
73
+ return { value: null, missing, corrupt: !missing, error: err };
74
+ }
75
+ // A zero-byte file is what a crash mid-write leaves behind. It carries nothing
76
+ // to preserve, so back-up-then-rebuild would only litter `~/.claude` with an
77
+ // empty `.corrupt-*` copy; treat it as absent instead.
78
+ if (raw.trim() === '') {
79
+ return { value: null, missing: true, corrupt: false, raw: bytes };
80
+ }
81
+ try {
82
+ // Parse the TRIMMED text. A UTF-8 BOM is JS whitespace (so `.trim()` above
83
+ // strips it) but `JSON.parse` rejects it — and a BOM is exactly what
84
+ // PowerShell 5.1's `Out-File` / `Set-Content` write by default. Parsing the
85
+ // untrimmed string classified a perfectly valid settings.json as corrupt and
86
+ // rebuilt the live file from a backup-and-replace path it never needed.
87
+ const value = JSON.parse(raw.trim());
88
+ // `null` / `"str"` / `[]` parse fine but are not a settings object; treating
89
+ // them as "absent" would rebuild over them just the same.
90
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
91
+ return { value: null, missing: false, corrupt: true, raw: bytes };
92
+ }
93
+ return { value, missing: false, corrupt: false, raw: bytes };
94
+ } catch (err) {
95
+ return { value: null, missing: false, corrupt: true, raw: bytes, error: err };
96
+ }
97
+ }
98
+
99
+ // Lenient reader kept exactly as-is for its 20+ callers (manifests, registries,
100
+ // plugin.json …) where "absent" and "unreadable" genuinely mean the same thing.
101
+ // Only the settings write path needs readJsonResult's distinction.
42
102
  function readJson(filePath) {
43
103
  try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; }
44
104
  }
45
105
 
106
+ // Preserve a file we are about to overwrite but could not turn into settings.
107
+ // `raw` is its contents when we managed to read them; when we did not (EACCES,
108
+ // EISDIR) it is undefined and we fall back to a filesystem-level copy — which
109
+ // will usually fail for the same reason the read did, and that failure is the
110
+ // point: it makes the caller refuse rather than overwrite. Returns the backup
111
+ // path, or null when no copy could be made.
112
+ function backupCorruptFile(filePath, raw) {
113
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
114
+ const dest = `${filePath}.corrupt-${stamp}`;
115
+ try {
116
+ // Buffer, not string: see readJsonResult. A string here would re-encode.
117
+ if (Buffer.isBuffer(raw)) fs.writeFileSync(dest, raw);
118
+ else if (typeof raw === 'string') fs.writeFileSync(dest, Buffer.from(raw, 'utf8'));
119
+ else fs.copyFileSync(filePath, dest);
120
+ return dest;
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ // Read ~/.claude/settings.json for a caller that will WRITE it back.
127
+ //
128
+ // A settings.json that exists but yields no object used to be indistinguishable
129
+ // from an absent one, so `readJson(settingsPath()) || {}` handed
130
+ // install()/update() an empty object and the next atomic write replaced the
131
+ // user's model / env / permissions / enabledPlugins / own hooks with a two-key
132
+ // file — silently, with no copy left. Such a file is now copied aside first; if
133
+ // even the copy fails we refuse to touch the original and return null, and the
134
+ // caller skips its settings work entirely.
135
+ // Returns `{ settings, backedUpTo }`:
136
+ // settings — the object to write back, or null when we refused to touch the file
137
+ // backedUpTo — path of the preserved original when we are about to REBUILD over
138
+ // a file that had content, else null
139
+ //
140
+ // `backedUpTo` is not decoration. Rebuilding from `{}` REPLACES the user's whole
141
+ // settings.json, and callers report their outcome to a human: `doctor` was
142
+ // printing "Hooks ✅ 1 issue(s) auto-repaired" for a run that moved the user's
143
+ // model / env / permissions into a `.corrupt-*` file it never mentioned. A
144
+ // destructive repair has to be reported as one, with the path to get it back.
145
+ function readSettingsForWrite() {
146
+ const p = settingsPath();
147
+ const res = readJsonResult(p);
148
+ if (res.value) return { settings: res.value, backedUpTo: null };
149
+ if (res.missing) return { settings: {}, backedUpTo: null };
150
+ const why = res.error ? res.error.message : 'it does not contain a JSON object';
151
+ const backup = backupCorruptFile(p, res.raw);
152
+ if (!backup) {
153
+ console.error(
154
+ `[code-graph] cannot use ${p} (${why}), and no backup copy could be made. ` +
155
+ `Leaving it untouched and skipping settings changes — repair the file ` +
156
+ `(or move it aside) and re-run.`
157
+ );
158
+ return { settings: null, backedUpTo: null };
159
+ }
160
+ console.error(
161
+ `[code-graph] cannot use ${p} (${why}). ` +
162
+ `Saved the original to ${backup} before rebuilding it.`
163
+ );
164
+ return { settings: {}, backedUpTo: backup };
165
+ }
166
+
46
167
  function writeJsonAtomic(filePath, data) {
47
168
  const dir = path.dirname(filePath);
48
169
  fs.mkdirSync(dir, { recursive: true });
@@ -51,6 +172,25 @@ function writeJsonAtomic(filePath, data) {
51
172
  fs.renameSync(tmp, filePath);
52
173
  }
53
174
 
175
+ // A settings.json we can READ but not WRITE (read-only ~/.claude, EROFS, a
176
+ // container mount) used to escape as a raw fs stack trace with no `[code-graph]`
177
+ // line — and because nothing of ours got registered, the follow-up `health` then
178
+ // reported "OK — all paths valid", since it only checks that the paths it FINDS
179
+ // are valid and it found none. Same class as the unreadable-file arm: report the
180
+ // real cause, change nothing, and make the caller's exit code non-zero.
181
+ function tryWriteSettings(settings) {
182
+ try {
183
+ writeJsonAtomic(settingsPath(), settings);
184
+ return null;
185
+ } catch (err) {
186
+ console.error(
187
+ `[code-graph] cannot write ${settingsPath()} (${err.code || err.name}: ${err.message}). ` +
188
+ `Nothing was changed. The plugin stays inactive until the file is writable.`
189
+ );
190
+ return err;
191
+ }
192
+ }
193
+
54
194
  function readManifest() {
55
195
  return readJson(MANIFEST_FILE) || { version: null, config: {} };
56
196
  }
@@ -451,7 +591,19 @@ function buildSettingsHookEntries() {
451
591
  // in plugin-cache hooks.json (it's still loaded from there), so we don't
452
592
  // re-write it to settings.json.
453
593
  function registerHooksToSettings(settings) {
454
- settings.hooks = settings.hooks || {};
594
+ // `hooks` must be a plain object. `settings.hooks || {}` accepted an ARRAY —
595
+ // and every named property assigned onto it below (`hooks.PreToolUse = [...]`)
596
+ // is silently dropped by JSON.stringify, which serializes an array by index.
597
+ // The result was total, reported-as-success inertness: `install` printed
598
+ // "Installed | settings=true", `health` printed "OK — all paths valid", and
599
+ // `"hooks": []` came back out with zero of our six hooks registered. A string
600
+ // or number was worse — an uncaught "Cannot create property on string".
601
+ // Anything non-object is replaced, same as a missing key: we cannot merge into
602
+ // a shape the schema does not allow, and leaving it means the plugin never
603
+ // works while claiming it does.
604
+ if (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)) {
605
+ settings.hooks = {};
606
+ }
455
607
 
456
608
  // Idempotent across delivery surfaces: if every desired (event,matcher) is
457
609
  // already present exactly once, pointing at a current, existing script
@@ -693,7 +845,19 @@ function verifyHooksFire({ hooks, env, timeoutMs = 4000, tmpBase } = {}) {
693
845
  function install({ reclaimStatusline = false } = {}) {
694
846
  const version = getPluginVersion();
695
847
  const manifest = readManifest();
696
- const settings = readJson(settingsPath()) || {};
848
+ const { settings, backedUpTo } = readSettingsForWrite();
849
+ if (!settings) {
850
+ // Unusable settings.json that we could not even copy aside. Bail without
851
+ // touching it — and without stamping the manifest, so the next run retries
852
+ // the whole install once the user has repaired the file.
853
+ return {
854
+ version,
855
+ settingsChanged: false,
856
+ statusLineClaimed: manifest.config.statusLine,
857
+ hooksRegistered: false,
858
+ settingsUnreadable: true,
859
+ };
860
+ }
697
861
  let settingsChanged = false;
698
862
 
699
863
  // 0. Migrate from old plugin IDs
@@ -768,7 +932,20 @@ function install({ reclaimStatusline = false } = {}) {
768
932
 
769
933
  // 3. Write settings atomically if changed
770
934
  if (settingsChanged) {
771
- writeJsonAtomic(settingsPath(), settings);
935
+ const writeErr = tryWriteSettings(settings);
936
+ if (writeErr) {
937
+ // Do NOT fall through to the manifest stamp. A manifest carrying the
938
+ // current version tells the next run "already installed", so it would skip
939
+ // the retry and the plugin would stay inactive after the user makes the
940
+ // file writable again — the same trap as the unreadable arm.
941
+ return {
942
+ version,
943
+ settingsChanged: false,
944
+ hooksRegistered: false,
945
+ settingsUnwritable: true,
946
+ error: writeErr.code || writeErr.name,
947
+ };
948
+ }
772
949
  }
773
950
 
774
951
  // 4. Write manifest with version
@@ -777,7 +954,14 @@ function install({ reclaimStatusline = false } = {}) {
777
954
  manifest.updatedAt = new Date().toISOString();
778
955
  writeManifest(manifest);
779
956
 
780
- return { version, settingsChanged, statusLineClaimed: manifest.config.statusLine, hooksRegistered };
957
+ return {
958
+ version,
959
+ settingsChanged,
960
+ statusLineClaimed: manifest.config.statusLine,
961
+ hooksRegistered,
962
+ // Non-null => the previous settings.json was REPLACED and lives here now.
963
+ settingsRebuiltFrom: backedUpTo,
964
+ };
781
965
  }
782
966
 
783
967
  // --- Uninstall (clean all config) ---
@@ -910,7 +1094,10 @@ function update() {
910
1094
  const version = getPluginVersion();
911
1095
  const manifest = readManifest();
912
1096
  const oldVersion = manifest.version;
913
- const settings = readJson(settingsPath()) || {};
1097
+ const { settings, backedUpTo } = readSettingsForWrite();
1098
+ if (!settings) {
1099
+ return { oldVersion, version, settingsChanged: false, hooksRegistered: false, settingsUnreadable: true };
1100
+ }
914
1101
  let settingsChanged = false;
915
1102
 
916
1103
  // 0. Migrate from old plugin IDs
@@ -939,7 +1126,18 @@ function update() {
939
1126
 
940
1127
  // 4. Write settings if changed
941
1128
  if (settingsChanged) {
942
- writeJsonAtomic(settingsPath(), settings);
1129
+ const writeErr = tryWriteSettings(settings);
1130
+ if (writeErr) {
1131
+ // Same reasoning as install(): stamping the manifest here would make the
1132
+ // next run believe the update landed.
1133
+ return {
1134
+ oldVersion, version,
1135
+ settingsChanged: false,
1136
+ hooksRegistered: false,
1137
+ settingsUnwritable: true,
1138
+ error: writeErr.code || writeErr.name,
1139
+ };
1140
+ }
943
1141
  }
944
1142
 
945
1143
  // 5. Clear update-check cache (force re-check after update)
@@ -959,7 +1157,7 @@ function update() {
959
1157
  // therefore skips any version still referenced by a live process cmdline.
960
1158
  cleanupOldCacheVersions(5);
961
1159
 
962
- return { oldVersion, version, settingsChanged, hooksRegistered };
1160
+ return { oldVersion, version, settingsChanged, hooksRegistered, settingsRebuiltFrom: backedUpTo };
963
1161
  }
964
1162
 
965
1163
  /**
@@ -1050,7 +1248,29 @@ function readActiveProcessCmdlines() {
1050
1248
  // empty array means repair succeeded
1051
1249
 
1052
1250
  function scanForBrokenPaths() {
1053
- const settings = readJson(settingsPath()) || {};
1251
+ // The READ-side member of the same collapsed-`null` class the write side was
1252
+ // fixed for: `readJson(...) || {}` on an unusable settings.json yields an
1253
+ // empty object, every loop below finds nothing to check, and the caller
1254
+ // reports "all paths valid" — the most confidently wrong answer available,
1255
+ // delivered during the exact incident it should be flagging. Surface it as an
1256
+ // issue instead.
1257
+ //
1258
+ // NOT auto-repairable in the sense that this issue carries no `fixId`. Be
1259
+ // precise about what `install()` then does, because an earlier version of this
1260
+ // comment claimed it "correctly refuses this file" and that is only half true:
1261
+ // it refuses only the subset it cannot copy aside (unreadable file, read-only
1262
+ // dir, path-is-a-directory). For the common case — unparseable but writable —
1263
+ // it BACKS UP and REBUILDS, so `healthCheck` reports `repaired: true` and hands
1264
+ // back `rebuiltFrom`. Callers must render that as the destructive repair it is.
1265
+ const settingsRead = readJsonResult(settingsPath());
1266
+ if (settingsRead.corrupt) {
1267
+ return [{
1268
+ type: 'settings-unusable',
1269
+ path: settingsPath(),
1270
+ reason: settingsRead.error ? settingsRead.error.message : 'not a JSON object',
1271
+ }];
1272
+ }
1273
+ const settings = settingsRead.value || {};
1054
1274
  const issues = [];
1055
1275
 
1056
1276
  // Check statusLine path
@@ -1101,13 +1321,17 @@ function healthCheck() {
1101
1321
  // away. install() may legitimately fail to resolve a problem (binary path
1102
1322
  // permanently gone, registry corrupted, etc.) and the previous code lied
1103
1323
  // by always returning repaired:true.
1104
- install();
1324
+ const r = install();
1105
1325
  const remaining = scanForBrokenPaths();
1106
1326
  return {
1107
1327
  healthy: false,
1108
1328
  issues,
1109
1329
  repaired: remaining.length === 0,
1110
1330
  remaining,
1331
+ // A "repair" that rebuilt an unusable settings.json REPLACED the user's
1332
+ // file — model / env / permissions / their own hooks now live only in the
1333
+ // backup. Callers must not render that as a plain success.
1334
+ rebuiltFrom: r.settingsRebuiltFrom || null,
1111
1335
  };
1112
1336
  }
1113
1337
 
@@ -1127,16 +1351,49 @@ function isPluginUninstalled(settings = readJson(settingsPath()) || {}) {
1127
1351
  // this so a CC `/plugin uninstall` (which fires no uninstall hook) still reclaims
1128
1352
  // the disk. Idempotent: rm is force, so repeat SessionStarts are no-ops. Does NOT
1129
1353
  // touch the plugin-cache script dirs — those are CC-managed and may be executing.
1354
+ // One thing in CACHE_DIR is NOT residue: adopted-projects.json, the only record
1355
+ // of which repos carry a managed CLAUDE.md block. Wiping it strands every block
1356
+ // — `uninstall({unadoptAll:true})` afterwards reads an empty registry, reports
1357
+ // `unadopted: []`, and the blocks stay in the user's repos with nothing left
1358
+ // that knows where they are. `uninstall()` captures the list before calling
1359
+ // here; `cleanupDisabledStatusline` does not, and by this function's own comment
1360
+ // that is the ONE path guaranteed to run after `/plugin uninstall`. So the
1361
+ // preservation belongs here, at the wipe, rather than at each caller — the same
1362
+ // "fix it at the shared layer, not per surface" the <external> query filter
1363
+ // needed.
1130
1364
  function removeCacheResidue() {
1131
- try { fs.rmSync(CACHE_DIR, { recursive: true, force: true }); return true; }
1132
- catch { return false; }
1365
+ // Path comes from adopt.js rather than a second spelling of the basename
1366
+ // a literal here would silently stop matching the day adopt.js renames it,
1367
+ // and the failure mode is exactly the data loss this guard exists to stop.
1368
+ // Preserve ONLY when the registry still names projects. A registry that is
1369
+ // absent, empty, or already fully unadopted (the normal SessionStart teardown
1370
+ // order, which unadopts first) strands nothing, and re-creating CACHE_DIR to
1371
+ // hold `[]` would just be new residue.
1372
+ let registryPath = null;
1373
+ let registry = null;
1374
+ try {
1375
+ registryPath = require('./adopt').adoptedRegistryFile();
1376
+ const raw = fs.existsSync(registryPath) ? fs.readFileSync(registryPath) : null;
1377
+ const parsed = raw ? JSON.parse(raw.toString('utf8')) : null;
1378
+ if (Array.isArray(parsed) && parsed.length) registry = raw;
1379
+ } catch { /* POSIX-only helper, unreadable, or corrupt — nothing to preserve */ }
1380
+ try {
1381
+ fs.rmSync(CACHE_DIR, { recursive: true, force: true });
1382
+ } catch { return false; }
1383
+ if (registryPath && registry) {
1384
+ try {
1385
+ fs.mkdirSync(path.dirname(registryPath), { recursive: true });
1386
+ fs.writeFileSync(registryPath, registry);
1387
+ } catch { /* best-effort: the binary is still reclaimed */ }
1388
+ }
1389
+ return true;
1133
1390
  }
1134
1391
 
1135
1392
  module.exports = {
1136
1393
  install, uninstall, update, healthCheck, scanForBrokenPaths, checkScopeConflict,
1137
1394
  isPluginExplicitlyDisabled, isPluginInactive, isPluginUninstalled, removeCacheResidue,
1138
1395
  cleanupDisabledStatusline,
1139
- readManifest, readJson, writeJsonAtomic,
1396
+ readManifest, readJson, readJsonResult, readSettingsForWrite, writeJsonAtomic,
1140
1397
  readRegistry, writeRegistry,
1141
1398
  getPluginVersion, cleanupOldCacheVersions,
1142
1399
  removeHooksFromSettings, isOurHookEntry,
@@ -1158,6 +1415,15 @@ if (require.main === module) {
1158
1415
  if (cmd === 'install') {
1159
1416
  // Explicit CLI install = user intent: reset any statusline stand-down and re-claim.
1160
1417
  const r = install({ reclaimStatusline: true });
1418
+ // Refusing to touch an unusable settings.json means NOTHING was installed —
1419
+ // no hooks, no statusline, no manifest stamp. Printing "Installed" and
1420
+ // exiting 0 there would make `lifecycle.js install && …` chains read the
1421
+ // refusal as success; the true diagnosis is already on stderr.
1422
+ if (r.settingsUnreadable || r.settingsUnwritable) {
1423
+ const why = r.settingsUnreadable ? 'unusable' : 'not writable';
1424
+ console.log(`Not installed: ${settingsPath()} is ${why} (see the error above). Nothing was changed.`);
1425
+ process.exit(1);
1426
+ }
1161
1427
  console.log(`Installed v${r.version} | settings=${r.settingsChanged} | statusLine=${r.statusLineClaimed}`);
1162
1428
  } else if (cmd === 'uninstall') {
1163
1429
  const r = uninstall({
@@ -1191,6 +1457,11 @@ if (require.main === module) {
1191
1457
  console.log(' Note: also run `/plugin uninstall code-graph-mcp` inside Claude Code to sync its UI state.');
1192
1458
  } else if (cmd === 'update') {
1193
1459
  const r = update();
1460
+ if (r.settingsUnreadable || r.settingsUnwritable) {
1461
+ const why = r.settingsUnreadable ? 'unusable' : 'not writable';
1462
+ console.log(`Not updated: ${settingsPath()} is ${why} (see the error above). Nothing was changed.`);
1463
+ process.exit(1);
1464
+ }
1194
1465
  console.log(`Updated ${r.oldVersion} → ${r.version} | settings=${r.settingsChanged}`);
1195
1466
  } else if (cmd === 'health') {
1196
1467
  const r = healthCheck();
@@ -1203,12 +1474,14 @@ if (require.main === module) {
1203
1474
  }
1204
1475
  }
1205
1476
  } else if (cmd === 'doctor') {
1206
- const { runDoctor } = require('./doctor');
1207
- const checkOnly = process.argv.includes('--check-only');
1208
- // Exit on issues that remain UNRESOLVED after repair, not issues found —
1209
- // a run that fixes everything exits 0. See unresolvedCount in doctor.js.
1210
- const { unresolved } = runDoctor({ checkOnly });
1211
- process.exit(unresolved > 0 ? 1 : 0);
1477
+ // Delegate to doctor.js's shared CLI so the two entry points cannot drift on
1478
+ // flag validation or exit codes. This arm used to do its own
1479
+ // `process.argv.includes('--check-only')`, which silently ignored every other
1480
+ // argument `lifecycle.js doctor --check-onlyy` ran the full repair pass.
1481
+ // Exit code still reflects issues that remain UNRESOLVED after repair, not
1482
+ // issues found (see unresolvedCount in doctor.js).
1483
+ const { runDoctorCli } = require('./doctor');
1484
+ process.exit(runDoctorCli(process.argv.slice(3)));
1212
1485
  } else if (cmd === 'verify-hooks-fire') {
1213
1486
  // v0.67.0 — Layer-A firing self-test. Spawned detached by session-init
1214
1487
  // (off the SessionStart budget); writes a small state file the next
@@ -193,6 +193,29 @@ function isHighIntentSource(source) {
193
193
  return source !== 'compact';
194
194
  }
195
195
 
196
+ // Every install()/update() in this file goes through these wrappers so the
197
+ // "your settings.json was rebuilt" notice cannot be wired into some call sites
198
+ // and not others — there are seven, and the previous fix wired the notice only
199
+ // into `doctor`, the path a user runs deliberately, while THIS path runs on
200
+ // every SessionStart and stayed silent.
201
+ //
202
+ // The notice goes to STDOUT on purpose. lifecycle.js already logs it to stderr,
203
+ // which a SessionStart hook discards — so from the user's side their model /
204
+ // env / permissions vanished with no message at all. stdout is the channel
205
+ // Claude Code surfaces (same one injectProjectMap uses).
206
+ function reportRebuild(r) {
207
+ if (r && r.settingsRebuiltFrom) {
208
+ process.stdout.write(
209
+ `[code-graph] ${settingsPath()} could not be parsed and has been REBUILT. ` +
210
+ `Your original is saved at ${r.settingsRebuiltFrom} — merge anything you ` +
211
+ `still need (model / env / permissions / your own hooks) back by hand.\n`
212
+ );
213
+ }
214
+ return r;
215
+ }
216
+ function installReporting(...args) { return reportRebuild(install(...args)); }
217
+ function updateReporting(...args) { return reportRebuild(update(...args)); }
218
+
196
219
  function syncLifecycleConfig() {
197
220
  // v0.49.1: stale-relic guard. A still-running Claude Code process fires
198
221
  // SessionStart from the plugin-cache dir it loaded at startup; after
@@ -207,11 +230,11 @@ function syncLifecycleConfig() {
207
230
  const currentVersion = getPluginVersion();
208
231
 
209
232
  if (!manifest.version) {
210
- install();
233
+ installReporting();
211
234
  return 'installed';
212
235
  }
213
236
  if (manifest.version !== currentVersion) {
214
- update();
237
+ updateReporting();
215
238
  return 'updated';
216
239
  }
217
240
  // Self-heal: version matches but statusLine may have been lost or path corrupted
@@ -220,13 +243,13 @@ function syncLifecycleConfig() {
220
243
  const settings = readJson(settingsPath()) || {};
221
244
  if (!settings.statusLine || !settings.statusLine.command ||
222
245
  !settings.statusLine.command.includes('statusline-composite')) {
223
- install();
246
+ installReporting();
224
247
  return 'self-healed';
225
248
  }
226
249
  // Also self-heal if composite path points to a non-existent script (path pollution)
227
250
  const scriptMatch = settings.statusLine.command.match(/node\s+"([^"]+)"/);
228
251
  if (scriptMatch && scriptMatch[1] && !fs.existsSync(scriptMatch[1])) {
229
- install();
252
+ installReporting();
230
253
  return 'self-healed-bad-path';
231
254
  }
232
255
  // v0.49.1: also self-heal when the composite path exists but is not the one
@@ -234,7 +257,7 @@ function syncLifecycleConfig() {
234
257
  // invisible to the existence check above; same fault class as the binary pin).
235
258
  const { compositeCommand } = require('./lifecycle');
236
259
  if (settings.statusLine.command !== compositeCommand()) {
237
- install();
260
+ installReporting();
238
261
  return 'self-healed-stale-statusline';
239
262
  }
240
263
  // Self-heal if any hook command points to a non-existent script (path pollution)
@@ -246,7 +269,7 @@ function syncLifecycleConfig() {
246
269
  for (const h of entry.hooks) {
247
270
  const m = h.command && h.command.match(/node\s+"([^"]+)"/);
248
271
  if (m && m[1] && m[1].includes('code-graph') && !fs.existsSync(m[1])) {
249
- install();
272
+ installReporting();
250
273
  return 'self-healed-bad-hook';
251
274
  }
252
275
  }
@@ -265,11 +288,11 @@ function syncLifecycleConfig() {
265
288
  const { surveyHookCoverage } = require('./lifecycle');
266
289
  const cov = surveyHookCoverage(settings);
267
290
  if (cov.missing.length > 0) {
268
- install();
291
+ installReporting();
269
292
  return 'self-healed-missing-settings-hook';
270
293
  }
271
294
  if (cov.stale.length > 0) {
272
- install();
295
+ installReporting();
273
296
  return 'self-healed-stale-settings-hook';
274
297
  }
275
298
  return 'noop';
@@ -489,7 +512,21 @@ function runSessionInit({ source } = {}) {
489
512
  // only place project unadoption can happen automatically.
490
513
  let teardown = null;
491
514
  if (uninstalled) {
492
- const cacheRemoved = removeCacheResidue();
515
+ // Unadopt BEFORE the wipe. The adopted-projects registry lives inside
516
+ // CACHE_DIR, so wiping first destroyed the record of every OTHER adopted
517
+ // repo — this branch only unadopts the current cwd, and a later
518
+ // `uninstall --unadopt-all` then read an empty registry and reported
519
+ // `unadopted: []` while the blocks stayed behind. Same capture-before-
520
+ // cleanup ordering `uninstall()` already had to learn; this was its
521
+ // sibling.
522
+ //
523
+ // Honest scope: this reorder alone does NOT save the registry. On the
524
+ // genuine post-uninstall path `cleanupDisabledStatusline()` runs earlier
525
+ // in this same function and already calls removeCacheResidue() itself, so
526
+ // the wipe still happens before we get here. What actually protects the
527
+ // other projects is the preservation inside removeCacheResidue(); this
528
+ // ordering is the belt to that pair of braces, and it is what keeps the
529
+ // single-project case from depending on the preservation at all.
493
530
  let unadopted = false;
494
531
  try {
495
532
  if (!isNonProjectCwd(process.cwd())) {
@@ -497,6 +534,7 @@ function runSessionInit({ source } = {}) {
497
534
  unadopted = !!(r && (r.blockPruned || r.fileRemoved || r.claudeMdRemoved));
498
535
  }
499
536
  } catch { /* best-effort — never let teardown break SessionStart */ }
537
+ const cacheRemoved = removeCacheResidue();
500
538
  teardown = { cacheRemoved, unadopted };
501
539
  }
502
540
  return { inactive: true, lifecycle: 'noop', autoUpdateLaunched: false, teardown };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.107.0",
3
+ "version": "0.108.1",
4
4
  "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,10 +36,10 @@
36
36
  "node": ">=16"
37
37
  },
38
38
  "optionalDependencies": {
39
- "@sdsrs/code-graph-linux-x64": "0.107.0",
40
- "@sdsrs/code-graph-linux-arm64": "0.107.0",
41
- "@sdsrs/code-graph-darwin-x64": "0.107.0",
42
- "@sdsrs/code-graph-darwin-arm64": "0.107.0",
43
- "@sdsrs/code-graph-win32-x64": "0.107.0"
39
+ "@sdsrs/code-graph-linux-x64": "0.108.1",
40
+ "@sdsrs/code-graph-linux-arm64": "0.108.1",
41
+ "@sdsrs/code-graph-darwin-x64": "0.108.1",
42
+ "@sdsrs/code-graph-darwin-arm64": "0.108.1",
43
+ "@sdsrs/code-graph-win32-x64": "0.108.1"
44
44
  }
45
45
  }