@sdsrs/code-graph 0.122.1 → 0.124.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.
package/README.md CHANGED
@@ -54,19 +54,27 @@ Every design decision — from token-aware compression to node_id-based snippet
54
54
 
55
55
  ## Performance
56
56
 
57
- | Metric | Value |
58
- |--------|-------|
59
- | Indexing speed | **300+ files/second** (single-threaded, release build) |
60
- | Incremental re-index | **<250ms** no-change detection via BLAKE3 Merkle tree |
61
- | FTS search P50 / P99 | **<300us / <1ms** |
62
- | Database overhead | **~3.5MB** per 800 nodes |
63
- | Token savings | **5-20x fewer tokens** per code understanding task vs grep+read |
57
+ Every row below is a line `code-graph-mcp benchmark` prints, measured on this
58
+ repository (278 files, 5,065 nodes, 10,731 edges) with a release build. Run the
59
+ same command on your own project — the numbers that matter are yours, and these
60
+ scale with tree size and machine.
64
61
 
65
- Run `code-graph-mcp benchmark` on your own project to measure.
62
+ | `benchmark` line | This repo, v0.123.0 |
63
+ |--------|-------|
64
+ | Full index | **~1.9s** (≈145 files/second, single-threaded) |
65
+ | Incremental (noop) | **~30ms** no-change detection via BLAKE3 Merkle tree |
66
+ | Query latency P50 / P99 | **~575us / ~1.9ms** |
67
+ | DB size | **~21.8MB** (≈4.4MB per 1,000 nodes) |
68
+ | Avg tokens/node | **~239** |
66
69
 
67
70
  ## Efficiency: code-graph vs Traditional Tools
68
71
 
69
- Real-world benchmarks comparing code-graph-mcp tools against traditional approaches (Grep + Read + Glob) on a 33-file Rust project (~537 AST nodes).
72
+ How many TOOL CALLS each question takes, comparing code-graph-mcp against Grep
73
+ + Read + Glob. These are call counts, not token measurements: the left column is
74
+ the round trips the traditional approach needs before it can answer, the right
75
+ column is the single call that answers it. Token cost follows call count only
76
+ loosely — how much source lands in the context window depends on the files, so
77
+ this table does not claim a token ratio.
70
78
 
71
79
  ### Tool Call Reduction
72
80
 
@@ -80,14 +88,25 @@ Real-world benchmarks comparing code-graph-mcp tools against traditional approac
80
88
  | File dependency mapping | 3-5 calls | 1 call (`module_overview` + `include_deps`) | **~75%** |
81
89
  | Similar code detection | N/A | 1 call (`get_ast_node` + `include_similar`) | **unique** |
82
90
 
83
- ### Overall Session Efficiency
91
+ ### Session token savings
84
92
 
85
- | Metric | Without code-graph | With code-graph | Improvement |
86
- |--------|:------------------:|:---------------:|:-----------:|
87
- | Tool calls per navigation task | ~6 | ~1.2 | **~80% fewer** |
88
- | Source lines read into context | ~8,000 lines | ~400 lines (structured) | **~95% less** |
89
- | Navigation token cost | ~36K tokens | ~7K tokens | **~80% saved** |
90
- | Full session token savings | | | **40-60%** |
93
+ `tests/effectiveness_bench.rs` is the only reproducible number here, and it is
94
+ worth being precise about what it measures. For each of five navigation tasks it
95
+ runs the real CLI on a fixture project and compares the response size in bytes
96
+ against a **hand-set** `baseline_bytes` for the Grep+Read approach. Bytes stand
97
+ in for tokens; the baselines are estimates committed once and held fixed, so its
98
+ value is regression tracking over releases, not a measurement of your project.
99
+
100
+ ```bash
101
+ cargo build --no-default-features
102
+ cargo test --test effectiveness_bench --no-default-features -- --ignored --nocapture
103
+ ```
104
+
105
+ On the fixture at v0.123.0 that prints `942 / 23000 = 0.04x`, and the test fails
106
+ if the overall ratio ever exceeds 0.60. Earlier revisions of this README carried
107
+ an "Overall Session Efficiency" table (~80% fewer calls, ~95% fewer lines read,
108
+ 40-60% session savings) whose first rows had no source at all; they are gone
109
+ rather than dressed up.
91
110
 
92
111
  ### What code-graph Uniquely Enables
93
112
 
@@ -454,6 +473,59 @@ Uses SQLite with:
454
473
 
455
474
  Data is stored in `.code-graph/index.db` under the project root (auto-created, gitignored).
456
475
 
