@sdsrs/code-graph 0.119.0 → 0.120.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.119.0",
7
+ "version": "0.120.1",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -808,7 +808,17 @@ function unadopt({ cwd, home } = {}) {
808
808
  // Also sweep any legacy memory-dir remnants (uninstall before auto-migration ran).
809
809
  const migrated = migrateLegacyMemoryDir({ cwd, home });
810
810
 
811
- const registryUpdated = removeAdopted(effectiveCwd, home);
811
+ // Deregister ONLY what we actually cleaned. The registry is the sole record
812
+ // of which repos carry a managed block — `uninstall --unadopt-all` and the
813
+ // uninstall-time sweep are both driven by it — so dropping the entry for a
814
+ // project whose CLAUDE.md we could not rewrite (root-owned file, read-only
815
+ // mount, EPERM dir) strands that block with nothing left pointing at it.
816
+ // Harmless while the caller only ever passed the current cwd; load-bearing
817
+ // since the uninstall sweep walks the whole list, because an unconditional
818
+ // deregister empties the file and removeCacheResidue() only preserves a
819
+ // NON-EMPTY registry — so the failed project's record died with the cache.
820
+ const cleanupFailed = claudeMdUnwritable || claudeMdUnreadable;
821
+ const registryUpdated = cleanupFailed ? false : removeAdopted(effectiveCwd, home);
812
822
  return {
813
823
  ok: true, fileRemoved, blockPruned, claudeMdRemoved,
814
824
  claudeMdUnreadable, claudeMdUnwritable, registryUpdated,
@@ -889,7 +899,7 @@ if (require.main === module) {
889
899
 
890
900
  module.exports = {
891
901
  adopt, unadopt, memoryDir, formatResult, stripSentinelBlock,
892
- readAdoptedProjects, recordAdopted, removeAdopted, adoptedRegistryFile,
902
+ readAdoptedProjects, readAdoptedResult, recordAdopted, removeAdopted, adoptedRegistryFile,
893
903
  isAdopted, isPluginModeInstall, maybeAutoAdopt, needsRefresh, isProjectRoot,
894
904
  detectProjectType, buildBlock, buildTriggerRows, migrateLegacyMemoryDir,
895
905
  claudeMdPath, detailDir, detailPath,
@@ -1063,7 +1063,12 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
1063
1063
  // that set it, and kept polling GitHub on the ordinary interval while
1064
1064
  // already rate-limited. Dead code since the backoff was written.
1065
1065
  saveState({ ...readState(), installedVersion, lastCheck: new Date().toISOString() });
1066
- return null;
1066
+ // NOT null: null is the CLI's "nothing to do" and it prints "Up to date
1067
+ // (vX)". A failed fetch means the opposite — the update status is
1068
+ // UNKNOWN — and a user running this because they are stuck on an old
1069
+ // version was being told the old version is current (offline, captive
1070
+ // portal, proxy, GitHub 5xx all land here).
1071
+ return { noop: true, reason: 'fetch-failed', from: installedVersion };
1067
1072
  }
1068
1073
 
1069
1074
  // Compare versions
@@ -1079,7 +1084,9 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
1079
1084
  // against our own parent.
1080
1085
  if (process.env.CODE_GRAPH_INSTALL_LOCK_HELD !== '1') {
1081
1086
  installLock = acquireLock(path.join(CACHE_DIR, 'install.lock'));
1082
- if (!installLock) return null;
1087
+ // Reason, not null — same misreport as the fetch failure above: the
1088
+ // holder is mid-update, so "Up to date (v<old>)" is exactly wrong.
1089
+ if (!installLock) return { noop: true, reason: 'install-lock-held', from: installedVersion };
1083
1090
  }
1084
1091
 
1085
1092
  if (hasUpdate) {
@@ -1266,6 +1273,14 @@ if (require.main === module) {
1266
1273
  console.log(`Update available: v${result.to} (auto-install failed)`);
1267
1274
  } else if (result && result.binaryUpdated) {
1268
1275
  console.log(`Repaired binary cache (v${result.to})`);
1276
+ } else if (result && result.noop && result.reason === 'fetch-failed') {
1277
+ // Message only — the exit code stays 0 on purpose: an unreachable
1278
+ // GitHub is not a failure of this command, and the launcher's install
1279
+ // chain plus `doctor` both spawn it.
1280
+ console.log(`Could not reach GitHub — update status UNKNOWN (still on v${result.from}). ` +
1281
+ 'Retried on the next session; run `code-graph-mcp doctor` if it persists.');
1282
+ } else if (result && result.noop && result.reason === 'install-lock-held') {
1283
+ console.log(`Another session is installing/updating right now — check skipped (on v${result.from}).`);
1269
1284
  } else if (!installMissing && isAutoUpdateDisabled()) {
1270
1285
  console.log('CODE_GRAPH_NO_AUTO_UPDATE=1 — auto-update skipped');
1271
1286
  } else if (!installMissing && isDevMode()) {
@@ -230,6 +230,36 @@ function isPlausibleReleaseBinary(candidate) {
230
230
  try { return fs.statSync(candidate).size > 1_000_000; } catch { return false; }
231
231
  }
232
232
 
233
+ /**
234
+ * Both places npm may put the platform optionalDependency inside a
235
+ * `node_modules` root, most-likely first:
236
+ *
237
+ * <root>/@sdsrs/code-graph-<plat>-<arch>/ (hoisted)
238
+ * <root>/@sdsrs/code-graph/node_modules/@sdsrs/code-graph-…/ (nested)
239
+ *
240
+ * Hoisting is an npm implementation detail, not a contract: npm 12 leaves the
241
+ * optionalDependency nested under the shell package on a plain
242
+ * `npm install -g @sdsrs/code-graph`, while older npm hoists it (both layouts
243
+ * were observed on one machine, 2026-08-17). Probing only the hoisted spelling
244
+ * made a SUCCESSFUL npm install read as "did not yield a binary": the launcher
245
+ * then re-downloaded ~41MB from GitHub, and — the part that outlives the
246
+ * session — never called `recordGlobalInstall()`, so `uninstall` had no marker
247
+ * proving the plugin owns those global packages and left them on PATH forever.
248
+ *
249
+ * Discovery only. The global-package INVENTORY scanners (staleGlobalPkgs,
250
+ * installedGlobalPkgs, doctor) stay top-level-on-purpose: a nested
251
+ * optionalDependency is the shell package's private dependency, so
252
+ * `npm install -g`/`npm uninstall -g` on its name would introduce or orphan a
253
+ * top-level global the user never asked for.
254
+ */
255
+ function platformPkgBinaries(nodeModulesRoot) {
256
+ const pkg = `code-graph-${PLATFORM}-${ARCH}`;
257
+ return [
258
+ path.join(nodeModulesRoot, '@sdsrs', pkg, BINARY_NAME),
259
+ path.join(nodeModulesRoot, '@sdsrs', 'code-graph', 'node_modules', '@sdsrs', pkg, BINARY_NAME),
260
+ ];
261
+ }
262
+
233
263
  function platformBinaryCandidates() {
234
264
  const out = [];
235
265
  // Fast path: standard module resolution.
@@ -239,10 +269,11 @@ function platformBinaryCandidates() {
239
269
  if (isNativeBinary(bin) && isPlausibleReleaseBinary(bin)) out.push(bin);
240
270
  } catch { /* not in node_modules walk-up */ }
241
271
 
242
- // Slow path: explicit global node_modules probe.
272
+ // Slow path: explicit global node_modules probe, both npm layouts.
243
273
  for (const globalRoot of globalNodeModulesCandidates()) {
244
- const bin = path.join(globalRoot, '@sdsrs', `code-graph-${PLATFORM}-${ARCH}`, BINARY_NAME);
245
- if (isNativeBinary(bin) && isPlausibleReleaseBinary(bin)) out.push(bin);
274
+ for (const bin of platformPkgBinaries(globalRoot)) {
275
+ if (isNativeBinary(bin) && isPlausibleReleaseBinary(bin)) out.push(bin);
276
+ }
246
277
  }
247
278
 
248
279
  return out;
@@ -363,9 +394,12 @@ function findBinaryUncached() {
363
394
  const npxDir = path.join(os.homedir(), '.npm', '_npx');
364
395
  try {
365
396
  for (const entry of fs.readdirSync(npxDir)) {
366
- const platDir = path.join(npxDir, entry, 'node_modules', '@sdsrs', `code-graph-${PLATFORM}-${ARCH}`);
367
- const hit = gate.consider(path.join(platDir, BINARY_NAME));
368
- if (hit) return hit;
397
+ // Same hoisted-or-nested question as the global prefix — an npx tree is
398
+ // just another node_modules root, and npm decides the layout there too.
399
+ for (const bin of platformPkgBinaries(path.join(npxDir, entry, 'node_modules'))) {
400
+ const hit = gate.consider(bin);
401
+ if (hit) return hit;
402
+ }
369
403
  }
370
404
  } catch { /* no npx cache */ }
371
405
 
@@ -588,10 +588,92 @@ function cleanupDisabledStatusline() {
588
588
  // still run after `/plugin uninstall` — Claude Code stops loading the
589
589
  // plugin's hooks.json, so the SessionStart teardown in session-init.js never
590
590
  // fires post-uninstall. Without this, the ~40MB cached binary leaked forever.
591
+ //
592
+ // Adoption comes off in the SAME breath, and BEFORE the wipe: install's
593
+ // auto-adopt writes a managed block into every project's CLAUDE.md, and until
594
+ // now nothing reachable removed it. session-init.js owned that step, on the
595
+ // branch its own comment concedes "usually never runs again" after a real
596
+ // uninstall — so the block (steering Claude at a CLI that is being deleted two
597
+ // lines below) survived forever in every adopted repo. The registry that names
598
+ // those projects lives inside CACHE_DIR, which is exactly why this must run
599
+ // first: same capture-before-cleanup ordering the rest of this teardown
600
+ // already learned. This branch fires at most once — the write above removes
601
+ // our composite from settings.json, so Claude Code stops invoking us.
591
602
  let cacheRemoved = false;
592
- if (uninstalled) cacheRemoved = removeCacheResidue();
603
+ let unadopted = [];
604
+ let registryUnusable = false;
605
+ if (uninstalled) {
606
+ ({ unadopted, registryUnusable } = unadoptRegisteredProjects());
607
+ cacheRemoved = removeCacheResidue();
608
+ }
593
609
 
594
- return { cleaned: true, settingsChanged, cacheRemoved };
610
+ return { cleaned: true, settingsChanged, cacheRemoved, unadopted, registryUnusable };
611
+ }
612
+
613
+ /**
614
+ * Strip the managed CLAUDE.md block + generated `.claude/plugin_code_graph_mcp.md`
615
+ * from every project in the adopted-projects registry. `unadopt` is
616
+ * sentinel-guarded, so user prose outside the managed block is preserved and a
617
+ * project that was already cleaned is a no-op.
618
+ *
619
+ * Fully swallowed per project AND overall: this runs inside a statusline render
620
+ * (statusline.js / statusline-composite.js call the caller at their top), where
621
+ * an uncaught throw blanks the user's status line — and an unreadable project
622
+ * must not cost the remaining ones their cleanup.
623
+ */
624
+ function unadoptRegisteredProjects() {
625
+ const out = [];
626
+ let readAdoptedResult, unadopt;
627
+ try { ({ readAdoptedResult, unadopt } = require('./adopt')); }
628
+ catch { return { unadopted: out, registryUnusable: false }; } // POSIX-only helper unavailable — teardown continues
629
+ // readAdoptedResult, NOT the lenient readAdoptedProjects(): that wrapper
630
+ // collapses "unreadable / truncated / wrong shape" into `[]`, which here is
631
+ // indistinguishable from "no projects to clean" — so a corrupt registry would
632
+ // silently sweep nothing AND still be deleted by removeCacheResidue() below,
633
+ // taking the only record of which repos carry a managed block with it. adopt.js
634
+ // documents the same bit for the same reason: only a genuinely ABSENT file may
635
+ // be read as empty.
636
+ let res;
637
+ try { res = readAdoptedResult(); } catch { return { unadopted: out, registryUnusable: true }; }
638
+ if (res && res.unusable) return { unadopted: out, registryUnusable: true };
639
+ for (const project of (res && res.list) || []) {
640
+ try {
641
+ const r = unadopt({ cwd: project });
642
+ out.push({ project, cleaned: !!(r && (r.blockPruned || r.fileRemoved || r.claudeMdRemoved)) });
643
+ } catch (e) {
644
+ out.push({ project, cleaned: false, error: (e && e.message) || String(e) });
645
+ }
646
+ }
647
+ reportUnadoptSweep(out);
648
+ return { unadopted: out, registryUnusable: false };
649
+ }
650
+
651
+ /**
652
+ * Tell the user their repos were just edited. Everything else about this
653
+ * teardown is invisible by construction: it runs inside a statusline render,
654
+ * both callers `process.exit(0)` on `cleaned` and discard the return value, and
655
+ * Claude Code fires no uninstall hook — so without this line the first signal is
656
+ * unexplained `CLAUDE.md` diffs in `git status` across several repositories.
657
+ * stderr, because a statusline's stdout IS the status line.
658
+ */
659
+ function reportUnadoptSweep(entries) {
660
+ try {
661
+ const cleaned = entries.filter((e) => e && e.cleaned).map((e) => e.project);
662
+ const failed = entries.filter((e) => e && !e.cleaned).map((e) => e.project);
663
+ if (!cleaned.length && !failed.length) return;
664
+ const lines = [];
665
+ if (cleaned.length) {
666
+ lines.push(`[code-graph] Plugin uninstalled — removed the managed CLAUDE.md block from ${cleaned.length} project(s):`);
667
+ for (const p of cleaned.slice(0, 10)) lines.push(` ${p}`);
668
+ if (cleaned.length > 10) lines.push(` …and ${cleaned.length - 10} more`);
669
+ lines.push(' Your own text outside the block was kept; .code-graph/ index dirs are untouched.');
670
+ }
671
+ if (failed.length) {
672
+ lines.push(`[code-graph] Could NOT clean ${failed.length} project(s) — remove the block by hand or run \`code-graph-mcp unadopt\` there:`);
673
+ for (const p of failed.slice(0, 10)) lines.push(` ${p}`);
674
+ }
675
+ process.stderr.write(lines.join('\n') + '\n');
676
+ } catch { /* a notice must never be able to fail a teardown */ }
595
677
  }
596
678
 
597
679
  // --- Scope Conflict Detection ---
@@ -1672,9 +1754,20 @@ function removeCacheResidue() {
1672
1754
  try {
1673
1755
  registryPath = require('./adopt').adoptedRegistryFile();
1674
1756
  const raw = fs.existsSync(registryPath) ? fs.readFileSync(registryPath) : null;
1675
- const parsed = raw ? JSON.parse(raw.toString('utf8')) : null;
1676
- if (Array.isArray(parsed) && parsed.length) registry = raw;
1677
- } catch { /* POSIX-only helper, unreadable, or corrupt — nothing to preserve */ }
1757
+ if (raw) {
1758
+ let parsed = null;
1759
+ let usable = true;
1760
+ try { parsed = JSON.parse(raw.toString('utf8')); } catch { usable = false; }
1761
+ // Preserve a NON-EMPTY list (projects still carry a block) and anything we
1762
+ // could not READ as a list. The unusable case used to fall into the same
1763
+ // "nothing to preserve" catch as a missing file — the strictly worse
1764
+ // outcome, since a registry we cannot parse is the one whose contents we
1765
+ // are least able to reconstruct, and the sweep above deliberately skips it
1766
+ // rather than guessing. Only a genuinely EMPTY array strands nothing and
1767
+ // is allowed to go with the cache dir.
1768
+ if (!usable || !Array.isArray(parsed) || parsed.length) registry = raw;
1769
+ }
1770
+ } catch { /* POSIX-only helper or an unreadable path — nothing to preserve */ }
1678
1771
  try {
1679
1772
  fs.rmSync(CACHE_DIR, { recursive: true, force: true });
1680
1773
  } catch { return false; }
@@ -1690,7 +1783,7 @@ function removeCacheResidue() {
1690
1783
  module.exports = {
1691
1784
  install, uninstall, update, healthCheck, scanForBrokenPaths, checkScopeConflict,
1692
1785
  isPluginExplicitlyDisabled, isPluginInactive, isPluginUninstalled, removeCacheResidue,
1693
- cleanupDisabledStatusline,
1786
+ cleanupDisabledStatusline, unadoptRegisteredProjects,
1694
1787
  readManifest, readJson, readJsonResult, readSettingsForWrite, writeJsonAtomic,
1695
1788
  readRegistry, readRegistryForWrite, writeRegistry,
1696
1789
  getPluginVersion, cleanupOldCacheVersions,
@@ -406,6 +406,30 @@ function ensureIndexFresh() {
406
406
  return 'refreshing';
407
407
  }
408
408
 
409
+ /**
410
+ * What to tell a user whose binary is missing. A missing binary is the NORMAL
411
+ * state of the first session after `/plugin install` — nothing ships the ~40MB
412
+ * engine with the plugin — and both automatic install paths are already
413
+ * running by the time the user reads this: launchBackgroundAutoUpdate() below
414
+ * (a missing binary bypasses the check throttle) and the MCP launcher's own
415
+ * install chain. Telling that user "MCP server cannot start. Install: npm
416
+ * install -g" reads as a failed install and sends them to fix something that
417
+ * is already fixing itself.
418
+ *
419
+ * The manual instruction is still the right answer when the user has opted out
420
+ * of auto-update, because then nothing else will fetch it. Pure so both arms
421
+ * are testable without a binary-less machine.
422
+ */
423
+ function missingBinaryMessage(env = process.env) {
424
+ if (env.CODE_GRAPH_NO_AUTO_UPDATE === '1') {
425
+ return '[code-graph] Binary not found, and auto-download is off (CODE_GRAPH_NO_AUTO_UPDATE=1).\n' +
426
+ ' Install it yourself: npm install -g @sdsrs/code-graph\n';
427
+ }
428
+ return '[code-graph] Binary not found — fetching it in the background (~40MB, first run only).\n' +
429
+ ' Tools appear as soon as it lands; no restart needed.\n' +
430
+ ' Still missing next session? Run `code-graph-mcp doctor`.\n';
431
+ }
432
+
409
433
  /**
410
434
  * Verify binary is available and executable.
411
435
  * On macOS, detect Gatekeeper quarantine (common after npm/GitHub download).
@@ -415,10 +439,7 @@ function verifyBinary() {
415
439
  const { findBinary } = require('./find-binary');
416
440
  const binary = findBinary();
417
441
  if (!binary) {
418
- process.stderr.write(
419
- '[code-graph] Binary not found — MCP server cannot start.\n' +
420
- 'Install: npm install -g @sdsrs/code-graph\n'
421
- );
442
+ process.stderr.write(missingBinaryMessage());
422
443
  return { available: false, binary: null };
423
444
  }
424
445
 
@@ -527,6 +548,18 @@ function consistencyCheck(binary) {
527
548
  return issues;
528
549
  }
529
550
 
551
+ /**
552
+ * Do two paths name the same project? The registry stores what `adopt` was
553
+ * given and `process.cwd()` is what the shell resolved, so a symlinked repo
554
+ * path (`/tmp` → `/private/tmp` on macOS) compares unequal as raw strings.
555
+ * realpath both, fall back to the resolved literal when a side no longer exists.
556
+ */
557
+ function samePath(a, b) {
558
+ if (!a || !b) return false;
559
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
560
+ return real(a) === real(b);
561
+ }
562
+
530
563
  function runSessionInit({ source } = {}) {
531
564
  // GC the shared tmp dir before anything else, so it happens even on the
532
565
  // inactive / non-project early returns below — those sessions still wrote
@@ -543,7 +576,8 @@ function runSessionInit({ source } = {}) {
543
576
  // Third caller of the same unguarded teardown (statusline.js and
544
577
  // statusline-composite.js are the others): a read-only ~/.claude turns this
545
578
  // into an uncaught throw that takes down the whole SessionStart hook.
546
- try { cleanupDisabledStatusline(); } catch { /* best-effort teardown */ }
579
+ let cleanup = null;
580
+ try { cleanup = cleanupDisabledStatusline(); } catch { /* best-effort teardown */ }
547
581
  // Genuine uninstall (not a temporary disable) leaves residue the settings-only
548
582
  // self-heal can't reach: ~/.cache/code-graph (the ~40MB binary + state) and the
549
583
  // current project's CLAUDE.md adoption block. CC fires no uninstall hook, AND it
@@ -570,9 +604,18 @@ function runSessionInit({ source } = {}) {
570
604
  // other projects is the preservation inside removeCacheResidue(); this
571
605
  // ordering is the belt to that pair of braces, and it is what keeps the
572
606
  // single-project case from depending on the preservation at all.
573
- let unadopted = false;
607
+ //
608
+ // cleanupDisabledStatusline() now sweeps the WHOLE adopted-projects
609
+ // registry (it is the only teardown that still runs after a real
610
+ // uninstall), so this cwd is usually already clean by the time we get
611
+ // here. Fold that in rather than re-deriving it: with the work moved
612
+ // earlier, the local `unadopt` returns "nothing to clean" and reporting
613
+ // `unadopted:false` for a project whose block IS gone would be a false
614
+ // negative in the one field a caller could act on.
615
+ let unadopted = !!(cleanup && Array.isArray(cleanup.unadopted)
616
+ && cleanup.unadopted.some((u) => u && u.cleaned && samePath(u.project, process.cwd())));
574
617
  try {
575
- if (!isNonProjectCwd(process.cwd())) {
618
+ if (!unadopted && !isNonProjectCwd(process.cwd())) {
576
619
  const r = unadopt({ cwd: process.cwd() });
577
620
  unadopted = !!(r && (r.blockPruned || r.fileRemoved || r.claudeMdRemoved));
578
621
  }
@@ -924,7 +967,7 @@ module.exports = {
924
967
  indexNeedsRevalidation,
925
968
  injectProjectMap,
926
969
  injectRecentImpact,
927
- verifyBinary,
970
+ verifyBinary, missingBinaryMessage,
928
971
  consistencyCheck,
929
972
  runSessionInit,
930
973
  computeQuietHooks,
@@ -35,7 +35,7 @@ jobs:
35
35
  node-version: '20'
36
36
  - name: Build snapshot
37
37
  run: |
38
- npx -y -p @sdsrs/code-graph@0.119.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.120.1 code-graph-mcp snapshot create --out snapshot.db
39
39
  zstd -9 snapshot.db -o snapshot.db.zst
40
40
  mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
41
41
  - name: Upload to release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.119.0",
3
+ "version": "0.120.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": {
@@ -35,10 +35,10 @@
35
35
  "node": ">=16"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@sdsrs/code-graph-linux-x64": "0.119.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.119.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.119.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.119.0",
42
- "@sdsrs/code-graph-win32-x64": "0.119.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.120.1",
39
+ "@sdsrs/code-graph-linux-arm64": "0.120.1",
40
+ "@sdsrs/code-graph-darwin-x64": "0.120.1",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.120.1",
42
+ "@sdsrs/code-graph-win32-x64": "0.120.1"
43
43
  }
44
44
  }