@sdsrs/code-graph 0.55.0 → 0.56.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.
package/bin/cli.js CHANGED
@@ -18,17 +18,19 @@ if (sub === "adopt" || sub === "unadopt") {
18
18
  process.exit(result.ok === false ? 1 : 0);
19
19
  }
20
20
 
21
- const { findBinary } = require("../claude-plugin/scripts/find-binary");
21
+ const { findBinary, unsupportedPlatformHint } = require("../claude-plugin/scripts/find-binary");
22
22
 
23
23
  const binary = findBinary();
24
24
 
25
25
  if (!binary) {
26
+ const hint = unsupportedPlatformHint();
26
27
  console.error(
27
28
  "Error: code-graph-mcp binary not found.\n\n" +
29
+ (hint ? hint + "\n\n" : "") +
28
30
  "To install:\n" +
29
31
  " npm install -g @sdsrs/code-graph\n\n" +
30
32
  "To build from source:\n" +
31
- " cargo build --release\n"
33
+ " cargo install code-graph-mcp --features embed-model\n"
32
34
  );
33
35
  process.exit(1);
34
36
  }
@@ -41,6 +43,10 @@ const child = spawn(binary, process.argv.slice(2), {
41
43
 
42
44
  child.on("error", (err) => {
43
45
  console.error(`Failed to start code-graph-mcp: ${err.message}`);
46
+ // A glibc binary installed on musl (older npm ignores the `libc` field) is present
47
+ // but fails to exec — surface the actionable platform hint instead of a bare error.
48
+ const hint = unsupportedPlatformHint();
49
+ if (hint) console.error("\n" + hint);
44
50
  process.exit(1);
45
51
  });
46
52
 
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.55.0",
7
+ "version": "0.56.1",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -13,6 +13,38 @@ const { findBinary, clearCache: clearBinaryCache } = require('./find-binary');
13
13
 
14
14
  // ── Diagnostics ───────────────────────────────────────────
15
15
 
16
+ /**
17
+ * Classify embedding/vector availability from a `health-check --json` payload.
18
+ * Pure (no I/O) so it is unit-testable. Surfaces a silent FTS5-only degradation
19
+ * that the prior embedding_progress-only check false-greened as "no embeddable
20
+ * nodes": when the binary is embed-capable and the index HAS embeddable nodes but
21
+ * none are embedded, the vector channel is inactive and semantic search runs
22
+ * FTS5-only — that is a 'warn', not 'ok'.
23
+ * @returns {{name:string, status:'ok'|'warn', detail:string}}
24
+ */
25
+ function classifyEmbeddings(hc) {
26
+ const ep = (hc && hc.embedding_progress) || '0/0';
27
+ const [done, total] = ep.split('/').map(Number);
28
+ if (hc && hc.model_available === false) {
29
+ return { name: 'Embeddings', status: 'warn',
30
+ detail: 'binary built without embed-model — semantic search is FTS5-only; reinstall via npm/plugin for the hybrid binary' };
31
+ }
32
+ if (!total) {
33
+ return { name: 'Embeddings', status: 'ok', detail: 'no embeddable nodes' };
34
+ }
35
+ if (!done) {
36
+ const why = (hc && hc.embedding_status === 'pending')
37
+ ? 'model not downloaded/loaded yet — auto-downloads in background on first search; retry shortly or restart the MCP server'
38
+ : `embedding_status=${(hc && hc.embedding_status) || 'unknown'}`;
39
+ return { name: 'Embeddings', status: 'warn',
40
+ detail: `vector INACTIVE — ${total} embeddable nodes, 0 embedded; semantic search is FTS5-only (${why})` };
41
+ }
42
+ if (done < total) {
43
+ return { name: 'Embeddings', status: 'ok', detail: `hybrid — ${Math.round((done / total) * 100)}% embedded (${done}/${total})` };
44
+ }
45
+ return { name: 'Embeddings', status: 'ok', detail: `hybrid — embeddings complete (${done}/${total})` };
46
+ }
47
+
16
48
  /**
17
49
  * Run all diagnostic checks. Returns an array of:
18
50
  * { name: string, status: 'ok'|'warn'|'error'|'skip', detail: string, fixId?: string }
@@ -119,17 +151,9 @@ function runDiagnostics() {
119
151
  });
120
152
  }
121
153
 
122
- // Embeddings
123
- const ep = hc.embedding_progress || '0/0';
124
- const [done, total] = ep.split('/').map(Number);
125
- if (total > 0 && done < total) {
126
- const pct = Math.round((done / total) * 100);
127
- results.push({ name: 'Embeddings', status: 'ok', detail: `${pct}% (${done}/${total})` });
128
- } else if (total === 0) {
129
- results.push({ name: 'Embeddings', status: 'ok', detail: 'no embeddable nodes' });
130
- } else {
131
- results.push({ name: 'Embeddings', status: 'ok', detail: `100% (${done}/${total})` });
132
- }
154
+ // Embeddings / vector availability — pure classifier; warns on FTS5-only
155
+ // degradation (model missing/not loaded) instead of false-greening it.
156
+ results.push(classifyEmbeddings(hc));
133
157
  }
134
158
  } catch (e) {
135
159
  const rawStderr = e.stderr ? e.stderr.toString() : '';
@@ -453,7 +477,7 @@ function runDoctor(opts = {}) {
453
477
  return { results, issueCount: issues.length };
454
478
  }
455
479
 
456
- module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, surveyHookCoverage, relicRepairGuard };
480
+ module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, surveyHookCoverage, relicRepairGuard, classifyEmbeddings };
457
481
 
458
482
  if (require.main === module) {
459
483
  const args = process.argv.slice(2);
@@ -93,3 +93,32 @@ test('relicRepairGuard blocks settings repair from a relic copy and redirects',
93
93
  // Active (or dev/npm) context → repair proceeds.
94
94
  assert.equal(relicRepairGuard({ relic: false, log: () => {} }), false);
95
95
  });
96
+
97
+ // ── classifyEmbeddings (vector-availability — warns on silent FTS5-only) ──
98
+
99
+ test('classifyEmbeddings WARNS when embed-capable but nothing embedded (vector inactive)', () => {
100
+ const { classifyEmbeddings } = require('./doctor');
101
+ // The exact silent-FTS5 gap: model_available compile-flag true, real embeddable
102
+ // nodes exist, but 0 embedded (model never downloaded/loaded).
103
+ const r = classifyEmbeddings({ model_available: true, embedding_progress: '0/2745',
104
+ embedding_status: 'pending', search_mode: 'fts_only' });
105
+ assert.equal(r.status, 'warn', 'must not false-green a vector-inactive index');
106
+ assert.match(r.detail, /FTS5-only|vector INACTIVE/);
107
+ });
108
+
109
+ test('classifyEmbeddings WARNS when binary lacks embed-model feature', () => {
110
+ const { classifyEmbeddings } = require('./doctor');
111
+ const r = classifyEmbeddings({ model_available: false, embedding_progress: '0/0' });
112
+ assert.equal(r.status, 'warn');
113
+ assert.match(r.detail, /without embed-model/);
114
+ });
115
+
116
+ test('classifyEmbeddings OK for hybrid (partial + complete) and no-embeddable', () => {
117
+ const { classifyEmbeddings } = require('./doctor');
118
+ assert.equal(classifyEmbeddings({ model_available: true, embedding_progress: '900/2745' }).status, 'ok');
119
+ assert.equal(classifyEmbeddings({ model_available: true, embedding_progress: '2745/2745' }).status, 'ok');
120
+ // total === 0 is a non-code index, genuinely nothing to embed → ok, not a false warn.
121
+ const none = classifyEmbeddings({ model_available: true, embedding_progress: '0/0' });
122
+ assert.equal(none.status, 'ok');
123
+ assert.match(none.detail, /no embeddable nodes/);
124
+ });
@@ -12,6 +12,45 @@ const CACHE_FILE = path.join(os.homedir(), '.cache', 'code-graph', 'binary-path'
12
12
  const BINARY_NAME = PLATFORM === 'win32' ? 'code-graph-mcp.exe' : 'code-graph-mcp';
13
13
  const PLATFORM_PKG = `@sdsrs/code-graph-${PLATFORM}-${ARCH}`;
14
14
 
15
+ /**
16
+ * libc flavor of the current Linux runtime: 'glibc' or 'musl'. musl (Alpine) has
17
+ * no `glibcVersionRuntime` in the Node process-report header. Best-effort; returns
18
+ * 'glibc' off Linux or when detection is unavailable.
19
+ */
20
+ function detectLibc() {
21
+ if (PLATFORM !== 'linux') return 'glibc';
22
+ try {
23
+ const header = process.report.getReport().header;
24
+ if (header && header.glibcVersionRuntime) return 'glibc';
25
+ return 'musl';
26
+ } catch {
27
+ try { if (fs.existsSync('/etc/alpine-release')) return 'musl'; } catch { /* ignore */ }
28
+ return 'glibc';
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Actionable install hint for a platform that has NO published prebuilt binary
34
+ * (Alpine/musl, or native Windows-on-ARM), or null when the platform is supported
35
+ * (generic messaging applies). Pure in (platform, arch, libc) so it is unit-testable
36
+ * without running on each OS. Prevents the misleading "npm install
37
+ * @sdsrs/code-graph-<plat>-<arch>" suggestion for a package that does not exist.
38
+ */
39
+ function unsupportedPlatformHint(platform = PLATFORM, arch = ARCH, libc = null) {
40
+ const lc = libc || (platform === 'linux' ? detectLibc() : 'glibc');
41
+ if (platform === 'linux' && lc === 'musl') {
42
+ return 'Detected Alpine/musl libc, which has no prebuilt binary. Install from source:\n'
43
+ + ' cargo install code-graph-mcp --features embed-model\n'
44
+ + 'or use a glibc-based image (e.g. node:20-slim / debian, not node:20-alpine).';
45
+ }
46
+ if (platform === 'win32' && arch === 'arm64') {
47
+ return 'Detected Windows on ARM (arm64), which has no native build. Either use an x64 '
48
+ + 'build of Node.js (the published x64 binary runs under Windows ARM emulation), '
49
+ + 'or install from source:\n cargo install code-graph-mcp --features embed-model';
50
+ }
51
+ return null;
52
+ }
53
+
15
54
  /** Read the npm pkg version from this script's package.json (claude-plugin/../package.json). */
16
55
  function getPackageVersion() {
17
56
  try { return require('../../package.json').version; }
@@ -263,6 +302,7 @@ module.exports = {
263
302
  findBinary, findBinaryUncached, clearCache,
264
303
  globalNodeModulesCandidates, findPlatformBinary,
265
304
  getPackageVersion, compareVersions, isCachedBinaryFresh,
305
+ detectLibc, unsupportedPlatformHint,
266
306
  CACHE_FILE, BINARY_NAME, PLATFORM_PKG,
267
307
  };
268
308
 
@@ -6,7 +6,8 @@ const os = require('os');
6
6
  const path = require('path');
7
7
 
8
8
  const { globalNodeModulesCandidates, findPlatformBinary, BINARY_NAME,
9
- compareVersions, getPackageVersion, isCachedBinaryFresh } = require('./find-binary');
9
+ compareVersions, getPackageVersion, isCachedBinaryFresh,
10
+ unsupportedPlatformHint } = require('./find-binary');
10
11
 
11
12
  function mkDir(t, prefix) {
12
13
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -219,3 +220,27 @@ test('isCachedBinaryFresh: file basename mismatch → not fresh', (t) => {
219
220
  if (process.platform !== 'win32') fs.chmodSync(wrongName, 0o755);
220
221
  assert.equal(isCachedBinaryFresh(wrongName, '0.25.0'), false);
221
222
  });
223
+
224
+ // ── unsupportedPlatformHint (actionable message for tails with no prebuilt binary) ──
225
+
226
+ test('unsupportedPlatformHint flags Alpine/musl with a source/glibc-image hint', () => {
227
+ const hint = unsupportedPlatformHint('linux', 'x64', 'musl');
228
+ assert.ok(hint, 'musl should produce a hint');
229
+ assert.match(hint, /musl|Alpine/);
230
+ assert.match(hint, /cargo install/);
231
+ });
232
+
233
+ test('unsupportedPlatformHint flags native Windows-on-ARM with emulation/source hint', () => {
234
+ const hint = unsupportedPlatformHint('win32', 'arm64', 'glibc');
235
+ assert.ok(hint, 'win32-arm64 should produce a hint');
236
+ assert.match(hint, /Windows on ARM|arm64/);
237
+ assert.match(hint, /x64|cargo install/);
238
+ });
239
+
240
+ test('unsupportedPlatformHint returns null for supported platforms', () => {
241
+ assert.equal(unsupportedPlatformHint('linux', 'x64', 'glibc'), null);
242
+ assert.equal(unsupportedPlatformHint('linux', 'arm64', 'glibc'), null);
243
+ assert.equal(unsupportedPlatformHint('darwin', 'arm64', 'glibc'), null);
244
+ assert.equal(unsupportedPlatformHint('darwin', 'x64', 'glibc'), null);
245
+ assert.equal(unsupportedPlatformHint('win32', 'x64', 'glibc'), null);
246
+ });
@@ -141,3 +141,39 @@ test('lifecycle install writes to CLAUDE_CONFIG_DIR instead of ~/.claude when se
141
141
  'default ~/.claude/settings.json must not be written when override is set');
142
142
  });
143
143
 
144
+ test('composite expands a leading ~ in a _previous command instead of dropping it (issue #24)', (t) => {
145
+ // A user whose prior statusline used a leading ~ (valid in settings.json, which
146
+ // Claude Code runs through a shell). install() captures it verbatim as _previous.
147
+ // The composite runs providers via execFileSync (no shell), so without tilde
148
+ // expansion the command throws ENOENT and is silently swallowed — the user's
149
+ // original statusline vanishes.
150
+ const homeDir = mkHome(t);
151
+ const prevScript = path.join(homeDir, '.claude', 'utils', 'statusline.sh');
152
+ fs.mkdirSync(path.dirname(prevScript), { recursive: true });
153
+ fs.writeFileSync(prevScript, '#!/bin/sh\necho "PREV-STATUSLINE-OK"\n');
154
+ fs.chmodSync(prevScript, 0o755);
155
+
156
+ const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
157
+ writeJson(registryPath, [
158
+ { id: '_previous', command: '~/.claude/utils/statusline.sh', needsStdin: true },
159
+ ]);
160
+
161
+ const out = runScript(homeDir, compositeCli, [], { input: '{}' });
162
+ assert.match(out, /PREV-STATUSLINE-OK/,
163
+ 'a _previous command using a leading ~ must be tilde-expanded, not silently dropped');
164
+ });
165
+
166
+ test('expandTilde mirrors shell tilde expansion (only a leading ~ / ~/)', () => {
167
+ const composite = require('./statusline-composite');
168
+ const home = os.homedir();
169
+ assert.equal(composite.expandTilde('~'), home);
170
+ assert.equal(composite.expandTilde('~/.claude/utils/statusline.sh'),
171
+ path.join(home, '.claude', 'utils', 'statusline.sh'));
172
+ assert.equal(composite.expandTilde('/abs/path/script.sh'), '/abs/path/script.sh');
173
+ assert.equal(composite.expandTilde('node'), 'node');
174
+ assert.equal(composite.expandTilde('~user/script.sh'), '~user/script.sh',
175
+ 'other-user home dirs are not resolved');
176
+ assert.equal(composite.expandTilde('a~/b'), 'a~/b',
177
+ 'only a leading ~ expands, not a mid-string ~');
178
+ });
179
+
@@ -105,7 +105,7 @@ if (process.env.CODE_GRAPH_FORCE_PLUGIN_MCP !== '1' && isNonProjectCwd(process.c
105
105
  return;
106
106
  }
107
107
 
108
- const { findBinary, clearCache } = require('./find-binary');
108
+ const { findBinary, clearCache, unsupportedPlatformHint } = require('./find-binary');
109
109
 
110
110
  let binary = findBinary();
111
111
 
@@ -169,6 +169,14 @@ if (!binary) {
169
169
  const installedViaMarketplace = fs.existsSync(
170
170
  path.join(__dirname, '..', '.claude-plugin', 'plugin.json')
171
171
  );
172
+ const platformHint = unsupportedPlatformHint();
173
+ if (platformHint) {
174
+ // Unsupported platform (Alpine/musl or native Windows-on-ARM): the per-platform
175
+ // npm package does not exist, so the generic "npm install @sdsrs/code-graph-<plat>-<arch>"
176
+ // suggestion below would point at a nonexistent package. Show the source/emulation hint.
177
+ process.stderr.write('[code-graph] Binary not found.\n' + platformHint + '\n');
178
+ process.exit(1);
179
+ }
172
180
  process.stderr.write('[code-graph] Binary not found. Install manually:\n');
173
181
  if (installedViaMarketplace) {
174
182
  process.stderr.write(
@@ -217,6 +225,10 @@ child.on('error', (err) => {
217
225
  ` xattr -d com.apple.quarantine "${binary}"\n`
218
226
  );
219
227
  }
228
+ // A glibc binary installed on musl (older npm ignores the `libc` field) is present
229
+ // but execs into a loader error — surface the actionable platform hint.
230
+ const platformHint = unsupportedPlatformHint();
231
+ if (platformHint) process.stderr.write(platformHint + '\n');
220
232
  process.exit(1);
221
233
  });
222
234
 
@@ -7,22 +7,28 @@
7
7
  */
8
8
  const { execFileSync } = require('child_process');
9
9
  const path = require('path');
10
+ const os = require('os');
10
11
  const lifecycle = require('./lifecycle');
11
12
  const { readRegistry } = lifecycle;
12
13
  const cleanupDisabledStatusline = lifecycle.cleanupDisabledStatusline || (() => ({ cleaned: false }));
13
14
 
14
15
  const SEPARATOR = ' \x1b[2m|\x1b[0m ';
15
16
 
16
- const disabledCleanup = cleanupDisabledStatusline();
17
- if (disabledCleanup.cleaned) process.exit(0);
17
+ function main() {
18
+ const disabledCleanup = cleanupDisabledStatusline();
19
+ if (disabledCleanup.cleaned) process.exit(0);
18
20
 
19
- // Collect stdin (Claude Code pipes JSON context)
20
- let stdinData = '';
21
- let ran = false;
22
- const stdinTimeout = setTimeout(() => { if (!ran) { ran = true; run(''); } }, 2000);
23
- process.stdin.setEncoding('utf8');
24
- process.stdin.on('data', (chunk) => { stdinData += chunk; });
25
- process.stdin.on('end', () => { clearTimeout(stdinTimeout); if (!ran) { ran = true; run(stdinData); } });
21
+ // Collect stdin (Claude Code pipes JSON context)
22
+ let stdinData = '';
23
+ let ran = false;
24
+ const stdinTimeout = setTimeout(() => { if (!ran) { ran = true; run(''); } }, 2000);
25
+ process.stdin.setEncoding('utf8');
26
+ process.stdin.on('data', (chunk) => { stdinData += chunk; });
27
+ process.stdin.on('end', () => { clearTimeout(stdinTimeout); if (!ran) { ran = true; run(stdinData); } });
28
+ }
29
+
30
+ // Only run the statusline when invoked as a CLI; `require()` (tests) just imports helpers.
31
+ if (require.main === module) main();
26
32
 
27
33
  function run(stdin) {
28
34
  const registry = readRegistry();
@@ -58,7 +64,14 @@ function runProvider(command, needsStdin, stdin) {
58
64
  const parts = parseCommand(command);
59
65
  if (!parts) return null;
60
66
 
61
- const out = execFileSync(parts[0], parts.slice(1), {
67
+ // Claude Code runs statusLine.command through a shell, so a leading `~`
68
+ // (e.g. `~/.claude/utils/statusline.sh`) is expanded natively. execFileSync
69
+ // does NOT use a shell, so we must expand `~/` ourselves on every word —
70
+ // otherwise a `_previous` command captured verbatim throws ENOENT and gets
71
+ // swallowed below, silently dropping the user's original statusline.
72
+ const argv = parts.map(expandTilde);
73
+
74
+ const out = execFileSync(argv[0], argv.slice(1), {
62
75
  timeout: 3000,
63
76
  stdio: ['pipe', 'pipe', 'pipe'],
64
77
  input: needsStdin ? stdin : '',
@@ -81,9 +94,19 @@ function parseCommand(cmd) {
81
94
  return parts.length > 0 ? parts : null;
82
95
  }
83
96
 
97
+ // Expand a leading `~` / `~/` to the home directory, mirroring shell tilde
98
+ // expansion (which Claude Code applies when it runs statusLine.command, but
99
+ // execFileSync does not). Only a bare `~` or a `~/`-prefixed word is expanded;
100
+ // `~user` and mid-string `~` are left untouched (we don't resolve other users).
101
+ function expandTilde(p) {
102
+ if (p === '~') return os.homedir();
103
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
104
+ return p;
105
+ }
106
+
84
107
  function codeGraphCommand() {
85
108
  // Always derive from __dirname — CLAUDE_PLUGIN_ROOT can leak from other plugins
86
109
  return `node "${path.join(__dirname, 'statusline.js')}"`;
87
110
  }
88
111
 
89
- module.exports = { run };
112
+ module.exports = { run, runProvider, parseCommand, expandTilde };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.55.0",
3
+ "version": "0.56.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.55.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.55.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.55.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.55.0",
42
- "@sdsrs/code-graph-win32-x64": "0.55.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.56.1",
39
+ "@sdsrs/code-graph-linux-arm64": "0.56.1",
40
+ "@sdsrs/code-graph-darwin-x64": "0.56.1",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.56.1",
42
+ "@sdsrs/code-graph-win32-x64": "0.56.1"
43
43
  }
44
44
  }