@sdsrs/code-graph 0.103.0 → 0.104.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.103.0",
7
+ "version": "0.104.1",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -9,7 +9,7 @@ const path = require('path');
9
9
  const os = require('os');
10
10
  const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
11
11
  const { claudeHome } = require('./claude-config');
12
- const { clearCache: clearBinaryCache, globalNodeModulesCandidates, PLATFORM_PKG, detectLibc } = require('./find-binary');
12
+ const { clearCache: clearBinaryCache, globalNodeModulesCandidates, nvmNodeModulesDirs, PLATFORM_PKG, detectLibc } = require('./find-binary');
13
13
  const { readBinaryVersion, compareVersions, isDevMode } = require('./version-utils');
14
14
  const { cgTmpDir } = require('./tmp-dir');
15
15
  const { npmSpawnOpts } = require('./npm-exec');
@@ -597,6 +597,31 @@ function staleGlobalPkgs(latestVersion, roots = null) {
597
597
  return out;
598
598
  }
599
599
 
600
+ /**
601
+ * Global installs of ours stranded under a NON-active node version. nvm keeps a
602
+ * separate global prefix per node; switching the default node leaves the old
603
+ * prefix's `@sdsrs/code-graph` behind — invisible to selfHealGlobalPkgs (which
604
+ * only sees, and can only `npm install -g` into, the ACTIVE node's prefix) yet
605
+ * still able to seed stale settings.json hooks / shadow PATH shims (the
606
+ * v24.11.1@0.46.0 relic firing beside the active install — RCA 2026-07-24).
607
+ * Detection-only: returns each relic's package + version + node prefix so doctor
608
+ * can surface it with manual remediation. `dirs`/`activeDir` injectable for tests.
609
+ */
610
+ function inactiveNodeGlobalRelics({ dirs = null, activeDir = null } = {}) {
611
+ const active = path.resolve(activeDir
612
+ || path.join(path.dirname(process.execPath), '..', 'lib', 'node_modules'));
613
+ const roots = dirs || nvmNodeModulesDirs();
614
+ const out = [];
615
+ for (const dir of roots) {
616
+ if (path.resolve(dir) === active) continue; // active prefix → not a relic
617
+ for (const name of [SHELL_PKG, PLATFORM_PKG]) {
618
+ const version = globalPkgVersion(name, [dir]);
619
+ if (version) out.push({ name, version, nodeModulesDir: dir });
620
+ }
621
+ }
622
+ return out;
623
+ }
624
+
600
625
  /** One targeted `npm install -g` for the given specs. Resolves true on exit 0. */
