@sdsrs/code-graph 0.116.0 → 0.118.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 +38 -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 +147 -11
- package/claude-plugin/scripts/doctor.js +158 -12
- package/claude-plugin/scripts/hook-emit.js +80 -11
- package/claude-plugin/scripts/lifecycle.js +193 -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 +73 -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 +15 -7
- package/package.json +7 -7
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
|
|
|
@@ -109,14 +109,18 @@ Real-world benchmarks comparing code-graph-mcp tools against traditional approac
|
|
|
109
109
|
```
|
|
110
110
|
src/
|
|
111
111
|
├── domain.rs # Shared constants, relation types, env-var config
|
|
112
|
+
├── resolve.rs # Shared symbol resolution + ambiguity verdicts (CLI and MCP)
|
|
113
|
+
├── outcome.rs # Retrieval-adoption metrics from session transcripts
|
|
114
|
+
├── cli/ # Every `code-graph-mcp <cmd>` subcommand (one file per command)
|
|
112
115
|
├── mcp/ # MCP protocol layer (JSON-RPC, tool registry, server)
|
|
113
116
|
│ └── server/ # McpServer with IndexingState + CacheState sub-structs
|
|
114
117
|
├── parser/ # Tree-sitter parsing, relation extraction, LanguageConfig dispatch
|
|
115
118
|
├── indexer/ # 3-phase pipeline, Merkle tree, file watcher
|
|
116
|
-
├── storage/ # SQLite schema (
|
|
119
|
+
├── storage/ # SQLite schema (v10), CRUD, FTS5, migrations
|
|
117
120
|
├── graph/ # Recursive CTE call graph queries
|
|
118
121
|
├── search/ # RRF fusion search combining BM25 + vector
|
|
119
122
|
├── embedding/ # Candle embedding model (optional, masked mean pooling)
|
|
123
|
+
├── snapshot/ # Portable graph snapshots (create / verify / install)
|
|
120
124
|
├── sandbox/ # Context compressor with token estimation
|
|
121
125
|
└── utils/ # Language detection, config
|
|
122
126
|
```
|
|
@@ -125,7 +129,7 @@ src/
|
|
|
125
129
|
|
|
126
130
|
### Option 1: Claude Code Plugin (Recommended)
|
|
127
131
|
|
|
128
|
-
Install as a Claude Code plugin for the best experience — includes
|
|
132
|
+
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
133
|
|
|
130
134
|
```bash
|
|
131
135
|
# Step 1: Add the marketplace
|
|
@@ -137,11 +141,11 @@ Install as a Claude Code plugin for the best experience — includes slash comma
|
|
|
137
141
|
|
|
138
142
|
What you get:
|
|
139
143
|
- **MCP Server** — All code-graph tools available to Claude
|
|
140
|
-
- **
|
|
144
|
+
- **Skills** — `explore` (structure-first navigation before reading files) and `index` (health-check / re-index / full rebuild); see [Plugin Skills](#plugin-skills)
|
|
141
145
|
- **Code Explorer Agent** — Deep code understanding expert via `code-explorer`
|
|
142
146
|
- **Auto-indexing Hook** — Incremental index on every file edit (PostToolUse)
|
|
143
147
|
- **StatusLine** — Real-time health display (nodes, files, watch status) — compatible with other plugins' StatusLine via composite multiplexer
|
|
144
|
-
- **Auto-update** — Checks for new
|
|
148
|
+
- **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
149
|
|
|
146
150
|
#### Manual Update
|
|
147
151
|
|
|
@@ -301,22 +305,38 @@ All tools are also available as CLI subcommands for shell scripts, hooks, and te
|
|
|
301
305
|
| `incremental-index` | — | Run incremental index update (auto-creates DB if needed) |
|
|
302
306
|
| `health-check` | `get_index_status` | Query index status and freshness |
|
|
303
307
|
| `benchmark` | — | Benchmark index speed, query latency, token savings |
|
|
308
|
+
| `affected [files…]` | — | Changed files → the test files to re-run (`--stdin`, `--depth`) |
|
|
309
|
+
| `tour [path]` | — | Dependency-ordered reading order for a repo or subtree |
|
|
310
|
+
| `centrality` | `project_map` (`include_centrality=true`) | Rank architectural chokepoints (betweenness over the call graph) |
|
|
311
|
+
| `cycles` | — | Detect circular import dependencies (file-level) |
|
|
312
|
+
| `surprising` | — | Surface unexpected cross-module couplings (uncertain edges) |
|
|
313
|
+
| `report` | — | Consolidated code-health report (summary + all analyses) |
|
|
314
|
+
| `stats` | — | Aggregate session metrics from `.code-graph/usage.jsonl` |
|
|
315
|
+
| `outcome` | — | Retrieval adoption from session transcripts (field-MRR; read-only) |
|
|
316
|
+
| `rebuild-index` | `rebuild_index` | Drop and rebuild the index from scratch (requires `--confirm`) |
|
|
317
|
+
| `reindex` | — | Incremental refresh; `--from-snapshot` refetches the published snapshot |
|
|
318
|
+
| `snapshot create\|inspect` | — | Build or inspect a portable graph snapshot |
|
|
319
|
+
| `doctor` | — | Diagnose and repair environment issues |
|
|
320
|
+
| `adopt` | — | Install the steering block into the project `CLAUDE.md` + detail doc |
|
|
321
|
+
| `unadopt` | — | Remove the steering block + detail doc |
|
|
322
|
+
| `serve` | — | Start the MCP JSON-RPC server on stdio (the default with no subcommand) |
|
|
304
323
|
|
|
305
324
|
Common options: `--json` (JSON output), `--compact` (compact output), `--limit N`, `--depth N`, `--file <path>`.
|
|
306
325
|
|
|
307
326
|
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
327
|
|
|
309
|
-
## Plugin
|
|
328
|
+
## Plugin Skills
|
|
310
329
|
|
|
311
|
-
|
|
330
|
+
Installing the plugin ships two skills that Claude loads on its own when the
|
|
331
|
+
situation matches — there are no slash commands to remember:
|
|
312
332
|
|
|
313
|
-
|
|
|
314
|
-
|
|
315
|
-
|
|
|
316
|
-
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
333
|
+
| Skill | Loaded when | What it does |
|
|
334
|
+
|-------|-------------|--------------|
|
|
335
|
+
| `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 |
|
|
336
|
+
| `index` | Search returns empty or stale results, or after a large restructuring | Walks `health-check`, incremental re-index, and full rebuild |
|
|
337
|
+
|
|
338
|
+
Both are thin routers over the CLI subcommands documented above, so anything a
|
|
339
|
+
skill does is also runnable by hand.
|
|
320
340
|
|
|
321
341
|
## Supported Languages (19)
|
|
322
342
|
|
|
@@ -438,7 +458,10 @@ Data is stored in `.code-graph/index.db` under the project root (auto-created, g
|
|
|
438
458
|
|
|
439
459
|
### Prerequisites
|
|
440
460
|
|
|
441
|
-
- Rust 1.
|
|
461
|
+
- Rust 1.95.0 (2021 edition) — the toolchain CI and the release build pin
|
|
462
|
+
(`dtolnay/rust-toolchain@1.95.0` in `.github/workflows/`). No older toolchain
|
|
463
|
+
is tested: `Cargo.lock` is lockfile **version 4**, which Cargo 1.75 cannot
|
|
464
|
+
read at all, and no `rust-version` floor is declared in `Cargo.toml`.
|
|
442
465
|
- A C compiler (for bundled SQLite / sqlite-vec)
|
|
443
466
|
|
|
444
467
|
### 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
|
}
|