@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 +88 -16
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/auto-update.js +165 -20
- package/claude-plugin/scripts/cg-answer.js +8 -1
- package/claude-plugin/scripts/find-binary.js +88 -11
- package/claude-plugin/scripts/lifecycle.js +39 -5
- package/claude-plugin/scripts/pr-impact-comment.js +61 -6
- package/claude-plugin/scripts/statusline-composite.js +100 -16
- package/claude-plugin/templates/code-graph-snapshot.yml +1 -1
- package/package.json +6 -6
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
91
|
+
### Session token savings
|
|
84
92
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
@@ -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
|
-
|
|
337
|
+
settleErr(new Error('missing status code'));
|
|
270
338
|
return;
|
|
271
339
|
}
|
|
272
|
-
|
|
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 {
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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',
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
866
|
+
}
|
|
725
867
|
|
|
726
|
-
// Update install manifest
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
//
|
|
129
|
-
|
|
130
|
-
return
|
|
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.
|
|
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.
|
|
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.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "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
|
}
|