@sdsrs/code-graph 0.7.15 → 0.7.16

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.7.15",
7
+ "version": "0.7.16",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -1,4 +1,16 @@
1
1
  {
2
- "_note": "Hooks registered to settings.json by lifecycle.js — hooks.json auto-loading is unreliable",
3
- "hooks": {}
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "startup|clear|compact",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-init.js\"",
10
+ "timeout": 5
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
4
16
  }
@@ -68,7 +68,8 @@ function saveState(state) {
68
68
  // ── Dev Mode Detection ─────────────────────────────────────
69
69
 
70
70
  function isDevMode() {
71
- const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || path.resolve(__dirname, '..');
71
+ // Always derive from __dirname — CLAUDE_PLUGIN_ROOT can leak from other plugins
72
+ const pluginRoot = path.resolve(__dirname, '..');
72
73
  // Dev mode: running from source repo (has Cargo.toml nearby)
73
74
  if (fs.existsSync(path.join(pluginRoot, '..', 'Cargo.toml'))) return true;
74
75
  // Dev mode: plugin root is a symlink
@@ -281,6 +282,20 @@ async function downloadAndInstall(latest) {
281
282
  writeJsonAtomic(path.join(CACHE_DIR, 'install-manifest.json'), manifest);
282
283
  } catch { /* not fatal */ }
283
284
 
285
+ // Run the NEW lifecycle.js to update settings.json hooks with new paths.
286
+ // Without this, settings.json hooks still point to the old version directory
287
+ // until the next session's self-heal corrects them.
288
+ if (pluginUpdated) {
289
+ try {
290
+ const newLifecycle = path.join(pluginDst, 'scripts', 'lifecycle.js');
291
+ if (fs.existsSync(newLifecycle)) {
292
+ execFileSync(process.execPath, [newLifecycle, 'update'], {
293
+ timeout: 5000, stdio: 'pipe',
294
+ });
295
+ }
296
+ } catch { /* not fatal — syncLifecycleConfig will self-heal on next session */ }
297
+ }
298
+
284
299
  // ── Step 2: Download platform binary directly from GitHub release ──
285
300
  if (latest.binaryUrl) {
286
301
  try {
@@ -26,9 +26,13 @@ function readJson(filePath) {
26
26
  }
27
27
 
28
28
  function runScript(homeDir, scriptPath, args = [], options = {}) {
29
+ const env = { ...process.env, HOME: homeDir };
30
+ // Do NOT set CLAUDE_PLUGIN_ROOT — lifecycle.js derives PLUGIN_ROOT from __dirname
31
+ // to avoid env var leakage from other plugins in shared hook execution context.
32
+ delete env.CLAUDE_PLUGIN_ROOT;
29
33
  return execFileSync(process.execPath, [scriptPath, ...args], {
30
34
  cwd: options.cwd || repoRoot,
31
- env: { ...process.env, HOME: homeDir, CLAUDE_PLUGIN_ROOT: pluginRoot },
35
+ env,
32
36
  input: options.input,
33
37
  stdio: ['pipe', 'pipe', 'pipe'],
34
38
  }).toString();
@@ -11,7 +11,10 @@ const OLD_PLUGIN_IDS = [
11
11
  ];
12
12
  const MARKETPLACE_NAME = 'code-graph-mcp';
13
13
  const CACHE_DIR = path.join(os.homedir(), '.cache', 'code-graph');
14
- const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT || path.resolve(__dirname, '..');
14
+ // Always derive from __dirname — CLAUDE_PLUGIN_ROOT env var can leak from other
15
+ // plugins when hooks run in shared process context (e.g. claude-mem-lite sets it
16
+ // to its own marketplace path, polluting all subsequent settings.json hook processes).
17
+ const PLUGIN_ROOT = path.resolve(__dirname, '..');
15
18
  const MANIFEST_FILE = path.join(CACHE_DIR, 'install-manifest.json');
16
19
  const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
17
20
  const INSTALLED_PLUGINS_PATH = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json');
@@ -223,9 +226,18 @@ function migrateOldPluginIds(settings) {
223
226
  // Same strategy as claude-mem-lite. hooks.json is kept empty to prevent double-firing.
224
227
 
225
228
  const OUR_HOOK_SCRIPTS = ['session-init.js', 'incremental-index.js', 'user-prompt-context.js', 'pre-edit-guide.js'];
229
+ const OUR_DESCRIPTIONS = [
230
+ 'StatusLine self-heal, lifecycle sync, project map injection',
231
+ 'Auto-inject impact analysis when editing functions with 2+ callers',
232
+ 'Auto-update code graph index after file edits',
233
+ 'Inject code-graph structural context based on user intent',
234
+ ];
226
235
 
227
236
  function isOurHookEntry(entry) {
228
237
  if (!entry || !entry.hooks) return false;
238
+ // Primary: match by description (immune to path pollution)
239
+ if (entry.description && OUR_DESCRIPTIONS.includes(entry.description)) return true;
240
+ // Fallback: match by script name + 'code-graph' in path
229
241
  return entry.hooks.some(h =>
230
242
  h.command && OUR_HOOK_SCRIPTS.some(s => h.command.includes(s)) &&
231
243
  h.command.includes('code-graph')
@@ -269,17 +281,17 @@ function registerHooksToSettings(settings) {
269
281
  for (const [event, newEntries] of Object.entries(defs)) {
270
282
  if (!settings.hooks[event]) settings.hooks[event] = [];
271
283
 
284
+ // First, remove ALL existing entries that match ours (cleans up duplicates
285
+ // from prior PLUGIN_ROOT pollution where isOurHookEntry couldn't match,
286
+ // causing infinite re-adds each session).
287
+ const beforeLen = settings.hooks[event].length;
288
+ settings.hooks[event] = settings.hooks[event].filter(e => !isOurHookEntry(e));
289
+ if (settings.hooks[event].length !== beforeLen) changed = true;
290
+
291
+ // Then add our entries fresh with correct paths
272
292
  for (const newEntry of newEntries) {
273
- const existingIdx = settings.hooks[event].findIndex(e => isOurHookEntry(e));
274
- if (existingIdx >= 0) {
275
- if (JSON.stringify(settings.hooks[event][existingIdx]) !== JSON.stringify(newEntry)) {
276
- settings.hooks[event][existingIdx] = newEntry;
277
- changed = true;
278
- }
279
- } else {
280
- settings.hooks[event].push(newEntry);
281
- changed = true;
282
- }
293
+ settings.hooks[event].push(newEntry);
294
+ changed = true;
283
295
  }
284
296
  }
285
297
 
@@ -328,6 +340,13 @@ function install() {
328
340
  settings.statusLine = { type: 'command', command: compositeCommand() };
329
341
  settingsChanged = true;
330
342
  manifest.config.statusLine = true;
343
+ } else {
344
+ // Composite exists — ensure path is correct (may have been polluted by env leak)
345
+ const cmd = compositeCommand();
346
+ if (settings.statusLine.command !== cmd) {
347
+ settings.statusLine.command = cmd;
348
+ settingsChanged = true;
349
+ }
331
350
  }
332
351
 
333
352
  // Register code-graph provider
@@ -506,8 +525,60 @@ function cleanupOldCacheVersions(keep = 3) {
506
525
  } catch { /* cache dir doesn't exist — nothing to clean */ }
507
526
  }
508
527
 
528
+ // --- Health Check ---
529
+ // Validates all registered paths in settings.json point to existing scripts.
530
+ // Returns { healthy, issues, repaired }.
531
+
532
+ function healthCheck() {
533
+ const settings = readJson(SETTINGS_PATH) || {};
534
+ const issues = [];
535
+
536
+ // Check statusLine path
537
+ if (isOurComposite(settings)) {
538
+ const m = settings.statusLine.command.match(/node\s+"([^"]+)"/);
539
+ if (m && m[1] && !fs.existsSync(m[1])) {
540
+ issues.push({ type: 'statusLine', path: m[1] });
541
+ }
542
+ }
543
+
544
+ // Check hook paths
545
+ if (settings.hooks) {
546
+ for (const [event, entries] of Object.entries(settings.hooks)) {
547
+ if (!Array.isArray(entries)) continue;
548
+ for (const entry of entries) {
549
+ if (!isOurHookEntry(entry) || !entry.hooks) continue;
550
+ for (const h of entry.hooks) {
551
+ const m = h.command && h.command.match(/node\s+"([^"]+)"/);
552
+ if (m && m[1] && !fs.existsSync(m[1])) {
553
+ issues.push({ type: 'hook', event, path: m[1] });
554
+ }
555
+ }
556
+ }
557
+ }
558
+ }
559
+
560
+ // Check registry paths
561
+ const registry = readRegistry();
562
+ for (const provider of registry) {
563
+ if (provider.id === '_previous') continue;
564
+ const m = provider.command && provider.command.match(/node\s+"([^"]+)"/);
565
+ if (m && m[1] && !fs.existsSync(m[1])) {
566
+ issues.push({ type: 'registry', id: provider.id, path: m[1] });
567
+ }
568
+ }
569
+
570
+ // Auto-repair if issues found
571
+ let repaired = false;
572
+ if (issues.length > 0) {
573
+ install();
574
+ repaired = true;
575
+ }
576
+
577
+ return { healthy: issues.length === 0, issues, repaired };
578
+ }
579
+
509
580
  module.exports = {
510
- install, uninstall, update, checkScopeConflict,
581
+ install, uninstall, update, healthCheck, checkScopeConflict,
511
582
  isPluginExplicitlyDisabled, isPluginInactive, cleanupDisabledStatusline,
512
583
  readManifest, readJson, writeJsonAtomic,
513
584
  readRegistry, writeRegistry,
@@ -516,7 +587,7 @@ module.exports = {
516
587
  PLUGIN_ID, OLD_PLUGIN_IDS, MARKETPLACE_NAME, CACHE_DIR, REGISTRY_FILE,
517
588
  };
518
589
 
519
- // CLI: node lifecycle.js <install|uninstall|update>
590
+ // CLI: node lifecycle.js <install|uninstall|update|health>
520
591
  if (require.main === module) {
521
592
  const cmd = process.argv[2];
522
593
  if (cmd === 'install') {
@@ -528,8 +599,18 @@ if (require.main === module) {
528
599
  } else if (cmd === 'update') {
529
600
  const r = update();
530
601
  console.log(`Updated ${r.oldVersion} → ${r.version} | settings=${r.settingsChanged}`);
602
+ } else if (cmd === 'health') {
603
+ const r = healthCheck();
604
+ if (r.healthy) {
605
+ console.log('Health: OK — all paths valid');
606
+ } else {
607
+ console.log(`Health: ${r.issues.length} issue(s) found${r.repaired ? ' — repaired' : ''}`);
608
+ for (const issue of r.issues) {
609
+ console.log(` ${issue.type}: ${issue.path || issue.id}`);
610
+ }
611
+ }
531
612
  } else {
532
- console.error('Usage: lifecycle.js <install|uninstall|update>');
613
+ console.error('Usage: lifecycle.js <install|uninstall|update|health>');
533
614
  process.exit(1);
534
615
  }
535
616
  }
@@ -12,7 +12,8 @@ const path = require('path');
12
12
  const fs = require('fs');
13
13
 
14
14
  // Set plugin root so find-binary.js can locate bundled/dev binaries
15
- process.env._FIND_BINARY_ROOT = process.env.CLAUDE_PLUGIN_ROOT || path.resolve(__dirname, '..');
15
+ // Always derive from __dirname — CLAUDE_PLUGIN_ROOT can leak from other plugins
16
+ process.env._FIND_BINARY_ROOT = path.resolve(__dirname, '..');
16
17
 
17
18
  const { findBinary, clearCache } = require('./find-binary');
18
19
 
@@ -35,8 +35,8 @@ function syncLifecycleConfig() {
35
35
  update();
36
36
  return 'updated';
37
37
  }
38
- // Self-heal: version matches but statusLine may have been lost
39
- // (e.g. plugin removed and reinstalled without lifecycle uninstall).
38
+ // Self-heal: version matches but statusLine may have been lost or path corrupted
39
+ // (e.g. plugin removed and reinstalled, or CLAUDE_PLUGIN_ROOT leaked from another plugin).
40
40
  // install() is idempotent — isOurComposite guard prevents duplicate work.
41
41
  const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json')) || {};
42
42
  if (!settings.statusLine || !settings.statusLine.command ||
@@ -44,6 +44,28 @@ function syncLifecycleConfig() {
44
44
  install();
45
45
  return 'self-healed';
46
46
  }
47
+ // Also self-heal if composite path points to a non-existent script (path pollution)
48
+ const scriptMatch = settings.statusLine.command.match(/node\s+"([^"]+)"/);
49
+ if (scriptMatch && scriptMatch[1] && !fs.existsSync(scriptMatch[1])) {
50
+ install();
51
+ return 'self-healed-bad-path';
52
+ }
53
+ // Self-heal if any hook command points to a non-existent script (path pollution)
54
+ if (settings.hooks) {
55
+ for (const entries of Object.values(settings.hooks)) {
56
+ if (!Array.isArray(entries)) continue;
57
+ for (const entry of entries) {
58
+ if (!entry.hooks) continue;
59
+ for (const h of entry.hooks) {
60
+ const m = h.command && h.command.match(/node\s+"([^"]+)"/);
61
+ if (m && m[1] && m[1].includes('code-graph') && !fs.existsSync(m[1])) {
62
+ install();
63
+ return 'self-healed-bad-hook';
64
+ }
65
+ }
66
+ }
67
+ }
68
+ }
47
69
  return 'noop';
48
70
  }
49
71
 
@@ -82,8 +82,8 @@ function parseCommand(cmd) {
82
82
  }
83
83
 
84
84
  function codeGraphCommand() {
85
- const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || path.resolve(__dirname, '..');
86
- return `node "${path.join(pluginRoot, 'scripts', 'statusline.js')}"`;
85
+ // Always derive from __dirname — CLAUDE_PLUGIN_ROOT can leak from other plugins
86
+ return `node "${path.join(__dirname, 'statusline.js')}"`;
87
87
  }
88
88
 
89
89
  module.exports = { run };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.7.15",
3
+ "version": "0.7.16",
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": {
@@ -34,10 +34,10 @@
34
34
  "node": ">=16"
35
35
  },
36
36
  "optionalDependencies": {
37
- "@sdsrs/code-graph-linux-x64": "0.7.15",
38
- "@sdsrs/code-graph-linux-arm64": "0.7.15",
39
- "@sdsrs/code-graph-darwin-x64": "0.7.15",
40
- "@sdsrs/code-graph-darwin-arm64": "0.7.15",
41
- "@sdsrs/code-graph-win32-x64": "0.7.15"
37
+ "@sdsrs/code-graph-linux-x64": "0.7.16",
38
+ "@sdsrs/code-graph-linux-arm64": "0.7.16",
39
+ "@sdsrs/code-graph-darwin-x64": "0.7.16",
40
+ "@sdsrs/code-graph-darwin-arm64": "0.7.16",
41
+ "@sdsrs/code-graph-win32-x64": "0.7.16"
42
42
  }
43
43
  }