@sdsrs/code-graph 0.119.0 → 0.120.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.119.0",
7
+ "version": "0.120.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -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,54 @@ 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
+ if (uninstalled) {
605
+ unadopted = unadoptRegisteredProjects();
606
+ cacheRemoved = removeCacheResidue();
607
+ }
608
+
609
+ return { cleaned: true, settingsChanged, cacheRemoved, unadopted };
610
+ }
593
611
 
594
- return { cleaned: true, settingsChanged, cacheRemoved };
612
+ /**
613
+ * Strip the managed CLAUDE.md block + generated `.claude/plugin_code_graph_mcp.md`
614
+ * from every project in the adopted-projects registry. `unadopt` is
615
+ * sentinel-guarded, so user prose outside the managed block is preserved and a
616
+ * project that was already cleaned is a no-op.
617
+ *
618
+ * Fully swallowed per project AND overall: this runs inside a statusline render
619
+ * (statusline.js / statusline-composite.js call the caller at their top), where
620
+ * an uncaught throw blanks the user's status line — and an unreadable project
621
+ * must not cost the remaining ones their cleanup.
622
+ */
623
+ function unadoptRegisteredProjects() {
624
+ const out = [];
625
+ let readAdoptedProjects, unadopt;
626
+ try { ({ readAdoptedProjects, unadopt } = require('./adopt')); }
627
+ catch { return out; } // POSIX-only helper unavailable — teardown continues
628
+ let projects = [];
629
+ try { projects = readAdoptedProjects() || []; } catch { return out; }
630
+ for (const project of projects) {
631
+ try {
632
+ const r = unadopt({ cwd: project });
633
+ out.push({ project, cleaned: !!(r && (r.blockPruned || r.fileRemoved || r.claudeMdRemoved)) });
634
+ } catch (e) {
635
+ out.push({ project, cleaned: false, error: (e && e.message) || String(e) });
636
+ }
637
+ }
638
+ return out;
595
639
  }
596
640
 
597
641
  // --- Scope Conflict Detection ---
@@ -1690,7 +1734,7 @@ function removeCacheResidue() {
1690
1734
  module.exports = {
1691
1735
  install, uninstall, update, healthCheck, scanForBrokenPaths, checkScopeConflict,
1692
1736
  isPluginExplicitlyDisabled, isPluginInactive, isPluginUninstalled, removeCacheResidue,
1693
- cleanupDisabledStatusline,
1737
+ cleanupDisabledStatusline, unadoptRegisteredProjects,
1694
1738
  readManifest, readJson, readJsonResult, readSettingsForWrite, writeJsonAtomic,
1695
1739
  readRegistry, readRegistryForWrite, writeRegistry,
1696
1740
  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.0 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.0",
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.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.120.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.120.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.120.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.120.0"
43
43
  }
44
44
  }