476
+ ## Environment variables
477
+
478
+ Every `CODE_GRAPH_*` variable the code reads, in one place — the alternative was
479
+ reading the source to find out a switch existed. All of them are optional; the
480
+ defaults are what you get by doing nothing.
481
+
482
+ **Switches you may actually want**
483
+
484
+ | Variable | Effect |
485
+ |---|---|
486
+ | `CODE_GRAPH_NO_AUTO_UPDATE=1` | Never check GitHub for a new release. |
487
+ | `CODE_GRAPH_NO_AUTO_ADOPT=1` | Do not write the steering block into a project's `CLAUDE.md` on SessionStart. |
488
+ | `CODE_GRAPH_NO_TEMPLATE_REFRESH=1` | Keep hand edits to the generated steering block — it is otherwise refreshed to the current template. |
489
+ | `CODE_GRAPH_QUIET_HOOKS=1` | Hooks inject a one-line pointer instead of the full decision table. |
490
+ | `CODE_GRAPH_VERBOSE_HOOKS=1` | The opposite: opt into the noisy form. |
491
+ | `CODE_GRAPH_NO_BLOCK_GREP=1` | Never turn a `grep` hint into a block — prefix a single command with it to get past one. |
492
+ | `CODE_GRAPH_NO_INJECT=1` | No post-tool AST context injection. |
493
+ | `CODE_GRAPH_NO_RECENT_IMPACT=1` | Skip the recent-impact section of the SessionStart briefing. |
494
+ | `CODE_GRAPH_HOOK_INDEX=on\|off` | Force the incremental-index hook on or off instead of letting it decide. |
495
+ | `CODE_GRAPH_MODEL_DIR=<dir>` | Load the embedding model from here (air-gapped installs). |
496
+ | `CODE_GRAPH_DISABLE_MODEL_DOWNLOAD=1` | Never fetch the model; fail instead. |
497
+ | `CODE_GRAPH_MAX_FILE_SIZE=<bytes>` | Skip files larger than this (default 1 MiB). |
498
+ | `CODE_GRAPH_MAX_CODE_LEN=<bytes>` | Truncate stored per-node source at this length. |
499
+ | `CODE_GRAPH_PARSE_TIMEOUT_MS=<ms>` | Per-file parse timeout. |
500
+ | `CODE_GRAPH_RESYNC_BUDGET=<n>` | Files a read command may re-index before answering (default 8). `CODE_GRAPH_GREP_SYNC_BUDGET` is the older name, still honoured by `grep`. |
501
+ | `CODE_GRAPH_RG_ARGV_BUDGET=<bytes>` | Cap on the argv `grep` builds for ripgrep. |
502
+ | `CODE_GRAPH_INTEGRITY_MAX_BYTES=<bytes>` | Index size above which `health-check` skips `PRAGMA quick_check`. |
503
+ | `CODE_GRAPH_SNAPSHOT_TRUST_URL=1` | Install a snapshot from an arbitrary URL. **A snapshot is a database — treat it like running a script.** |
504
+ | `CODE_GRAPH_SNAPSHOT_TRUST_ORIGIN=1` | Install a snapshot whose origin does not match this repository. |
505
+ | `CODE_GRAPH_SNAPSHOT_PIN=<digest>` | Accept exactly this snapshot digest (an alternative to the two switches above). |
506
+ | `CODE_GRAPH_PROJECT_TYPE=<type>` | Override project-type detection for the steering block. |
507
+ | `CODE_GRAPH_FAIL_ON_RISK=1` | Make the PR impact comment fail the check on HIGH risk (CI). |
508
+
509
+ <details>
510
+ <summary><b>Internal and test-only</b> — set by the plugin's own processes, or by the test suite. Setting them by hand is not supported.</summary>
511
+
512
+ | Variable | Set by | Purpose |
513
+ |---|---|---|
514
+ | `CODE_GRAPH_STATUSLINE_CWD` | statusline-composite | Forwards Claude Code's authoritative cwd to each statusline provider. |
515
+ | `CODE_GRAPH_INSTALL_LOCK_HELD` | launcher | Tells a child that the parent already holds the install lock, so it does not deadlock. |
516
+ | `CODE_GRAPH_AUTO_UPDATE_SILENT` | session-init | Runs the update check without console output. |
517
+ | `CODE_GRAPH_NO_ANSWER_IN_DENY` | lifecycle | Keeps `cg-answer` out of the deny path. |
518
+ | `CODE_GRAPH_FORCE_PLUGIN_MCP` | launcher | Serve MCP even from a context that would otherwise decline. |
519
+ | `CODE_GRAPH_FORCE_STATUSLINE` | `lifecycle install` | Reclaim the statusline slot from another provider. |
520
+ | `CODE_GRAPH_INTERNAL` | this repo's own hooks | Marks a tool call as self-generated so it is excluded from adoption metrics. |
521
+ | `CODE_GRAPH_DOGFOOD` | dev sessions | Tags MCP usage as dev self-test traffic. |
522
+ | `CODE_GRAPH_DEV` | dev checkouts | Dev mode: changes binary resolution and disables auto-update. |
523
+ | `CODE_GRAPH_EMIT_CONFIDENCE` | debugging | Emit per-result confidence from semantic search. |
524
+ | `CODE_GRAPH_BIN` | `scripts/e2e-validate.js` | Binary under test. |
525
+ | `CODE_GRAPH_AUTO_UPDATE_E2E=1` | release smoke test | Opts into the live auto-update E2E, skipped by default. |
526
+
527
+ </details>
528
+
457
529
  ## Build from Source
