@sdsrs/code-graph 0.115.0 → 0.117.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -15
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/adopt.js +210 -50
- package/claude-plugin/scripts/auto-update.js +88 -6
- package/claude-plugin/scripts/doctor.js +127 -5
- package/claude-plugin/scripts/hook-emit.js +37 -9
- package/claude-plugin/scripts/lifecycle.js +169 -28
- package/claude-plugin/scripts/pr-impact-comment.js +33 -1
- package/claude-plugin/scripts/pre-edit-guide.js +16 -8
- package/claude-plugin/scripts/proc-opts.js +15 -0
- package/claude-plugin/scripts/session-init.js +44 -2
- package/claude-plugin/scripts/statusline-chain.js +17 -1
- package/claude-plugin/scripts/statusline-composite.js +3 -0
- package/claude-plugin/scripts/statusline.js +3 -0
- package/claude-plugin/templates/code-graph-snapshot.yml +8 -3
- package/claude-plugin/templates/plugin_code_graph_mcp.md +14 -6
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ A high-performance code knowledge graph server implementing the [Model Context P
|
|
|
22
22
|
- **Embedding model** — Optional local embedding via Candle (feature-gated `embed-model`). Context reordered to prioritize structural relations over code for better embedding quality
|
|
23
23
|
- **Self-healing** — Automatic SQLite corruption recovery with rebuild. Startup repair for incomplete indexing (Phase 3 failures)
|
|
24
24
|
- **MCP protocol** — JSON-RPC 2.0 over stdio, plug-and-play with Claude Code, Cursor, Windsurf, and other MCP clients
|
|
25
|
-
- **Claude Code Plugin** — First-class plugin with
|
|
25
|
+
- **Claude Code Plugin** — First-class plugin with skills (`explore`, `index`), a `code-explorer` agent, auto-indexing hooks, StatusLine integration, and self-updating
|
|
26
26
|
|
|
27
27
|
## Why code-graph-mcp?
|
|
28
28
|
|
|
@@ -113,7 +113,7 @@ src/
|
|
|
113
113
|
│ └── server/ # McpServer with IndexingState + CacheState sub-structs
|
|
114
114
|
├── parser/ # Tree-sitter parsing, relation extraction, LanguageConfig dispatch
|
|
115
115
|
├── indexer/ # 3-phase pipeline, Merkle tree, file watcher
|
|
116
|
-
├── storage/ # SQLite schema (
|
|
116
|
+
├── storage/ # SQLite schema (v10), CRUD, FTS5, migrations
|
|
117
117
|
├── graph/ # Recursive CTE call graph queries
|
|
118
118
|
├── search/ # RRF fusion search combining BM25 + vector
|
|
119
119
|
├── embedding/ # Candle embedding model (optional, masked mean pooling)
|
|
@@ -125,7 +125,7 @@ src/
|
|
|
125
125
|
|
|
126
126
|
### Option 1: Claude Code Plugin (Recommended)
|
|
127
127
|
|
|
128
|
-
Install as a Claude Code plugin for the best experience — includes
|
|
128
|
+
Install as a Claude Code plugin for the best experience — includes skills, the `code-explorer` agent, auto-indexing hooks, StatusLine health display, and automatic updates:
|
|
129
129
|
|
|
130
130
|
```bash
|
|
131
131
|
# Step 1: Add the marketplace
|
|
@@ -137,11 +137,11 @@ Install as a Claude Code plugin for the best experience — includes slash comma
|
|
|
137
137
|
|
|
138
138
|
What you get:
|
|
139
139
|
- **MCP Server** — All code-graph tools available to Claude
|
|
140
|
-
- **
|
|
140
|
+
- **Skills** — `explore` (structure-first navigation before reading files) and `index` (health-check / re-index / full rebuild); see [Plugin Skills](#plugin-skills)
|
|
141
141
|
- **Code Explorer Agent** — Deep code understanding expert via `code-explorer`
|
|
142
142
|
- **Auto-indexing Hook** — Incremental index on every file edit (PostToolUse)
|
|
143
143
|
- **StatusLine** — Real-time health display (nodes, files, watch status) — compatible with other plugins' StatusLine via composite multiplexer
|
|
144
|
-
- **Auto-update** — Checks for new
|
|
144
|
+
- **Auto-update** — Checks for a new version at session start (throttled to at most one check every 2 minutes). Between forced checks the re-check interval is 30 minutes after an "up to date" answer and 6 hours while an update is already pending. Updates install silently.
|
|
145
145
|
|
|
146
146
|
#### Manual Update
|
|
147
147
|
|
|
@@ -306,17 +306,18 @@ Common options: `--json` (JSON output), `--compact` (compact output), `--limit N
|
|
|
306
306
|
|
|
307
307
|
As of **v0.37.0** the CLI is [clap](https://docs.rs/clap)-based: **every subcommand has `--help`** for its full flag list (`code-graph-mcp <command> --help`), value flags accept both `--flag value` and `--flag=value`, and unknown flags or malformed arguments fail fast with a clear error and a non-zero exit code (`2`) instead of being silently ignored. For example, `trace` hides downstream middleware with `--no-middleware` (shown by default), and `snapshot` is a `create`/`inspect` subcommand pair.
|
|
308
308
|
|
|
309
|
-
## Plugin
|
|
309
|
+
## Plugin Skills
|
|
310
310
|
|
|
311
|
-
|
|
311
|
+
Installing the plugin ships two skills that Claude loads on its own when the
|
|
312
|
+
situation matches — there are no slash commands to remember:
|
|
312
313
|
|
|
313
|
-
|
|
|
314
|
-
|
|
315
|
-
|
|
|
316
|
-
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
314
|
+
| Skill | Loaded when | What it does |
|
|
315
|
+
|-------|-------------|--------------|
|
|
316
|
+
| `explore` | Starting work in unfamiliar code, or before editing a module | Routes the question to `overview` / `map` / `callgraph` / `search` / `impact` instead of reading files one at a time |
|
|
317
|
+
| `index` | Search returns empty or stale results, or after a large restructuring | Walks `health-check`, incremental re-index, and full rebuild |
|
|
318
|
+
|
|
319
|
+
Both are thin routers over the CLI subcommands documented above, so anything a
|
|
320
|
+
skill does is also runnable by hand.
|
|
320
321
|
|
|
321
322
|
## Supported Languages (19)
|
|
322
323
|
|
|
@@ -438,7 +439,10 @@ Data is stored in `.code-graph/index.db` under the project root (auto-created, g
|
|
|
438
439
|
|
|
439
440
|
### Prerequisites
|
|
440
441
|
|
|
441
|
-
- Rust 1.
|
|
442
|
+
- Rust 1.95.0 (2021 edition) — the toolchain CI and the release build pin
|
|
443
|
+
(`dtolnay/rust-toolchain@1.95.0` in `.github/workflows/`). No older toolchain
|
|
444
|
+
is tested: `Cargo.lock` is lockfile **version 4**, which Cargo 1.75 cannot
|
|
445
|
+
read at all, and no `rust-version` floor is declared in `Cargo.toml`.
|
|
442
446
|
- A C compiler (for bundled SQLite / sqlite-vec)
|
|
443
447
|
|
|
444
448
|
### Build
|
|
@@ -323,6 +323,42 @@ function escapeRegex(s) {
|
|
|
323
323
|
return s.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&');
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
// Remove every match of `re` and heal ONLY the seam each removal leaves behind.
|
|
327
|
+
//
|
|
328
|
+
// The whole-file `out.replace(/\n{3,}/g, '\n\n')` this replaces was the last
|
|
329
|
+
// unscoped edit in a function whose entire contract is "touch nothing but our
|
|
330
|
+
// block". It rewrote the user's prose: blank-line runs inside fenced code blocks
|
|
331
|
+
// collapsed, and because the collapse changed bytes even when no marker was
|
|
332
|
+
// present, `unadopt` reported "De-blocked" for files that never held our block
|
|
333
|
+
// — on every SessionStart, and across every registered project on uninstall
|
|
334
|
+
// (audit 2026-08-16 P1-15).
|
|
335
|
+
//
|
|
336
|
+
// Seam rule: the blank lines that end up adjacent BECAUSE the block between them
|
|
337
|
+
// was removed collapse to one blank line (the old behavior, now local); bytes
|
|
338
|
+
// anywhere else are copied through untouched. A text containing no match is
|
|
339
|
+
// returned identical, which is what makes "changed" mean "we removed something".
|
|
340
|
+
function stripAndHealSeams(text, re) {
|
|
341
|
+
let out = '';
|
|
342
|
+
let cursor = 0;
|
|
343
|
+
re.lastIndex = 0;
|
|
344
|
+
for (let m = re.exec(text); m !== null; m = re.exec(text)) {
|
|
345
|
+
if (m[0] === '') { re.lastIndex++; continue; } // zero-width guard: never loop forever
|
|
346
|
+
out += text.slice(cursor, m.index);
|
|
347
|
+
cursor = m.index + m[0].length;
|
|
348
|
+
const before = /\n*$/.exec(out)[0].length;
|
|
349
|
+
const after = /^\n*/.exec(text.slice(cursor))[0].length;
|
|
350
|
+
if (before + after > 2) {
|
|
351
|
+
out = out.slice(0, out.length - before) + '\n\n';
|
|
352
|
+
// Skip the newlines we just absorbed. Safe for the scan: the skipped span
|
|
353
|
+
// is newlines only, and every pattern here starts at a line's first
|
|
354
|
+
// non-newline character.
|
|
355
|
+
cursor += after;
|
|
356
|
+
re.lastIndex = cursor;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return out + text.slice(cursor);
|
|
360
|
+
}
|
|
361
|
+
|
|
326
362
|
// Strip our sentinel block — well-formed first, then self-heal orphan begin/end.
|
|
327
363
|
// Shared by adopt (so re-adopt rewrites a stale/malformed block) and unadopt.
|
|
328
364
|
//
|
|
@@ -352,7 +388,7 @@ function stripSentinelBlock(text) {
|
|
|
352
388
|
`${BEGIN_LINE}(?:(?!${SENTINEL_BEGIN_SRC})[\\s\\S])*?${END_LINE}\\n?`,
|
|
353
389
|
'gm'
|
|
354
390
|
);
|
|
355
|
-
let out = text
|
|
391
|
+
let out = stripAndHealSeams(text, wellFormed);
|
|
356
392
|
// Orphan BEGIN with no matching END (truncation / partial edit): remove the
|
|
357
393
|
// MARKER LINE ONLY, never the content after it.
|
|
358
394
|
//
|
|
@@ -371,13 +407,17 @@ function stripSentinelBlock(text) {
|
|
|
371
407
|
// line-anchored BEGIN *and* END, so a leftover fragment does not block
|
|
372
408
|
// re-adoption — the next adopt writes a fresh, well-formed block.
|
|
373
409
|
const orphanBegin = new RegExp(`${BEGIN_LINE}\\n?`, 'gm');
|
|
374
|
-
out = out
|
|
375
|
-
// Orphan END line by itself — same rule, one line, nothing around it.
|
|
410
|
+
out = stripAndHealSeams(out, orphanBegin);
|
|
411
|
+
// Orphan END line by itself — same rule, one line, nothing around it. Written
|
|
412
|
+
// as the same line-anchored removal the other two passes use (the old
|
|
413
|
+
// split/filter/join spelling was a third predicate for "is this our END line",
|
|
414
|
+
// and every duplicated predicate in this file has drifted at least once).
|
|
376
415
|
if (out.includes(SENTINEL_END)) {
|
|
377
|
-
out = out
|
|
416
|
+
out = stripAndHealSeams(out, new RegExp(`${END_LINE}\\n?`, 'gm'));
|
|
378
417
|
}
|
|
379
|
-
//
|
|
380
|
-
|
|
418
|
+
// NOTE: no whole-file newline collapse here. Each removal above healed its own
|
|
419
|
+
// seam; bytes the user wrote are returned exactly as they came in.
|
|
420
|
+
return out;
|
|
381
421
|
}
|
|
382
422
|
|
|
383
423
|
function platformGuard() {
|
|
@@ -402,32 +442,62 @@ function adoptedRegistryFile(home) {
|
|
|
402
442
|
return path.join(home || os.homedir(), '.cache', 'code-graph', 'adopted-projects.json');
|
|
403
443
|
}
|
|
404
444
|
|
|
405
|
-
|
|
445
|
+
// Read the registry keeping WHY it failed — the same one bit lifecycle.js's
|
|
446
|
+
// readJsonResult exists for. Only a genuinely ABSENT (or empty) file may be
|
|
447
|
+
// treated as "nothing here, safe to create": everything else (EACCES, EISDIR,
|
|
448
|
+
// truncated JSON, wrong shape) means the file EXISTS and holds entries we cannot
|
|
449
|
+
// read. The old lenient reader returned `[]` for all of them and the next
|
|
450
|
+
// recordAdopted persisted `[thisProject]` over it — dropping every other adopted
|
|
451
|
+
// project, which is exactly the list `uninstall --unadopt-all` iterates, so
|
|
452
|
+
// their managed CLAUDE.md blocks would be stranded (audit 2026-08-16 P1-12).
|
|
453
|
+
function readAdoptedResult(home) {
|
|
454
|
+
let raw;
|
|
455
|
+
try {
|
|
456
|
+
raw = fs.readFileSync(adoptedRegistryFile(home), 'utf8');
|
|
457
|
+
} catch (err) {
|
|
458
|
+
const missing = Boolean(err) && err.code === 'ENOENT';
|
|
459
|
+
return { list: [], missing, unusable: !missing };
|
|
460
|
+
}
|
|
461
|
+
if (raw.trim() === '') return { list: [], missing: true, unusable: false };
|
|
406
462
|
try {
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
463
|
+
const parsed = JSON.parse(raw);
|
|
464
|
+
if (!Array.isArray(parsed)) return { list: [], missing: false, unusable: true };
|
|
465
|
+
return { list: parsed.filter((p) => typeof p === 'string'), missing: false, unusable: false };
|
|
466
|
+
} catch {
|
|
467
|
+
return { list: [], missing: false, unusable: true };
|
|
468
|
+
}
|
|
410
469
|
}
|
|
411
470
|
|
|
471
|
+
/** Read-side contract is unchanged: a list, never a throw. */
|
|
472
|
+
function readAdoptedProjects(home) {
|
|
473
|
+
return readAdoptedResult(home).list;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** @returns {boolean} true when the project is recorded (or already was). */
|
|
412
477
|
function recordAdopted(projectDir, home) {
|
|
478
|
+
const res = readAdoptedResult(home);
|
|
479
|
+
if (res.unusable) return false; // never rebuild over entries we cannot read
|
|
413
480
|
try {
|
|
414
481
|
const file = adoptedRegistryFile(home);
|
|
415
|
-
const list = readAdoptedProjects(home);
|
|
416
482
|
const abs = path.resolve(projectDir);
|
|
417
|
-
if (list.includes(abs)) return;
|
|
483
|
+
if (res.list.includes(abs)) return true;
|
|
418
484
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
419
|
-
writeFileAtomic(file, JSON.stringify([...list, abs], null, 2) + '\n');
|
|
420
|
-
|
|
485
|
+
writeFileAtomic(file, JSON.stringify([...res.list, abs], null, 2) + '\n');
|
|
486
|
+
return true;
|
|
487
|
+
} catch { return false; } // best-effort: registry loss only degrades guidance
|
|
421
488
|
}
|
|
422
489
|
|
|
490
|
+
/** @returns {boolean} true when the project is absent from the registry afterwards. */
|
|
423
491
|
function removeAdopted(projectDir, home) {
|
|
492
|
+
const res = readAdoptedResult(home);
|
|
493
|
+
if (res.unusable) return false;
|
|
424
494
|
try {
|
|
425
|
-
const list = readAdoptedProjects(home);
|
|
426
495
|
const abs = path.resolve(projectDir);
|
|
427
|
-
const next = list.filter((p) => p !== abs);
|
|
428
|
-
if (next.length === list.length) return;
|
|
496
|
+
const next = res.list.filter((p) => p !== abs);
|
|
497
|
+
if (next.length === res.list.length) return true;
|
|
429
498
|
writeFileAtomic(adoptedRegistryFile(home), JSON.stringify(next, null, 2) + '\n');
|
|
430
|
-
|
|
499
|
+
return true;
|
|
500
|
+
} catch { return false; }
|
|
431
501
|
}
|
|
432
502
|
|
|
433
503
|
function adopt({ cwd, templatePath, home } = {}) {
|
|
@@ -445,18 +515,40 @@ function adopt({ cwd, templatePath, home } = {}) {
|
|
|
445
515
|
return { ok: false, reason: 'no-template', template: tpl };
|
|
446
516
|
}
|
|
447
517
|
|
|
518
|
+
// Every filesystem touch below is on files the USER owns and may have made
|
|
519
|
+
// unreadable (a root-owned CLAUDE.md from a `sudo` session) or replaced with a
|
|
520
|
+
// directory. Those throw EACCES/EISDIR, and this function is called bare from
|
|
521
|
+
// maybeAutoAdopt → runSessionInit: one such file killed the whole SessionStart
|
|
522
|
+
// hook, so binary verification, index freshness and the hook self-test never
|
|
523
|
+
// ran (audit 2026-08-16 P1-16). Adoption is optional; the rest of the session
|
|
524
|
+
// is not. Every arm below returns a REASON instead of throwing.
|
|
525
|
+
|
|
448
526
|
// 1. Install the detail doc at <cwd>/.claude/plugin_code_graph_mcp.md.
|
|
449
527
|
// First line is the MANAGED_BY marker (HTML comment → invisible in rendered
|
|
450
528
|
// markdown) so unadopt/needsRefresh can tell our generated copy from a user
|
|
451
529
|
// file of the same name. needsRefresh strips it before the bytewise compare.
|
|
452
530
|
const dDir = detailDir(effectiveCwd);
|
|
453
|
-
if (!fs.existsSync(dDir)) fs.mkdirSync(dDir, { recursive: true });
|
|
454
531
|
const dPath = detailPath(effectiveCwd);
|
|
455
|
-
const desiredDetail = Buffer.concat([Buffer.from(`${MANAGED_BY}\n`), fs.readFileSync(tpl)]);
|
|
456
532
|
let detailWritten = false;
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
533
|
+
let desiredDetail;
|
|
534
|
+
try {
|
|
535
|
+
desiredDetail = Buffer.concat([Buffer.from(`${MANAGED_BY}\n`), fs.readFileSync(tpl)]);
|
|
536
|
+
} catch (e) {
|
|
537
|
+
return { ok: false, reason: 'no-template', template: tpl, error: e.code || String(e) };
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
if (!fs.existsSync(dDir)) fs.mkdirSync(dDir, { recursive: true });
|
|
541
|
+
// readFileSync on the existing copy can throw for the same reasons as
|
|
542
|
+
// CLAUDE.md below; an unreadable one is "different from what we want", so
|
|
543
|
+
// fall through to the write and let THAT report the real failure.
|
|
544
|
+
let current = null;
|
|
545
|
+
try { current = fs.readFileSync(dPath); } catch { current = null; }
|
|
546
|
+
if (current === null || !current.equals(desiredDetail)) {
|
|
547
|
+
writeFileAtomic(dPath, desiredDetail);
|
|
548
|
+
detailWritten = true;
|
|
549
|
+
}
|
|
550
|
+
} catch (e) {
|
|
551
|
+
return { ok: false, reason: 'detail-unwritable', detailPath: dPath, error: e.code || String(e) };
|
|
460
552
|
}
|
|
461
553
|
|
|
462
554
|
// 2. Ensure the managed block in <cwd>/CLAUDE.md. Create-if-missing, else
|
|
@@ -465,19 +557,33 @@ function adopt({ cwd, templatePath, home } = {}) {
|
|
|
465
557
|
const cPath = claudeMdPath(effectiveCwd);
|
|
466
558
|
const block = buildBlock(detectProjectType(effectiveCwd));
|
|
467
559
|
const exists = fs.existsSync(cPath);
|
|
468
|
-
|
|
560
|
+
let current = '';
|
|
561
|
+
if (exists) {
|
|
562
|
+
try {
|
|
563
|
+
current = fs.readFileSync(cPath, 'utf8');
|
|
564
|
+
} catch (e) {
|
|
565
|
+
// EACCES / EISDIR / EIO: the file is there and we cannot read it. Writing
|
|
566
|
+
// anyway would replace content we never saw, so this project simply cannot
|
|
567
|
+
// be adopted right now.
|
|
568
|
+
return { ok: false, reason: 'claude-md-unreadable', claudeMdPath: cPath, error: e.code || String(e) };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
469
571
|
if (current.includes(block)) {
|
|
470
|
-
recordAdopted(effectiveCwd, home);
|
|
471
|
-
return { ok: true, detailPath: dPath, claudeMdPath: cPath, detailWritten, claudeMdWritten: false, created: false, healed: false };
|
|
572
|
+
const registryRecorded = recordAdopted(effectiveCwd, home);
|
|
573
|
+
return { ok: true, detailPath: dPath, claudeMdPath: cPath, detailWritten, claudeMdWritten: false, created: false, healed: false, registryRecorded };
|
|
472
574
|
}
|
|
473
575
|
const cleaned = exists ? stripSentinelBlock(current) : '';
|
|
474
576
|
const healed = exists && cleaned !== current;
|
|
475
577
|
const base = cleaned.replace(/\n+$/, '');
|
|
476
578
|
const prefix = base ? base + '\n\n' : '';
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
579
|
+
try {
|
|
580
|
+
// followLink: read-modify-write of a file the user may have symlinked.
|
|
581
|
+
writeFileAtomic(cPath, prefix + block + '\n', { followLink: true });
|
|
582
|
+
} catch (e) {
|
|
583
|
+
return { ok: false, reason: 'claude-md-unwritable', claudeMdPath: cPath, error: e.code || String(e) };
|
|
584
|
+
}
|
|
585
|
+
const registryRecorded = recordAdopted(effectiveCwd, home);
|
|
586
|
+
return { ok: true, detailPath: dPath, claudeMdPath: cPath, detailWritten, claudeMdWritten: true, created: !exists, healed, registryRecorded };
|
|
481
587
|
}
|
|
482
588
|
|
|
483
589
|
// "已 install" 判定:detail 文件在 + CLAUDE.md 内有我们的 sentinel 块(任意版本)。
|
|
@@ -487,7 +593,11 @@ function isAdopted({ cwd } = {}) {
|
|
|
487
593
|
const cPath = claudeMdPath(effectiveCwd);
|
|
488
594
|
const dPath = detailPath(effectiveCwd);
|
|
489
595
|
if (!fs.existsSync(dPath) || !fs.existsSync(cPath)) return false;
|
|
490
|
-
|
|
596
|
+
// Unreadable (EACCES) or a directory (EISDIR) → "not adopted here". A throw
|
|
597
|
+
// out of this predicate took SessionStart down with it (P1-16), and a file we
|
|
598
|
+
// cannot read cannot be proven to hold our block anyway.
|
|
599
|
+
let c;
|
|
600
|
+
try { c = fs.readFileSync(cPath, 'utf8'); } catch { return false; }
|
|
491
601
|
// Line-anchored for the same reason stripSentinelBlock is: a user quoting the
|
|
492
602
|
// markers in prose would otherwise read as adopted, and this gates the
|
|
493
603
|
// idempotent auto-adopt — so the block would never actually be written.
|
|
@@ -507,8 +617,15 @@ function needsRefresh({ cwd, templatePath } = {}) {
|
|
|
507
617
|
return false;
|
|
508
618
|
}
|
|
509
619
|
// Detail-doc body drift — strip the leading MANAGED_BY marker line first.
|
|
510
|
-
|
|
511
|
-
|
|
620
|
+
// Unreadable inputs → false: a refresh we cannot decide is one we must not
|
|
621
|
+
// attempt (and adopt() would refuse the write anyway). Never throws — this
|
|
622
|
+
// runs inside the SessionStart hook (P1-16).
|
|
623
|
+
let shipped;
|
|
624
|
+
let current;
|
|
625
|
+
try {
|
|
626
|
+
shipped = fs.readFileSync(tpl);
|
|
627
|
+
current = fs.readFileSync(dPath);
|
|
628
|
+
} catch { return false; }
|
|
512
629
|
let body = current;
|
|
513
630
|
const nl = current.indexOf(0x0a);
|
|
514
631
|
// Equality on the trimmed line, matching the unadopt guard. `includes` here
|
|
@@ -523,7 +640,9 @@ function needsRefresh({ cwd, templatePath } = {}) {
|
|
|
523
640
|
// needsRefresh always agree on the variant — including when a project gains a
|
|
524
641
|
// web-framework dep and switches type bucket, or on a sentinel version bump.
|
|
525
642
|
const block = buildBlock(detectProjectType(effectiveCwd));
|
|
526
|
-
|
|
643
|
+
try {
|
|
644
|
+
return !fs.readFileSync(cPath, 'utf8').includes(block);
|
|
645
|
+
} catch { return false; }
|
|
527
646
|
}
|
|
528
647
|
|
|
529
648
|
// 检测脚本是否从 Claude Code 插件 cache 运行。
|
|
@@ -643,25 +762,45 @@ function unadopt({ cwd, home } = {}) {
|
|
|
643
762
|
mine = h === MANAGED_BY
|
|
644
763
|
|| (h.startsWith(LEGACY_ADOPTED_BY) && h.endsWith('-->'));
|
|
645
764
|
} catch { mine = false; }
|
|
646
|
-
|
|
765
|
+
// The unlink itself can fail (read-only dir, EPERM) — an uninstall sweep
|
|
766
|
+
// must keep going for the remaining projects.
|
|
767
|
+
if (mine) {
|
|
768
|
+
try { fs.unlinkSync(dPath); fileRemoved = true; } catch { /* left behind, reported as not removed */ }
|
|
769
|
+
}
|
|
647
770
|
}
|
|
648
771
|
|
|
649
772
|
// CLAUDE.md — strip only our block. If nothing else remains, remove the file
|
|
650
773
|
// we created; otherwise preserve the user's prose.
|
|
774
|
+
//
|
|
775
|
+
// Unreadable / a directory / an un-writable dir: report it and move on. This
|
|
776
|
+
// path runs over EVERY registered project from `uninstall --unadopt-all`, so
|
|
777
|
+
// one bad file used to abort the whole sweep with a raw stack trace (P1-16).
|
|
778
|
+
let claudeMdUnreadable = false;
|
|
779
|
+
let claudeMdUnwritable = false;
|
|
651
780
|
if (fs.existsSync(cPath)) {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
781
|
+
let before;
|
|
782
|
+
try {
|
|
783
|
+
before = fs.readFileSync(cPath, 'utf8');
|
|
784
|
+
} catch { claudeMdUnreadable = true; }
|
|
785
|
+
if (before !== undefined) {
|
|
786
|
+
const after = stripSentinelBlock(before);
|
|
787
|
+
if (after !== before) {
|
|
788
|
+
try {
|
|
789
|
+
// Only delete a file we could have created. A symlinked CLAUDE.md points
|
|
790
|
+
// at something the user owns (a shared team file, a dotfiles repo);
|
|
791
|
+
// unlinking it removes their link, and the "file we created" rationale
|
|
792
|
+
// does not apply. Write the stripped text through the link instead.
|
|
793
|
+
if (after.trim() === '' && !fs.lstatSync(cPath).isSymbolicLink()) {
|
|
794
|
+
fs.unlinkSync(cPath);
|
|
795
|
+
claudeMdRemoved = true;
|
|
796
|
+
} else {
|
|
797
|
+
writeFileAtomic(cPath, after, { followLink: true });
|
|
798
|
+
}
|
|
799
|
+
// Only after the write lands: `blockPruned` drives the "De-blocked"
|
|
800
|
+
// line, and claiming it for a write that threw is the same class of
|
|
801
|
+
// false success this batch keeps finding.
|
|
802
|
+
blockPruned = true;
|
|
803
|
+
} catch { claudeMdUnwritable = true; }
|
|
665
804
|
}
|
|
666
805
|
}
|
|
667
806
|
}
|
|
@@ -669,8 +808,12 @@ function unadopt({ cwd, home } = {}) {
|
|
|
669
808
|
// Also sweep any legacy memory-dir remnants (uninstall before auto-migration ran).
|
|
670
809
|
const migrated = migrateLegacyMemoryDir({ cwd, home });
|
|
671
810
|
|
|
672
|
-
removeAdopted(effectiveCwd, home);
|
|
673
|
-
return {
|
|
811
|
+
const registryUpdated = removeAdopted(effectiveCwd, home);
|
|
812
|
+
return {
|
|
813
|
+
ok: true, fileRemoved, blockPruned, claudeMdRemoved,
|
|
814
|
+
claudeMdUnreadable, claudeMdUnwritable, registryUpdated,
|
|
815
|
+
target: dPath, claudeMdPath: cPath, migrated,
|
|
816
|
+
};
|
|
674
817
|
}
|
|
675
818
|
|
|
676
819
|
function formatResult(action, result) {
|
|
@@ -688,6 +831,18 @@ function formatResult(action, result) {
|
|
|
688
831
|
if (result.reason === 'no-template') {
|
|
689
832
|
return `[code-graph] Template missing: ${result.template}`;
|
|
690
833
|
}
|
|
834
|
+
// Name the file and the OS error: "adopt failed: claude-md-unreadable" is
|
|
835
|
+
// not something a user can act on, and this is the arm a root-owned
|
|
836
|
+
// CLAUDE.md lands in.
|
|
837
|
+
if (result.reason === 'claude-md-unreadable' || result.reason === 'claude-md-unwritable') {
|
|
838
|
+
const what = result.reason === 'claude-md-unreadable' ? 'read' : 'write';
|
|
839
|
+
return `[code-graph] Cannot ${what} ${result.claudeMdPath} (${result.error || 'unknown error'}).\n` +
|
|
840
|
+
` Nothing was changed. Fix its permissions (or move it aside) and re-run;\n` +
|
|
841
|
+
' opt out entirely with CODE_GRAPH_NO_AUTO_ADOPT=1.';
|
|
842
|
+
}
|
|
843
|
+
if (result.reason === 'detail-unwritable') {
|
|
844
|
+
return `[code-graph] Cannot write ${result.detailPath} (${result.error || 'unknown error'}). Nothing was changed.`;
|
|
845
|
+
}
|
|
691
846
|
return `[code-graph] adopt failed: ${result.reason || 'unknown'}`;
|
|
692
847
|
}
|
|
693
848
|
const lines = [];
|
|
@@ -707,11 +862,16 @@ function formatResult(action, result) {
|
|
|
707
862
|
if (result.claudeMdRemoved) lines.push(`[code-graph] Removed → ${result.claudeMdPath} (was code-graph-only)`);
|
|
708
863
|
else if (result.blockPruned) lines.push(`[code-graph] De-blocked → ${result.claudeMdPath}`);
|
|
709
864
|
if (result.fileRemoved) lines.push(`[code-graph] Removed → ${result.target}`);
|
|
865
|
+
if (result.claudeMdUnreadable || result.claudeMdUnwritable) {
|
|
866
|
+
lines.push(`[code-graph] Could not ${result.claudeMdUnreadable ? 'read' : 'write'} ${result.claudeMdPath} — ` +
|
|
867
|
+
'the managed block (if any) is still there. Fix its permissions and re-run.');
|
|
868
|
+
}
|
|
710
869
|
const m = result.migrated || {};
|
|
711
870
|
if (m.memoryIndexPruned || m.legacyDetailRemoved) {
|
|
712
871
|
lines.push('[code-graph] Cleaned legacy memory-dir artifacts.');
|
|
713
872
|
}
|
|
714
873
|
if (!result.blockPruned && !result.fileRemoved && !result.claudeMdRemoved &&
|
|
874
|
+
!result.claudeMdUnreadable && !result.claudeMdUnwritable &&
|
|
715
875
|
!(m.memoryIndexPruned || m.legacyDetailRemoved)) {
|
|
716
876
|
lines.push('[code-graph] Nothing to unadopt');
|
|
717
877
|
}
|
|
@@ -177,7 +177,11 @@ function shouldCheck(state, { force = false, binaryMissing = false, binaryStale
|
|
|
177
177
|
if (state.rateLimited) return elapsed >= RATE_LIMIT_INTERVAL_MS;
|
|
178
178
|
if (binaryMissing) return true;
|
|
179
179
|
if (!isUpdateSuspended(state)) {
|
|
180
|
-
|
|
180
|
+
// ...and only while that heal still has a retry budget. Once it is spent,
|
|
181
|
+
// the bypass re-fetched the API and re-entered the ~40MB download on every
|
|
182
|
+
// single session (P1-14) — the same reasoning that keeps `binaryStale` out
|
|
183
|
+
// of the suspended branch.
|
|
184
|
+
if (binaryStale && !isBinaryHealExhausted(state)) return true;
|
|
181
185
|
if (force) return elapsed >= SESSION_START_MIN_GAP_MS;
|
|
182
186
|
}
|
|
183
187
|
const interval = state.updateAvailable === false ? UP_TO_DATE_RECHECK_MS : CHECK_INTERVAL_MS;
|
|
@@ -726,9 +730,82 @@ async function downloadAndInstall(latest, {
|
|
|
726
730
|
* on the shell-matches-latest path. Extracted + injectable so the wiring itself is
|
|
727
731
|
* regression-tested, not just the predicate. Returns true iff a download promoted.
|
|
728
732
|
*/
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
733
|
+
/**
|
|
734
|
+
* Replace a missing/stale cached binary — BOUNDED, per target version.
|
|
735
|
+
*
|
|
736
|
+
* This had no counter, so a promote that could not land (the Windows case named
|
|
737
|
+
* at promoteVerifiedBinary: the running MCP server holds the .exe, rename →
|
|
738
|
+
* EACCES) re-downloaded ~40MB on every session forever: the caller cleared
|
|
739
|
+
* `updateAttempts`/`suspendedAt` unconditionally right after calling this, and
|
|
740
|
+
* `shouldCheck`'s `binaryStale` arm bypasses the throttle (audit 2026-08-16
|
|
741
|
+
* P1-14; measured 8 calls → 8 downloads).
|
|
742
|
+
*
|
|
743
|
+
* Counted the same way as selfHealGlobalPkgs, including its hard-won rule:
|
|
744
|
+
* success is "the binary is no longer stale", NOT "download() returned true".
|
|
745
|
+
* A download whose promote silently failed used to reset the budget on every
|
|
746
|
+
* run, which is a cap that can never be reached.
|
|
747
|
+
*
|
|
748
|
+
* The counter is deliberately SEPARATE from `updateAttempts`: that one tracks
|
|
749
|
+
* the plugin-shell update, and the branch this runs in resets it because the
|
|
750
|
+
* shell IS current. Sharing it would have made each reset re-arm the other.
|
|
751
|
+
*
|
|
752
|
+
* @returns {{healed: boolean, patch: object}} patch is spread into the state save
|
|
753
|
+
*/
|
|
754
|
+
async function selfHealStaleBinary(latest, {
|
|
755
|
+
state = {}, needsUpdate = cachedBinaryNeedsUpdate, download = downloadBinary,
|
|
756
|
+
// "Present" must mean USABLE, not merely on disk. A truncated, non-executable
|
|
757
|
+
// or wrong-arch cached binary leaves the MCP server exactly as dead as a
|
|
758
|
+
// missing one, and every sibling predicate here already treats unreadable as
|
|
759
|
+
// needing replacement (cachedBinaryNeedsUpdate, cachedBinaryStaleVsState).
|
|
760
|
+
// Keying on existsSync alone put a corrupt binary under the stale budget,
|
|
761
|
+
// which isBinaryHealExhausted only re-arms when a NEW release ships — so five
|
|
762
|
+
// quick failures parked the only recovery path permanently (pre-tag review of
|
|
763
|
+
// the P1-14 fix).
|
|
764
|
+
binaryPresent = () => {
|
|
765
|
+
const p = cachedBinaryPath();
|
|
766
|
+
return fs.existsSync(p) && readBinaryVersion(p) !== null;
|
|
767
|
+
},
|
|
768
|
+
} = {}) {
|
|
769
|
+
if (!latest || !needsUpdate(latest)) {
|
|
770
|
+
// Healthy → clear any leftover counter so the next real staleness starts fresh.
|
|
771
|
+
return {
|
|
772
|
+
healed: false,
|
|
773
|
+
patch: state.binaryHealAttempts ? { binaryHealAttempts: 0, binaryHealVersion: null } : {},
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
// A MISSING binary is exempt from the attempt budget: with no engine at all
|
|
777
|
+
// the MCP server is dead, and `needsUpdate` returns true for "absent" too —
|
|
778
|
+
// letting the stale-heal counter absorb those failures would permanently
|
|
779
|
+
// park the only recovery path after five offline session starts (batch
|
|
780
|
+
// review of the P1-14 fix). The budget exists to stop re-downloading over a
|
|
781
|
+
// binary that RUNS but cannot be replaced (Windows EACCES-on-rename); a
|
|
782
|
+
// missing binary keeps the pre-P1-14 unbounded retry on purpose.
|
|
783
|
+
const missing = !binaryPresent();
|
|
784
|
+
const attempts = state.binaryHealVersion === latest.version ? (state.binaryHealAttempts || 0) : 0;
|
|
785
|
+
if (!missing && attempts >= MAX_UPDATE_ATTEMPTS) return { healed: false, patch: {} };
|
|
786
|
+
await download(latest);
|
|
787
|
+
// Re-read the disk, not the return value (see above).
|
|
788
|
+
const stillStale = needsUpdate(latest);
|
|
789
|
+
return {
|
|
790
|
+
healed: !stillStale,
|
|
791
|
+
patch: {
|
|
792
|
+
binaryHealVersion: latest.version,
|
|
793
|
+
binaryHealAttempts: !stillStale ? 0 : missing ? attempts : attempts + 1,
|
|
794
|
+
},
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* The stale-binary heal has spent its budget on the release we are tracking.
|
|
800
|
+
* Read by shouldCheck: with the heal parked, the `binaryStale` throttle bypass
|
|
801
|
+
* can accomplish nothing and would just re-fetch the API (and, worse, re-enter
|
|
802
|
+
* the download path) every session. Re-arms itself when `latestVersion` moves.
|
|
803
|
+
*/
|
|
804
|
+
function isBinaryHealExhausted(state) {
|
|
805
|
+
return Boolean(state)
|
|
806
|
+
&& Boolean(state.binaryHealVersion)
|
|
807
|
+
&& state.binaryHealVersion === state.latestVersion
|
|
808
|
+
&& (state.binaryHealAttempts || 0) >= MAX_UPDATE_ATTEMPTS;
|
|
732
809
|
}
|
|
733
810
|
|
|
734
811
|
// ── Global npm package self-heal ───────────────────────────
|
|
@@ -1052,7 +1129,8 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
|
|
|
1052
1129
|
// OR stale (see selfHealStaleBinary). The shell version (manifest.version)
|
|
1053
1130
|
// can match latest while the cached binary lags — this is exactly the wild
|
|
1054
1131
|
// failure observed in the field (shell at v0.45, binary pinned at v0.16.6).
|
|
1055
|
-
const
|
|
1132
|
+
const binaryHeal = await selfHealStaleBinary(latest, { state });
|
|
1133
|
+
const selfHealedBinary = binaryHeal.healed;
|
|
1056
1134
|
|
|
1057
1135
|
// Same for the GLOBAL npm delivery surface (the `code-graph-mcp` CLI on
|
|
1058
1136
|
// PATH + any explicitly-installed platform package): nothing else ever
|
|
@@ -1078,6 +1156,10 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
|
|
|
1078
1156
|
suspendedAt: null,
|
|
1079
1157
|
rateLimited: false,
|
|
1080
1158
|
binaryUpdated: selfHealedBinary || state.binaryUpdated,
|
|
1159
|
+
// The shell-update counters above reset because the shell IS current.
|
|
1160
|
+
// The BINARY heal keeps its own, un-reset budget — clearing it here is
|
|
1161
|
+
// what made the stale-binary re-download unbounded (P1-14).
|
|
1162
|
+
...binaryHeal.patch,
|
|
1081
1163
|
...globalHeal,
|
|
1082
1164
|
});
|
|
1083
1165
|
return selfHealedBinary
|
|
@@ -1101,7 +1183,7 @@ module.exports = {
|
|
|
1101
1183
|
PLUGIN_ASSET_NAME,
|
|
1102
1184
|
downloadBinary, cachedBinaryPath, cachedBinaryNeedsUpdate, cachedBinaryStaleVsState,
|
|
1103
1185
|
getPlatformAssetName,
|
|
1104
|
-
selfHealStaleBinary,
|
|
1186
|
+
selfHealStaleBinary, isBinaryHealExhausted,
|
|
1105
1187
|
selfHealGlobalPkgs, staleGlobalPkgs, globalPkgVersion, npmInstallGlobal,
|
|
1106
1188
|
shouldHealGlobalsOnThrottle, inactiveNodeGlobalRelics,
|
|
1107
1189
|
downloadAndInstall, refreshMarketplaceClone, marketplaceCloneDir,
|