601
626
  function npmInstallGlobal(specs) {
602
627
  return new Promise((resolve) => {
@@ -647,6 +672,21 @@ async function selfHealGlobalPkgs(latest, state, {
647
672
  };
648
673
  }
649
674
 
675
+ // Whether a THROTTLED checkForUpdate should still attempt the global-npm
676
+ // self-heal. The post-fetch heal below only runs on the non-throttle path, but
677
+ // the ONLY context that can SEE a user's nvm/global prefix is a CLI run under
678
+ // that node (globalNodeModulesCandidates is execPath-derived) — and such a run,
679
+ // once binary+shell are current, short-circuits at the throttle early-return and
680
+ // never reaches the heal. That gap stranded a global `code-graph-mcp` shim at
681
+ // 0.101.0 while the binary reached 0.103.0 (RCA 2026-07-24). Cheap local
682
+ // package.json read (readStale) gates the slow, lock-guarded npm path. Split out
683
+ // so the decision is unit-testable without the full checkForUpdate harness.
684
+ function shouldHealGlobalsOnThrottle(state, { readStale = staleGlobalPkgs } = {}) {
685
+ if (!state || !state.latestVersion) return false;
686
+ if (process.env.CODE_GRAPH_INSTALL_LOCK_HELD === '1') return false; // parent launcher holds it
687
+ return readStale(state.latestVersion).length > 0;
688
+ }
689
+
650
690
  async function checkForUpdate({ installMissing = false, force = false } = {}) {
651
691
  let installLock = null;
652
692
  try {
@@ -671,6 +711,16 @@ async function checkForUpdate({ installMissing = false, force = false } = {}) {
671
711
  if (state.installedVersion !== installedVersion) {
672
712
  saveState({ ...state, installedVersion });
673
713
  }
714
+ // Global-npm shell/platform self-heal reaches the throttle window too (see
715
+ // shouldHealGlobalsOnThrottle). Cheap local check first; only the actually-
716
+ // stale case takes the slow, lock-guarded npm path.
717
+ if (shouldHealGlobalsOnThrottle(state)) {
718
+ installLock = acquireLock(path.join(CACHE_DIR, 'install.lock'));
719
+ if (installLock) {
720
+ const globalHeal = await selfHealGlobalPkgs({ version: state.latestVersion }, state);
721
+ saveState({ ...readState(), ...globalHeal });
722
+ }
723
+ }
674
724
  if (state.updateAvailable && state.latestVersion
675
725
  && compareVersions(state.latestVersion, installedVersion) > 0) {
676
726
  return { updateAvailable: true, from: installedVersion, to: state.latestVersion };
@@ -776,6 +826,7 @@ module.exports = {
776
826
  getPlatformAssetName,
777
827
  selfHealStaleBinary,
778
828
  selfHealGlobalPkgs, staleGlobalPkgs, globalPkgVersion, npmInstallGlobal,
829
+ shouldHealGlobalsOnThrottle, inactiveNodeGlobalRelics,
779
830
  downloadAndInstall, refreshMarketplaceClone, marketplaceCloneDir,
780
831
  };
781
832
 
@@ -285,11 +285,29 @@ function runDiagnostics() {
285
285
  // `lifecycle.js uninstall` removes them; without it they are treated as
286
286
  // user-installed and a plugin uninstall leaves them on PATH.
287
287
  try {
288
- const { globalPkgVersion } = require('./auto-update');
288
+ const { globalPkgVersion, inactiveNodeGlobalRelics } = require('./auto-update');
289
289
  const { PLATFORM_PKG } = require('./find-binary');
290
290
  const found = [SHELL_PKG, PLATFORM_PKG]
291
291
  .map((name) => ({ name, version: globalPkgVersion(name) }))
292
292
  .filter((p) => p.version);
293
+
294
+ // Relics stranded under a NON-active node version (nvm keeps a per-node
295
+ // global prefix). selfHealGlobalPkgs / the check above only see the active
296
+ // node, so these drift unseen for months and can seed stale settings.json
297
+ // hooks — the v24.11.1@0.46.0 relic behind the RCA. Report-only: `npm i -g`
298
+ // can't target another node's prefix, so hand the user the exact remediation.
299
+ const relics = inactiveNodeGlobalRelics();
300
+ if (relics.length) {
301
+ const home = require('os').homedir();
302
+ results.push({
303
+ name: 'Global npm relics',
304
+ status: 'warn',
305
+ detail: relics.map((r) => `${r.name}@${r.version} (${r.nodeModulesDir.replace(home, '~')})`).join('; ')
306
+ + ' — installed under a non-active node version; auto-heal cannot reach another node\'s prefix. '
307
+ + 'Remove each via `nvm use <that node> && npm rm -g <pkg>`, or uninstall the unused node (`nvm uninstall <ver>`).',
308
+ });
309
+ }
310
+
293
311
  if (found.length) {
294
312
  const marker = !!readJson(GLOBAL_INSTALL_MARKER);
295
313
  // Heal-exhausted is otherwise invisible: selfHealGlobalPkgs stops after
@@ -119,6 +119,22 @@ function globalNodeModulesCandidates() {
119
119
  return [...new Set(out)];
120
120
  }
121
121
 
122
+ // Every nvm-managed node version's global node_modules dir (`~/.nvm/versions/
123
+ // node/*/lib/node_modules`). nvm keeps a SEPARATE global prefix per node
124
+ // version; switching the default node strands the previous version's globals —
125
+ // a global `@sdsrs/code-graph` there is invisible to globalNodeModulesCandidates
126
+ // (execPath-derived → only the ACTIVE node) yet still shadows PATH shims / seeds
127
+ // stale settings.json hooks (the v24.11.1@0.46.0 relic — RCA 2026-07-24). Used
128
+ // for detection/reporting only; `npm install -g` cannot target another node's
129
+ // prefix. `base` is injectable for hermetic tests (never the real ~/.nvm).
130
+ function nvmNodeModulesDirs(base = path.join(os.homedir(), '.nvm', 'versions', 'node')) {
131
+ let entries;
132
+ try { entries = fs.readdirSync(base); } catch { return []; }
133
+ return entries
134
+ .map((v) => path.join(base, v, 'lib', 'node_modules'))
135
+ .filter((d) => { try { return fs.statSync(d).isDirectory(); } catch { return false; } });
136
+ }
137
+
122
138
  function isNativeBinary(candidate) {
123
139
  if (!candidate) return false;
124
140
  try {
@@ -367,7 +383,7 @@ function clearCache() {
367
383
 
368
384
  module.exports = {
369
385
  findBinary, findBinaryUncached, clearCache,
370
- globalNodeModulesCandidates, findPlatformBinary, platformBinaryCandidates, createVersionGate,
386
+ globalNodeModulesCandidates, nvmNodeModulesDirs, findPlatformBinary, platformBinaryCandidates, createVersionGate,
371
387
  getPackageVersion, compareVersions, isCachedBinaryFresh,
372
388
  detectLibc, unsupportedPlatformHint,
373
389
  CACHE_FILE, BINARY_NAME, PLATFORM_PKG,
@@ -375,13 +375,17 @@ function isOurHookEntry(entry) {
375
375
  if (!entry || !entry.hooks) return false;
376
376
  // Primary: match by description (immune to path pollution).
377
377
  if (entry.description && OUR_DESCRIPTIONS.includes(entry.description)) return true;
378
- // Fallback: script name + MARKETPLACE_NAME in path. v0.32.1: tightened from
379
- // bare 'code-graph' (which would claim a user's own ~/code-graph/foo.js) to
380
- // the actual marketplace dir name 'code-graph-mcp' — Requirement 3 says
381
- // foreign-entry strip is unacceptable, so be conservative.
378
+ // Fallback: script basename + a delivery-surface marker in the path. TWO
379
+ // surfaces ship these scripts: the marketplace plugin-cache (dir
380
+ // 'code-graph-mcp') AND the global npm package (dir '@sdsrs/code-graph' — note
381
+ // NO '-mcp' suffix). v0.32.1 tightened from bare 'code-graph' (which would
382
+ // claim a user's own ~/code-graph/foo.js) to MARKETPLACE_NAME, but that alone
383
+ // missed the npm-global surface, so `npm i -g`-delivered hooks were never
384
+ // evicted and orphan-accumulated across node/version switches (RCA 2026-07-24).
385
+ // Both markers are specific enough not to claim a user's unrelated file.
382
386
  return entry.hooks.some(h =>
383
387
  h.command && OUR_HOOK_SCRIPTS.some(s => h.command.includes(s)) &&
384
- h.command.includes(MARKETPLACE_NAME)
388
+ (h.command.includes(MARKETPLACE_NAME) || h.command.includes(SHELL_PKG))
385
389
  );
386
390
  }
387
391
 
@@ -448,6 +452,23 @@ function buildSettingsHookEntries() {
448
452
  // re-write it to settings.json.
449
453
  function registerHooksToSettings(settings) {
450
454
  settings.hooks = settings.hooks || {};
455
+
456
+ // Idempotent across delivery surfaces: if every desired (event,matcher) is
457
+ // already present exactly once, pointing at a current, existing script
458
+ // (plugin-cache OR global-npm), do nothing. Stops the settings.json ping-pong
459
+ // where the cache session-init and the npm-global CLI doctor each rewrote the
460
+ // other's valid entry every run (RCA 2026-07-24). Any missing/stale/dead entry
461
+ // — or a duplicate ( oursCount > expected) — still triggers evict+rewrite.
462
+ const survey = surveyHookCoverage(settings);
463
+ let oursCount = 0;
464
+ for (const entries of Object.values(settings.hooks)) {
465
+ if (Array.isArray(entries)) oursCount += entries.filter(isOurHookEntry).length;
466
+ }
467
+ if (survey.missing.length === 0 && survey.stale.length === 0
468
+ && oursCount === survey.expected.length) {
469
+ return false;
470
+ }
471
+
451
472
  const before = JSON.stringify(settings.hooks);
452
473
 
453
474
  // Pass 1: evict our entries across every event.
@@ -467,6 +488,22 @@ function registerHooksToSettings(settings) {
467
488
  return before !== JSON.stringify(settings.hooks);
468
489
  }
469
490
 
491
+ // Extract the .js script path a hook command invokes — bare (`node "…"`) or
492
+ // existence-guarded (`if [ -f "…" ]; then node "…"; fi`).
493
+ function hookCmdScript(cmd) {
494
+ const m = (cmd || '').match(/node "([^"]+\.js)"/) || (cmd || '').match(/"([^"]+\.js)"/);
495
+ return m ? m[1] : null;
496
+ }
497
+
498
+ // Version encoded in a plugin-cache path (.../code-graph-mcp/code-graph-mcp/<ver>/scripts/…).
499
+ // Null for in-place installs (global npm), whose path never carries a version
500
+ // dir — npm overwrites the same path on upgrade, so such a path never goes
501
+ // version-stale (only dead-path-stale, caught separately by fs.existsSync).
502
+ function cacheDirVersion(scriptPath) {
503
+ const m = (scriptPath || '').match(/\/code-graph-mcp\/code-graph-mcp\/(\d+\.\d+\.\d+[^/]*)\//);
504
+ return m ? m[1] : null;
505
+ }
506
+
470
507
  // Inventory of (event, matcher) tuples we expect to find in settings.json after
471
508
  // install. Consumed by doctor (report + fix) and session-init (self-heal):
472
509
  // `missing` = entry absent; `stale` = present but the registered command no
@@ -504,10 +541,34 @@ function surveyHookCoverage(settings) {
504
541
  }
505
542
  }
506
543
 
544
+ const { compareVersions } = require('./version-utils');
507
545
  const missing = expected.filter(k => !present.has(k));
508
- const stale = expected.filter(k =>
509
- present.has(k) && desiredCmd[k] && presentCmd[k] && presentCmd[k] !== desiredCmd[k]
510
- );
546
+ // Version/surface-tolerant staleness. Was an exact command-string compare,
547
+ // which made two registration authorities (plugin-cache session-init vs
548
+ // global-npm CLI doctor — different absolute paths) each flag the other's
549
+ // VALID CURRENT entry stale and rewrite it → settings.json ping-pong on every
550
+ // alternating run (RCA 2026-07-24). An entry is stale only when its script is
551
+ // a dead path OR resolves to an OLDER plugin-cache version dir than we'd write
552
+ // now. A present entry on a different but valid, current surface (npm in-place
553
+ // install: file exists, no version in path) is NOT stale.
554
+ const stale = expected.filter(k => {
555
+ if (!present.has(k) || !presentCmd[k]) return false;
556
+ const pScript = hookCmdScript(presentCmd[k]);
557
+ if (!pScript) return false;
558
+ if (!fs.existsSync(pScript)) return true; // dead path
559
+ const pv = cacheDirVersion(pScript);
560
+ if (pv) {
561
+ // Pinned to a plugin-cache version dir: stale iff older than us. Compare
562
+ // against the desired cache dir when we're the cache authority; the
563
+ // global-npm/dev authority's desired path carries no version dir, so
564
+ // fall back to our own plugin version — an old-version cache pin must be
565
+ // healed from EITHER surface, not only when the desired path happens to
566
+ // be cache-shaped. Newer-than-us stays (downgrade-war guard, §1.11).
567
+ const dv = cacheDirVersion(hookCmdScript(desiredCmd[k])) || getPluginVersion();
568
+ return compareVersions(pv, dv) < 0;
569
+ }
570
+ return false; // in-place/current surface
571
+ });
511
572
  return { expected, present: [...present], missing, stale };
512
573
  }
513
574
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.103.0",
3
+ "version": "0.104.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": {
@@ -29,17 +29,17 @@
29
29
  ],
30
30
  "scripts": {
31
31
  "build": "cargo build --release --no-default-features && node scripts/copy-binary.js",
32
- "prepare": "git rev-parse --git-dir > /dev/null 2>&1 && ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit || true",
32
+ "prepare": "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath scripts/githooks || true",
33
33
  "preuninstall": "node claude-plugin/scripts/lifecycle.js uninstall || true"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=16"
37
37
  },
38
38
  "optionalDependencies": {
39
- "@sdsrs/code-graph-linux-x64": "0.103.0",
40
- "@sdsrs/code-graph-linux-arm64": "0.103.0",
41
- "@sdsrs/code-graph-darwin-x64": "0.103.0",
42
- "@sdsrs/code-graph-darwin-arm64": "0.103.0",
43
- "@sdsrs/code-graph-win32-x64": "0.103.0"
39
+ "@sdsrs/code-graph-linux-x64": "0.104.1",
40
+ "@sdsrs/code-graph-linux-arm64": "0.104.1",
41
+ "@sdsrs/code-graph-darwin-x64": "0.104.1",
42
+ "@sdsrs/code-graph-darwin-arm64": "0.104.1",
43
+ "@sdsrs/code-graph-win32-x64": "0.104.1"
44
44
  }
45
45
  }