@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 +2 -2
- package/bin/cli.js +32 -8
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/adopt.js +107 -23
- package/claude-plugin/scripts/cg-answer.js +16 -1
- package/claude-plugin/scripts/doctor.js +155 -17
- package/claude-plugin/scripts/lifecycle.js +291 -18
- package/claude-plugin/scripts/session-init.js +47 -9
- package/package.json +6 -6
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
25
|
-
"
|
|
26
|
-
"`code-graph-mcp unadopt` to remove it.\n"
|
|
27
|
-
: "code-graph-mcp unadopt — remove the code-graph
|
|
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`:
|
|
30
|
-
"
|
|
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({
|
|
@@ -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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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,
|
|
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]*?${
|
|
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
|
-
//
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
228
|
-
? hookResult.remaining
|
|
229
|
-
: hookResult.issues
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1132
|
-
|
|
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
|
-
|
|
1207
|
-
|
|
1208
|
-
//
|
|
1209
|
-
//
|
|
1210
|
-
|
|
1211
|
-
|
|
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
|
-
|
|
233
|
+
installReporting();
|
|
211
234
|
return 'installed';
|
|
212
235
|
}
|
|
213
236
|
if (manifest.version !== currentVersion) {
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
+
installReporting();
|
|
269
292
|
return 'self-healed-missing-settings-hook';
|
|
270
293
|
}
|
|
271
294
|
if (cov.stale.length > 0) {
|
|
272
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
40
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
42
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
43
|
-
"@sdsrs/code-graph-win32-x64": "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
|
}
|