458
530
 
459
531
  ### Prerequisites
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.122.1",
7
+ "version": "0.124.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -254,8 +254,71 @@ function resolveProxy(targetUrl, env = process.env) {
254
254
  return proxy && proxy.trim() ? proxy.trim() : null;
255
255
  }
256
256
 
257
+ // Overall deadline for one metadata GET, as a multiple of the per-socket
258
+ // inactivity budget. Deliberately looser than `timeoutMs`: that one is an
259
+ // inactivity timer and must stay tight, while this one only has to stop a
260
+ // request that can never finish, so a slow-but-progressing response must not
261
+ // trip it.
262
+ const FETCH_TOTAL_TIMEOUT_FACTOR = 4;
263
+
257
264
  function requestJson(url, timeoutMs = FETCH_TIMEOUT_MS) {
258
265
  return new Promise((resolve, reject) => {
266
+ // Every path below settles through these. `req.setTimeout` is an
267
+ // INACTIVITY timer that lives on the socket, so a connection dropped
268
+ // mid-body emitted no `end`, no request `error` and no timeout — with only
269
+ // `data`/`end` wired on the response the promise stayed pending forever.
270
+ // The per-session detached `check` then accumulated zombie node processes,
271
+ // and a foreground run hung the terminal (audit 2026-08-22 P1-2). The
272
+ // response listeners close that shape directly; the watchdog is the
273
+ // backstop for any other never-settles shape. Both must be single-shot —
274
+ // `error` after a partial body would otherwise re-settle a settled
275
+ // promise — and both must clear the timer so it cannot hold the event
276
+ // loop open past a normal response.
277
+ let settled = false;
278
+ let watchdog = null;
279
+ // Settling the promise is only half of it. An undestroyed request is an
280
+ // ACTIVE HANDLE: the caller's `await` returns while the socket stays open,
281
+ // the event loop has a reason to live, and the detached per-session `check`
282
+ // remains resident — the same zombie process, reached by the other half of
283
+ // the problem. Every handle that can outlive the promise registers here.
284
+ const handles = [];
285
+ const teardown = () => {
286
+ while (handles.length > 0) {
287
+ const h = handles.pop();
288
+ try { h.destroy(); } catch { /* already gone */ }
289
+ }
290
+ };
291
+ const clearWatchdog = () => {
292
+ if (watchdog !== null) {
293
+ clearTimeout(watchdog);
294
+ watchdog = null;
295
+ }
296
+ };
297
+ const settleOk = (value) => {
298
+ if (settled) return;
299
+ settled = true;
300
+ clearWatchdog();
301
+ resolve(value);
302
+ };
303
+ const settleErr = (err) => {
304
+ if (settled) return;
305
+ settled = true;
306
+ clearWatchdog();
307
+ teardown();
308
+ reject(err instanceof Error ? err : new Error(String(err)));
309
+ };
310
+ watchdog = setTimeout(
311
+ () => settleErr(new Error('request watchdog timeout')),
312
+ timeoutMs * FETCH_TOTAL_TIMEOUT_FACTOR,
313
+ );
314
+ // The timer is a handle too. If `https.request` throws synchronously (a
315
+ // malformed URL), the executor's throw rejects the promise without ever
316
+ // reaching `clearWatchdog`, and the timer alone holds the process open for
317
+ // the whole `timeoutMs * FETCH_TOTAL_TIMEOUT_FACTOR` — 12s at the production
318
+ // budget. Unref'd it still fires while a request is in flight (the socket
319
+ // keeps the loop alive), but it can no longer be the ONLY reason to stay up.
320
+ watchdog.unref();
321
+
259
322
  const headers = {
260
323
  'Accept': 'application/vnd.github+json',
261
324
  'User-Agent': 'code-graph-auto-update/1.0',
@@ -264,12 +327,17 @@ function requestJson(url, timeoutMs = FETCH_TIMEOUT_MS) {
264
327
  let body = '';
265
328
  res.setEncoding('utf8');
266
329
  res.on('data', (chunk) => { body += chunk; });
330
+ // A truncated body is not a usable answer — reject rather than hand
331
+ // back half a JSON document. 'aborted' covers the Node versions that
332
+ // do not surface the drop as a response 'error'.
333
+ res.on('aborted', () => settleErr(new Error('response aborted before end')));
334
+ res.on('error', settleErr);
267
335
  res.on('end', () => {
268
336
  if (!res.statusCode) {
269
- reject(new Error('missing status code'));
337
+ settleErr(new Error('missing status code'));
270
338
  return;
271
339
  }
272
- resolve({ statusCode: res.statusCode, body });
340
+ settleOk({ statusCode: res.statusCode, body });
273
341
  });
274
342
  };
275
343
 
@@ -280,7 +348,7 @@ function requestJson(url, timeoutMs = FETCH_TIMEOUT_MS) {
280
348
  // CONNECT to reach parity for users behind a corporate proxy.
281
349
  let pu, target;
282
350
  try { pu = new URL(proxy); target = new URL(url); }
283
- catch { reject(new Error('invalid proxy or target URL')); return; }
351
+ catch { settleErr(new Error('invalid proxy or target URL')); return; }
284
352
  const connectHeaders = {};
285
353
  if (pu.username) {
286
354
  const cred = `${decodeURIComponent(pu.username)}:${decodeURIComponent(pu.password)}`;
@@ -296,25 +364,31 @@ function requestJson(url, timeoutMs = FETCH_TIMEOUT_MS) {
296
364
  connectReq.on('connect', (res, socket) => {
297
365
  if (res.statusCode !== 200) {
298
366
  socket.destroy();
299
- reject(new Error(`proxy CONNECT failed: ${res.statusCode}`));
367
+ settleErr(new Error(`proxy CONNECT failed: ${res.statusCode}`));
300
368
  return;
301
369
  }
370
+ // The tunnelled socket outlives connectReq, so it needs its own entry:
371
+ // destroying the CONNECT request does not close the tunnel under it.
372
+ handles.push(socket);
302
373
  const req = https.request(url, {
303
374
  method: 'GET', headers, socket, agent: false, servername: target.hostname,
304
375
  }, onResponse);
376
+ handles.push(req);
305
377
  req.setTimeout(timeoutMs, () => req.destroy(new Error('request timeout')));
306
- req.on('error', reject);
378
+ req.on('error', settleErr);
307
379
  req.end();
308
380
  });
381
+ handles.push(connectReq);
309
382
  connectReq.setTimeout(timeoutMs, () => connectReq.destroy(new Error('proxy connect timeout')));
310
- connectReq.on('error', reject);
383
+ connectReq.on('error', settleErr);
311
384
  connectReq.end();
312
385
  return;
313
386
  }
314
387
 
315
388
  const req = https.request(url, { method: 'GET', headers }, onResponse);
389
+ handles.push(req);
316
390
  req.setTimeout(timeoutMs, () => req.destroy(new Error('request timeout')));
317
- req.on('error', reject);
391
+ req.on('error', settleErr);
318
392
  req.end();
319
393
  });
320
394
  }
@@ -159,14 +159,82 @@ function isNativeBinary(candidate) {
159
159
  * Permissive on unknown values: missing pkg version or unreadable binary
160
160
  * version → trust cache (don't refuse the only path we know about).
161
161
  */
162
- function isCachedBinaryFresh(cachedPath, pkgVersion) {
162
+ function isCachedBinaryFresh(cachedPath, pkgVersion, knownVersion) {
163
163
  if (!isNativeBinary(cachedPath)) return false;
164
164
  if (!pkgVersion) return true;
165
- const cacheVer = readBinaryVersion(cachedPath);
165
+ // `knownVersion` lets the caller skip the `--version` spawn when the cache
166
+ // entry already recorded it AND the file has not changed since — see
167
+ // `readCacheEntry`. Absent, behaviour is exactly as before.
168
+ const cacheVer = knownVersion || readBinaryVersion(cachedPath);
166
169
  if (!cacheVer) return true;
167
170
  return compareVersions(cacheVer, pkgVersion) >= 0;
168
171
  }
169
172
 
173
+ /// Identity stamp for a binary file: if this is unchanged, the version we
174
+ /// recorded for it is still its version. mtime alone would be fooled by a
175
+ /// same-second replacement, so size rides along.
176
+ function binaryStamp(binPath) {
177
+ try {
178
+ const st = fs.statSync(binPath);
179
+ return `${st.mtimeMs}:${st.size}`;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ /// Read the on-disk cache entry.
186
+ ///
187
+ /// The file used to hold a bare path, so every cache HIT still spawned
188
+ /// `code-graph-mcp --version` to check freshness — four `findBinary()` calls in
189
+ /// one SessionStart process meant four spawns, plus one more from the
190
+ /// consistency check. Cheap warm (2-4ms), but a cold spawn behind Windows AV
191
+ /// has been measured above 2s in this codebase's own notes, and they run
192
+ /// serially inside a hook's time budget (audit 2026-08-22 P2-17).
193
+ ///
194
+ /// The entry now carries the version and a file stamp. A pre-existing bare-path
195
+ /// file still reads (no version → verified once, then rewritten in the new
196
+ /// shape), and an older plugin reading the new file sees a non-path string,
197
+ /// fails `isNativeBinary`, and re-discovers — one extra walk, never a wrong
198
+ /// answer.
199
+ function readCacheEntry() {
200
+ let raw;
201
+ try {
202
+ raw = fs.readFileSync(CACHE_FILE, 'utf8').trim();
203
+ } catch {
204
+ return null;
205
+ }
206
+ if (!raw) return null;
207
+ if (raw[0] === '{') {
208
+ try {
209
+ const parsed = JSON.parse(raw);
210
+ return parsed && typeof parsed.path === 'string' ? parsed : null;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+ return { path: raw };
216
+ }
217
+
218
+ function writeCacheEntry(binPath) {
219
+ try {
220
+ fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
221
+ fs.writeFileSync(
222
+ CACHE_FILE,
223
+ JSON.stringify({
224
+ path: binPath,
225
+ version: readBinaryVersion(binPath) || null,
226
+ stamp: binaryStamp(binPath),
227
+ })
228
+ );
229
+ } catch { /* ok */ }
230
+ }
231
+
232
+ /// Per-process memo. `findBinary()` is called four times in a single
233
+ /// session-init run; only the first should touch the disk. Only a FOUND path is
234
+ /// memoized — a miss must stay retryable, because `launcher-install` installs
235
+ /// the binary and looks again in the same process.
236
+ let memoizedBinary = null;
237
+
170
238
  /**
171
239
  * Locate the code-graph-mcp binary using multiple strategies.
172
240
  * Results are cached to disk so repeated calls (e.g. per-hook) are fast.
@@ -183,21 +251,29 @@ function isCachedBinaryFresh(cachedPath, pkgVersion) {
183
251
  * Returns the absolute path or null if not found.
184
252
  */
185
253
  function findBinary() {
254
+ if (memoizedBinary) return memoizedBinary;
255
+
186
256
  // Try disk cache first (avoids spawning `which` on hot paths)
187
- try {
188
- const cached = fs.readFileSync(CACHE_FILE, 'utf8').trim();
189
- if (isCachedBinaryFresh(cached, getPackageVersion())) return cached;
190
- if (cached) clearCache();
191
- } catch { /* no cache or stale */ }
257
+ const entry = readCacheEntry();
258
+ if (entry) {
259
+ // Trust the recorded version only while the FILE is the one it was recorded
260
+ // for; a replaced binary re-reads it.
261
+ const stamped = entry.stamp && entry.stamp === binaryStamp(entry.path);
262
+ const known = stamped ? entry.version : null;
263
+ if (isCachedBinaryFresh(entry.path, getPackageVersion(), known)) {
264
+ if (!known) writeCacheEntry(entry.path); // upgrade a bare-path / stale-stamp record
265
+ memoizedBinary = entry.path;
266
+ return memoizedBinary;
267
+ }
268
+ clearCache();
269
+ }
192
270
 
193
271
  const result = findBinaryUncached();
194
272
 
195
273
  // Write cache for subsequent calls
196
274
  if (isNativeBinary(result)) {
197
- try {
198
- fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
199
- fs.writeFileSync(CACHE_FILE, result);
200
- } catch { /* ok */ }
275
+ writeCacheEntry(result);
276
+ memoizedBinary = result;
201
277
  }
202
278
 
203
279
  return result;
@@ -414,6 +490,7 @@ function findBinaryUncached() {
414
490
  * findBinary() picks up the new location.
415
491
  */
416
492
  function clearCache() {
493
+ memoizedBinary = null; // the memo is a cache too — an update must invalidate both
417
494
  try { fs.unlinkSync(CACHE_FILE); } catch { /* ok */ }
418
495
  }
419
496
 
@@ -702,12 +702,44 @@ function migrateOldPluginIds(settings) {
702
702
  delete settings.enabledPlugins[oldId];
703
703
  changed = true;
704
704
  }
705
+ }
705
706
 
706
- // Clean old ID from installed_plugins.json
707
- const installed = readJson(installedPluginsPath());
708
- if (installed && installed.plugins && oldId in installed.plugins) {
709
- delete installed.plugins[oldId];
710
- writeJsonAtomic(installedPluginsPath(), installed);
707
+ // Clean old IDs from installed_plugins.json — Claude Code's OWN file.
708
+ //
709
+ // Read once and write once, through the same three-way read the other
710
+ // read-modify-write sites in this file use. The lenient `readJson` was the
711
+ // last caller left here, and it cannot tell "no such file" (nothing to do)
712
+ // from "unreadable" (say so): both came back null and were skipped in
713
+ // silence. The write was also the only unguarded one in install()/update() —
714
+ // with `~/.claude` unwritable (EACCES) and a legacy ID still present, it threw
715
+ // a bare stack out of both of doctor's repair arms, which is a repair tool
716
+ // crashing on the state it exists to repair (audit 2026-08-22 P2-10).
717
+ const installedRead = readJsonResult(installedPluginsPath());
718
+ if (installedRead.corrupt || installedRead.lossy) {
719
+ console.error(
720
+ `[code-graph] cannot read ${installedPluginsPath()} — leaving legacy plugin ` +
721
+ 'IDs in place. Claude Code may still list an old code-graph entry.'
722
+ );
723
+ } else {
724
+ const installed = installedRead.value;
725
+ let ipChanged = false;
726
+ if (installed && installed.plugins) {
727
+ for (const oldId of OLD_PLUGIN_IDS) {
728
+ if (oldId in installed.plugins) {
729
+ delete installed.plugins[oldId];
730
+ ipChanged = true;
731
+ }
732
+ }
733
+ }
734
+ if (ipChanged) {
735
+ try {
736
+ writeJsonAtomic(installedPluginsPath(), installed);
737
+ } catch (err) {
738
+ console.error(
739
+ `[code-graph] cannot write ${installedPluginsPath()} (${err.code || err.name}) — ` +
740
+ 'a legacy code-graph entry remains. Remove it with `/plugin uninstall`.'
741
+ );
742
+ }
711
743
  }
712
744
  }
713
745
 
@@ -1785,6 +1817,7 @@ module.exports = {
1785
1817
  isPluginExplicitlyDisabled, isPluginInactive, isPluginUninstalled, removeCacheResidue,
1786
1818
  cleanupDisabledStatusline, unadoptRegisteredProjects,
1787
1819
  readManifest, readJson, readJsonResult, readSettingsForWrite, writeJsonAtomic,
1820
+ migrateOldPluginIds, // exported so its failure arms are testable (audit 2026-08-22 P2-10)
1788
1821
  readRegistry, readRegistryForWrite, writeRegistry,
1789
1822
  getPluginVersion, cleanupOldCacheVersions,
1790
1823
  removeHooksFromSettings, isOurHookEntry,
@@ -324,6 +324,13 @@ function runMain() {
324
324
  ...(pattern ? { pattern } : {}),
325
325
  fallthrough: answer.status,
326
326
  reason: answer.status,
327
+ // Attribute the skip to the mode that burned the attempt — the LAST mode
328
+ // tried, so a callgraph miss that fell through to grep is charged to grep,
329
+ // matching the fallback order above. Without this the aggregator files
330
+ // every skip under one '?' bucket and you can see THAT injects fail but
331
+ // not WHICH mode is failing (D#147). The Rust side keeps this out of
332
+ // `inject_by_mode` (delivered-only) — see src/cli/usage.rs.
333
+ mode: answeredMode,
327
334
  });
328
335
  return;
329
336
  }
@@ -219,6 +219,60 @@ function renderMarkdown(review) {
219
219
  return lines.join('\n');
220
220
  }
221
221
 
222
+ /// Split a stream of back-to-back JSON documents into their source texts.
223
+ ///
224
+ /// `gh api --paginate` writes ONE document per page, concatenated with no
225
+ /// separator (`[...][...]`), which `JSON.parse` rejects outright. The caller's
226
+ /// catch then fell through to "no existing comment", so every CI run on a PR
227
+ /// past 100 comments POSTed a fresh sticky comment instead of patching the one
228
+ /// already there (audit 2026-08-22 P2-12).
229
+ ///
230
+ /// Splits on TOP-LEVEL boundaries only, tracking string and escape state. The
231
+ /// obvious `][` → `],[` rewrite is wrong: `][` occurs inside ordinary comment
232
+ /// bodies (markdown reference links are literally `[text][ref]`), so that
233
+ /// repair would corrupt the very payload it is trying to read.
234
+ function splitJsonDocuments(text) {
235
+ const out = [];
236
+ let depth = 0;
237
+ let inStr = false;
238
+ let esc = false;
239
+ let start = -1;
240
+ for (let i = 0; i < text.length; i++) {
241
+ const c = text[i];
242
+ if (inStr) {
243
+ if (esc) esc = false;
244
+ else if (c === '\\') esc = true;
245
+ else if (c === '"') inStr = false;
246
+ continue;
247
+ }
248
+ if (c === '"') { inStr = true; continue; }
249
+ if (c === '[' || c === '{') {
250
+ if (depth === 0) start = i;
251
+ depth++;
252
+ } else if (c === ']' || c === '}') {
253
+ depth--;
254
+ if (depth === 0 && start >= 0) {
255
+ out.push(text.slice(start, i + 1));
256
+ start = -1;
257
+ }
258
+ }
259
+ }
260
+ return out;
261
+ }
262
+
263
+ /// Flatten a paginated `gh api` array response into one array of items.
264
+ /// A single page parses as itself; multiple pages concatenate.
265
+ function parseGhPagedArray(stdout) {
266
+ const items = [];
267
+ for (const doc of splitJsonDocuments(String(stdout || ''))) {
268
+ let value;
269
+ try { value = JSON.parse(doc); } catch { continue; }
270
+ if (Array.isArray(value)) items.push(...value);
271
+ else items.push(value);
272
+ }
273
+ return items;
274
+ }
275
+
222
276
  /// Upsert a sticky comment: find an existing comment containing MARKER and PATCH
223
277
  /// it, else POST a new one. Uses `gh api` (preinstalled on GitHub runners).
224
278
  function upsertComment(repo, prNumber, body) {
@@ -230,11 +284,9 @@ function upsertComment(repo, prNumber, body) {
230
284
  const list = gh(['api', '--paginate', `repos/${repo}/issues/${prNumber}/comments`]);
231
285
  let existingId = null;
232
286
  if (list.status === 0) {
233
- try {
234
- const comments = JSON.parse(list.stdout || '[]');
235
- const hit = comments.find((c) => (c.body || '').includes(MARKER));
236
- if (hit) existingId = hit.id;
237
- } catch { /* fall through to create */ }
287
+ const comments = parseGhPagedArray(list.stdout);
288
+ const hit = comments.find((c) => c && (c.body || '').includes(MARKER));
289
+ if (hit) existingId = hit.id;
238
290
  }
239
291
 
240
292
  if (existingId) {
@@ -308,4 +360,7 @@ if (require.main === module) {
308
360
  main(process.argv.slice(2));
309
361
  }
310
362
 
311
- module.exports = { isTestPath, renderMarkdown, computeReview, upsertComment, MARKER };
363
+ module.exports = {
364
+ isTestPath, renderMarkdown, computeReview, upsertComment, MARKER,
365
+ parseGhPagedArray, splitJsonDocuments,
366
+ };
@@ -65,11 +65,26 @@ function run(stdin) {
65
65
  }
66
66
  }
67
67
 
68
+ /// True when the command needs a shell to mean what it says. Claude Code runs
69
+ /// `statusLine.command` through one, so a captured `_previous` legitimately
70
+ /// contains pipelines — and a pipeline cannot run through `execFileSync` under
71
+ /// any splitting, so today it produces ENOENT and a silently missing segment.
72
+ ///
73
+ /// Trade-off, stated because it is a real one: through `sh -c`, the timeout's
74
+ /// SIGKILL reaches the SHELL, not necessarily a grandchild that traps signals
75
+ /// (the hazard the direct-exec path was hardened against). It applies only to
76
+ /// commands that cannot run at all today, so nothing that currently works loses
77
+ /// the guarantee. Windows has no `sh`, so there the command stays on the direct
78
+ /// path and a pipeline keeps failing as before rather than failing differently.
79
+ function needsShell(command) {
80
+ return process.platform !== 'win32' && SHELL_METACHARS.test(command);
81
+ }
82
+
68
83
  function runProvider(command, needsStdin, stdin) {
69
84
  if (!command) return null;
70
85
  try {
71
86
  // Parse command into executable + args
72
- const parts = parseCommand(command);
87
+ const parts = needsShell(command) ? ['/bin/sh', '-c', command] : parseCommand(command);
73
88
  if (!parts) return null;
74
89
 
75
90
  // Claude Code runs statusLine.command through a shell, so a leading `~`
@@ -77,7 +92,9 @@ function runProvider(command, needsStdin, stdin) {
77
92
  // does NOT use a shell, so we must expand `~/` ourselves on every word —
78
93
  // otherwise a `_previous` command captured verbatim throws ENOENT and gets
79
94
  // swallowed below, silently dropping the user's original statusline.
80
- const argv = parts.map(expandTilde);
95
+ // `sh -c` does its own tilde expansion; expanding our own would corrupt the
96
+ // script text (`~` inside a quoted string is not a home directory).
97
+ const argv = needsShell(command) ? parts : parts.map(expandTilde);
81
98
 
82
99
  // Forward Claude Code's authoritative current dir (from the stdin payload) as
83
100
  // a plugin-scoped env var. The code-graph provider gates on it instead of its
@@ -117,17 +134,69 @@ function cwdFromStdin(stdin) {
117
134
  } catch { return null; }
118
135
  }
119
136
 
120
- function parseCommand(cmd) {
121
- // Handle: node "/path/to/script.js"
122
- const match = cmd.match(/^(\S+)\s+"([^"]+)"(.*)$/);
123
- if (match) {
124
- const args = [match[2]];
125
- if (match[3].trim()) args.push(...match[3].trim().split(/\s+/));
126
- return [match[1], ...args];
137
+ /// Shell constructs a direct `execFileSync` cannot honour at all: pipelines,
138
+ /// redirection, sequencing, command substitution, backgrounding.
139
+ ///
140
+ /// Deliberately NOT here: `\\` (every Windows path has them), `~` (expandTilde
141
+ /// handles it), and glob characters (far likelier to be a literal in a path
142
+ /// than an intended glob in a statusline command).
143
+ const SHELL_METACHARS = /[|&;<>()`$]/;
144
+
145
+ /// Split a command line into argv the way a shell would: double quotes, single
146
+ /// quotes, and backslash escapes of space / quote / backslash.
147
+ ///
148
+ /// The old parser was a single regex that only understood ONE double-quoted
149
+ /// word immediately after the executable; everything else went through
150
+ /// `split(/\s+/)`. So a `_previous` command whose path contains a space —
151
+ /// `"C:\Program Files\tools\line.exe"`, `node "~/My Configs/line.js"` — was
152
+ /// torn into fragments, `execFileSync` threw ENOENT, and the catch swallowed
153
+ /// it. The user's original statusline vanished without a word, which is exactly
154
+ /// the case the `_previous` slot exists to protect (audit 2026-08-22 P2-9).
155
+ ///
156
+ /// A backslash escapes only space, quote and backslash. Treating it as a
157
+ /// general escape would eat `C:\Users\me\bin` on Windows, turning a working
158
+ /// path into a broken one — a repair that breaks the platform it did not test.
159
+ ///
160
+ /// Returns null for an unterminated quote: the caller then leaves the provider
161
+ /// alone rather than exec'ing a guess.
162
+ function tokenize(cmd) {
163
+ const argv = [];
164
+ let cur = '';
165
+ let has = false;
166
+ let quote = null; // '"' | "'" | null
167
+ for (let i = 0; i < cmd.length; i++) {
168
+ const c = cmd[i];
169
+ if (quote === "'") {
170
+ // Single quotes are literal, backslash included — POSIX rules.
171
+ if (c === "'") quote = null;
172
+ else { cur += c; has = true; }
173
+ continue;
174
+ }
175
+ if (c === '\\' && (cmd[i + 1] === ' ' || cmd[i + 1] === '"' || cmd[i + 1] === '\\')) {
176
+ cur += cmd[++i];
177
+ has = true;
178
+ continue;
179
+ }
180
+ if (quote === '"') {
181
+ if (c === '"') quote = null;
182
+ else { cur += c; has = true; }
183
+ continue;
184
+ }
185
+ if (c === '"' || c === "'") { quote = c; has = true; continue; }
186
+ if (/\s/.test(c)) {
187
+ if (has) { argv.push(cur); cur = ''; has = false; }
188
+ continue;
189
+ }
190
+ cur += c;
191
+ has = true;
127
192
  }
128
- // Handle: node /path/to/script.js
129
- const parts = cmd.split(/\s+/);
130
- return parts.length > 0 ? parts : null;
193
+ if (quote) return null; // unterminated quote — do not guess
194
+ if (has) argv.push(cur);
195
+ return argv.length > 0 ? argv : null;
196
+ }
197
+
198
+ function parseCommand(cmd) {
199
+ return tokenize(cmd);
131
200
  }
132
201
 
133
202
  // Expand a leading `~` / `~/` to the home directory, mirroring shell tilde
@@ -145,4 +214,4 @@ function codeGraphCommand() {
145
214
  return `node "${path.join(__dirname, 'statusline.js')}"`;
146
215
  }
147
216
 
148
- module.exports = { run, runProvider, parseCommand, expandTilde, cwdFromStdin };
217
+ module.exports = { run, runProvider, parseCommand, tokenize, needsShell, expandTilde, cwdFromStdin };
@@ -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.122.1 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.124.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.122.1",
3
+ "version": "0.124.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.122.1",
39
- "@sdsrs/code-graph-linux-arm64": "0.122.1",
40
- "@sdsrs/code-graph-darwin-x64": "0.122.1",
41
- "@sdsrs/code-graph-darwin-arm64": "0.122.1",
42
- "@sdsrs/code-graph-win32-x64": "0.122.1"
38
+ "@sdsrs/code-graph-linux-x64": "0.124.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.124.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.124.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.124.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.124.0"
43
43
  }
44
44
  }