@sdsrs/code-graph 0.123.0 → 0.125.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.123.0",
7
+ "version": "0.125.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -7,7 +7,7 @@ const http = require('http');
7
7
  const crypto = require('crypto');
8
8
  const path = require('path');
9
9
  const os = require('os');
10
- const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, readJsonResult, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
10
+ const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, readJsonResult, backupCorruptFile, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
11
11
  const { claudeHome } = require('./claude-config');
12
12
  const { clearCache: clearBinaryCache, globalNodeModulesCandidates, nvmNodeModulesDirs, PLATFORM_PKG, detectLibc } = require('./find-binary');
13
13
  const { readBinaryVersion, compareVersions, isDevMode } = require('./version-utils');
@@ -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
  }
@@ -711,25 +785,96 @@ async function downloadAndInstall(latest, {
711
785
  // fragile), pluginDst was never created. Advancing installPath/manifest to it anyway
712
786
  // pointed Claude Code at a nonexistent install dir while state read "up to date".
713
787
  if (pluginUpdated) {
714
- // Update installed_plugins.json to point to new version
788
+ // Update installed_plugins.json to point to new version.
789
+ //
790
+ // Through the same three-way read the lifecycle.js site uses. The lenient
791
+ // `readJson` returns null for ENOENT, EACCES and unparseable alike, and
792
+ // the `if (installed && …)` guard below then skipped the repoint in
793
+ // SILENCE — while the plugin copy had landed and the manifest below is
794
+ // about to be advanced. Claude Code keeps launching the old install dir
795
+ // with state reading "up to date": the split-brain shape the binary-pin
796
+ // incident was made of, and one this file cannot fix by guessing at bytes
797
+ // it could not read. So it says so instead, which keeps `/plugin update`
798
+ // reachable as the manual way out.
715
799
  const installedPath = installedPluginsPath();
716
- try {
717
- const installed = readJson(installedPath);
800
+ const installedRead = readJsonResult(installedPath);
801
+ // Whether the registry entry is STILL pointing at the old version when this
802
+ // block ends. It gates the manifest advance below, and that gate is the
803
+ // whole difference between a report and a fix: `checkForUpdate` reads
804
+ // `readManifest().version` as the authoritative installed version, so
805
+ // advancing it past a repoint that did not happen makes the next session
806
+ // compute "up to date" — the message below prints ONCE, into a SessionStart
807
+ // hook's stderr, and the split-brain then has nothing behind it. Left
808
+ // behind, the ordinary check interval retries the whole install and
809
+ // re-reports, and the repoint lands by itself the moment the file is
810
+ // repaired. `missing` is not blocked: no registry means nothing to repoint.
811
+ let repointBlocked = false;
812
+ if (installedRead.corrupt) {
813
+ // Value unusable — the bytes are not ours to guess at.
814
+ const why = installedRead.error
815
+ ? (installedRead.error.code || installedRead.error.message)
816
+ : 'it does not contain a JSON object';
817
+ console.error(
818
+ `[code-graph] plugin ${latest.version} is installed, but ${installedPath} ` +
819
+ `could not be read (${why}) — its entry for this plugin still points at the ` +
820
+ 'previous version. Run `/plugin update` or repair that file by hand.'
821
+ );
822
+ repointBlocked = true;
823
+ } else {
824
+ let installed = installedRead.value;
825
+ // `lossy` is NOT `corrupt`: the value parsed and is usable, it is the
826
+ // BYTES that will not survive our rewrite (a cp1252 byte inside a path,
827
+ // see readJsonResult). lifecycle.js's readSettingsForWrite route applies
828
+ // here for the same reason — preserve the true bytes, then proceed, since
829
+ // refusing outright strands the install over a byte we can work around.
830
+ // Collapsing this into the corrupt arm also misreported it: a lossy result
831
+ // carries no `error`, so the message called a parseable file unparseable.
832
+ if (installed && installedRead.lossy) {
833
+ const backup = backupCorruptFile(installedPath, installedRead.raw);
834
+ if (backup) {
835
+ console.error(
836
+ `[code-graph] ${installedPath} contains bytes that are not valid UTF-8; ` +
837
+ `repointing it at ${latest.version} will replace them. Saved the original ` +
838
+ `to ${backup} first.`
839
+ );
840
+ } else {
841
+ console.error(
842
+ `[code-graph] plugin ${latest.version} is installed, but ${installedPath} ` +
843
+ 'contains bytes that are not valid UTF-8 and no backup copy could be made — ' +
844
+ 'its entry for this plugin still points at the previous version. Rewriting it ' +
845
+ 'would replace those bytes permanently. Run `/plugin update` after repairing it.'
846
+ );
847
+ installed = null;
848
+ repointBlocked = true;
849
+ }
850
+ }
718
851
  if (installed && installed.plugins && installed.plugins[PLUGIN_ID]) {
719
852
  installed.plugins[PLUGIN_ID][0].installPath = pluginDst;
720
853
  installed.plugins[PLUGIN_ID][0].version = latest.version;
721
854
  installed.plugins[PLUGIN_ID][0].lastUpdated = new Date().toISOString();
722
- writeJsonAtomic(installedPath, installed);
855
+ try {
856
+ writeJsonAtomic(installedPath, installed);
857
+ } catch (err) {
858
+ console.error(
859
+ `[code-graph] plugin ${latest.version} is installed, but ${installedPath} ` +
860
+ `could not be written (${err.code || err.name}) — its entry for this plugin ` +
861
+ 'still points at the previous version. Run `/plugin update`.'
862
+ );
863
+ repointBlocked = true;
864
+ }
723
865
  }
724
- } catch { /* not fatal */ }
866
+ }
725
867
 
726
- // Update install manifest
727
- try {
728
- const manifest = readManifest();
729
- manifest.version = latest.version;
730
- manifest.updatedAt = new Date().toISOString();
731
- writeJsonAtomic(path.join(CACHE_DIR, 'install-manifest.json'), manifest);
732
- } catch { /* not fatal */ }
868
+ // Update install manifest — only when nothing is left pointing at the old
869
+ // version. See `repointBlocked` above: this value IS the update gate.
870
+ if (!repointBlocked) {
871
+ try {
872
+ const manifest = readManifest();
873
+ manifest.version = latest.version;
874
+ manifest.updatedAt = new Date().toISOString();
875
+ writeJsonAtomic(path.join(CACHE_DIR, 'install-manifest.json'), manifest);
876
+ } catch { /* not fatal */ }
877
+ }
733
878
 
734
879
  // Run the NEW lifecycle.js to update settings.json hooks with new paths.
735
880
  // Without this, settings.json hooks still point to the old version directory
@@ -61,7 +61,14 @@ function truncateAtLine(text, maxBytes) {
61
61
  if (lastNl > 0) {
62
62
  return { text: head.slice(0, lastNl), truncated: true };
63
63
  }
64
- return { text: buf.subarray(0, maxBytes).toString('latin1'), truncated: true };
64
+ // Hard cut, when even the first line does not fit. Back the cut off to a
65
+ // UTF-8 character boundary instead of re-decoding the bytes: `latin1` maps
66
+ // each byte to its own character, so a CJK line came back as mojibake rather
67
+ // than as a shortened line, and `utf8` alone would leave a U+FFFD where the
68
+ // cut landed mid-character. A continuation byte is `10xxxxxx`.
69
+ let end = maxBytes;
70
+ while (end > 0 && (buf[end] & 0xc0) === 0x80) end--;
71
+ return { text: buf.subarray(0, end).toString('utf8'), truncated: true };
65
72
  }
66
73
 
67
74
  /**
@@ -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,8 @@ module.exports = {
1785
1817
  isPluginExplicitlyDisabled, isPluginInactive, isPluginUninstalled, removeCacheResidue,
1786
1818
  cleanupDisabledStatusline, unadoptRegisteredProjects,
1787
1819
  readManifest, readJson, readJsonResult, readSettingsForWrite, writeJsonAtomic,
1820
+ backupCorruptFile, // auto-update.js repoints installed_plugins.json and owes the same preserve-then-proceed route
1821
+ migrateOldPluginIds, // exported so its failure arms are testable (audit 2026-08-22 P2-10)
1788
1822
  readRegistry, readRegistryForWrite, writeRegistry,
1789
1823
  getPluginVersion, cleanupOldCacheVersions,
1790
1824
  removeHooksFromSettings, isOurHookEntry,
@@ -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
+ };
@@ -42,7 +42,7 @@ function run(stdin) {
42
42
  const registry = readRegistry();
43
43
  if (registry.length === 0) {
44
44
  // Fallback: no registry, run code-graph only
45
- const cg = runProvider(codeGraphCommand(), false, stdin);
45
+ const cg = runProvider(codeGraphCommand(), false, stdin, 'code-graph');
46
46
  if (cg) process.stdout.write(cg);
47
47
  return;
48
48
  }
@@ -57,7 +57,7 @@ function run(stdin) {
57
57
 
58
58
  const outputs = [];
59
59
  for (const provider of sorted) {
60
- const out = runProvider(provider.command, provider.needsStdin, stdin);
60
+ const out = runProvider(provider.command, provider.needsStdin, stdin, provider.id);
61
61
  if (out) outputs.push(out);
62
62
  }
63
63
  if (outputs.length > 0) {
@@ -65,11 +65,41 @@ function run(stdin) {
65
65
  }
66
66
  }
67
67
 
68
- function runProvider(command, needsStdin, stdin) {
68
+ /// True when the command needs a shell to mean what it says.
69
+ ///
70
+ /// Gated on the entry being `_previous`, and that gate is the whole point.
71
+ /// `_previous` IS the user's `statusLine.command`, which Claude Code runs
72
+ /// through a shell — so a captured pipeline is legitimate there, and a pipeline
73
+ /// cannot run through `execFileSync` under any splitting: it produces ENOENT and
74
+ /// a silently missing segment.
75
+ ///
76
+ /// The other two registry classes were never shell strings. `codeGraphCommand()`
77
+ /// composes `node "<__dirname>/statusline.js"`, and third-party entries arrive
78
+ /// through `statusline-chain.js register`, whose only executor has ever been
79
+ /// `execFileSync`. Handing those to a shell imposes semantics they never had,
80
+ /// and OUR segment is the one that dies: measured, a plugin installed under a
81
+ /// directory named `dev$work` produced `node "…/dev$work/statusline.js"`, which
82
+ /// a shell reads as `…/dev/statusline.js` — segment gone, `catch` swallows it.
83
+ /// Inside double quotes only `$` and a backtick break, which is why this stayed
84
+ /// invisible until someone had one in an install path.
85
+ ///
86
+ /// Trade-off, stated because it is a real one: through `sh -c`, the timeout's
87
+ /// SIGKILL reaches the SHELL, not necessarily a grandchild that traps signals
88
+ /// (the hazard the direct-exec path was hardened against). Confining the shell
89
+ /// to `_previous` also confines that loss to the entry that cannot work without
90
+ /// it. Windows keeps everything on the direct path — note that Claude Code
91
+ /// itself runs statusline commands through Git Bash there, so a `_previous`
92
+ /// pipeline works in Claude Code and still dies here; the fix is half-applied by
93
+ /// platform, which is a gap rather than a regression (it never worked here).
94
+ function needsShell(command, id) {
95
+ return id === '_previous' && process.platform !== 'win32' && SHELL_METACHARS.test(command);
96
+ }
97
+
98
+ function runProvider(command, needsStdin, stdin, id) {
69
99
  if (!command) return null;
70
100
  try {
71
101
  // Parse command into executable + args
72
- const parts = parseCommand(command);
102
+ const parts = needsShell(command, id) ? ['/bin/sh', '-c', command] : parseCommand(command);
73
103
  if (!parts) return null;
74
104
 
75
105
  // Claude Code runs statusLine.command through a shell, so a leading `~`
@@ -77,7 +107,9 @@ function runProvider(command, needsStdin, stdin) {
77
107
  // does NOT use a shell, so we must expand `~/` ourselves on every word —
78
108
  // otherwise a `_previous` command captured verbatim throws ENOENT and gets
79
109
  // swallowed below, silently dropping the user's original statusline.
80
- const argv = parts.map(expandTilde);
110
+ // `sh -c` does its own tilde expansion; expanding our own would corrupt the
111
+ // script text (`~` inside a quoted string is not a home directory).
112
+ const argv = needsShell(command, id) ? parts : parts.map(expandTilde);
81
113
 
82
114
  // Forward Claude Code's authoritative current dir (from the stdin payload) as
83
115
  // a plugin-scoped env var. The code-graph provider gates on it instead of its
@@ -117,17 +149,69 @@ function cwdFromStdin(stdin) {
117
149
  } catch { return null; }
118
150
  }
119
151
 
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];
152
+ /// Shell constructs a direct `execFileSync` cannot honour at all: pipelines,
153
+ /// redirection, sequencing, command substitution, backgrounding.
154
+ ///
155
+ /// Deliberately NOT here: `\\` (every Windows path has them), `~` (expandTilde
156
+ /// handles it), and glob characters (far likelier to be a literal in a path
157
+ /// than an intended glob in a statusline command).
158
+ const SHELL_METACHARS = /[|&;<>()`$]/;
159
+
160
+ /// Split a command line into argv the way a shell would: double quotes, single
161
+ /// quotes, and backslash escapes of space / quote / backslash.
162
+ ///
163
+ /// The old parser was a single regex that only understood ONE double-quoted
164
+ /// word immediately after the executable; everything else went through
165
+ /// `split(/\s+/)`. So a `_previous` command whose path contains a space —
166
+ /// `"C:\Program Files\tools\line.exe"`, `node "~/My Configs/line.js"` — was
167
+ /// torn into fragments, `execFileSync` threw ENOENT, and the catch swallowed
168
+ /// it. The user's original statusline vanished without a word, which is exactly
169
+ /// the case the `_previous` slot exists to protect (audit 2026-08-22 P2-9).
170
+ ///
171
+ /// A backslash escapes only space, quote and backslash. Treating it as a
172
+ /// general escape would eat `C:\Users\me\bin` on Windows, turning a working
173
+ /// path into a broken one — a repair that breaks the platform it did not test.
174
+ ///
175
+ /// Returns null for an unterminated quote: the caller then leaves the provider
176
+ /// alone rather than exec'ing a guess.
177
+ function tokenize(cmd) {
178
+ const argv = [];
179
+ let cur = '';
180
+ let has = false;
181
+ let quote = null; // '"' | "'" | null
182
+ for (let i = 0; i < cmd.length; i++) {
183
+ const c = cmd[i];
184
+ if (quote === "'") {
185
+ // Single quotes are literal, backslash included — POSIX rules.
186
+ if (c === "'") quote = null;
187
+ else { cur += c; has = true; }
188
+ continue;
189
+ }
190
+ if (c === '\\' && (cmd[i + 1] === ' ' || cmd[i + 1] === '"' || cmd[i + 1] === '\\')) {
191
+ cur += cmd[++i];
192
+ has = true;
193
+ continue;
194
+ }
195
+ if (quote === '"') {
196
+ if (c === '"') quote = null;
197
+ else { cur += c; has = true; }
198
+ continue;
199
+ }
200
+ if (c === '"' || c === "'") { quote = c; has = true; continue; }
201
+ if (/\s/.test(c)) {
202
+ if (has) { argv.push(cur); cur = ''; has = false; }
203
+ continue;
204
+ }
205
+ cur += c;
206
+ has = true;
127
207
  }
128
- // Handle: node /path/to/script.js
129
- const parts = cmd.split(/\s+/);
130
- return parts.length > 0 ? parts : null;
208
+ if (quote) return null; // unterminated quote — do not guess
209
+ if (has) argv.push(cur);
210
+ return argv.length > 0 ? argv : null;
211
+ }
212
+
213
+ function parseCommand(cmd) {
214
+ return tokenize(cmd);
131
215
  }
132
216
 
133
217
  // Expand a leading `~` / `~/` to the home directory, mirroring shell tilde
@@ -145,4 +229,4 @@ function codeGraphCommand() {
145
229
  return `node "${path.join(__dirname, 'statusline.js')}"`;
146
230
  }
147
231
 
148
- module.exports = { run, runProvider, parseCommand, expandTilde, cwdFromStdin };
232
+ 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.123.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.125.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.123.0",
3
+ "version": "0.125.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.123.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.123.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.123.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.123.0",
42
- "@sdsrs/code-graph-win32-x64": "0.123.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.125.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.125.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.125.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.125.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.125.0"
43
43
  }
44
44
  }