@sdsrs/code-graph 0.108.0 → 0.109.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
|
@@ -235,7 +235,14 @@ Remove the `code-graph` entry from your MCP settings file (e.g. `~/.cursor/mcp.j
|
|
|
235
235
|
|
|
236
236
|
### npm (Global)
|
|
237
237
|
|
|
238
|
+
Run the teardown **first**, while the CLI still exists — npm 7 removed the
|
|
239
|
+
`preuninstall`/`postuninstall` lifecycle scripts, so nothing runs on your behalf
|
|
240
|
+
during `npm uninstall`. Doing it in the other order leaves the hook entries in
|
|
241
|
+
`~/.claude/settings.json` and the ~40 MB binary cache behind, with no command
|
|
242
|
+
left on disk to remove them.
|
|
243
|
+
|
|
238
244
|
```bash
|
|
245
|
+
code-graph-mcp uninstall # restore statusline, strip hooks, drop the cache
|
|
239
246
|
npm uninstall -g @sdsrs/code-graph
|
|
240
247
|
```
|
|
241
248
|
|
|
@@ -39,7 +39,13 @@ const BINARY_CACHE_DIR = path.join(CACHE_DIR, 'bin');
|
|
|
39
39
|
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h — steady-state re-check
|
|
40
40
|
const UP_TO_DATE_RECHECK_MS = 30 * 60 * 1000; // 30min — re-verify an "up to date" result (release-race guard)
|
|
41
41
|
const SESSION_START_MIN_GAP_MS = 2 * 60 * 1000; // 2min — anti-hammer floor for forced (session-start) checks
|
|
42
|
-
|
|
42
|
+
// GitHub's unauthenticated REST quota is 60 req/hr and resets HOURLY, so one
|
|
43
|
+
// hour is the whole wait — 24h was written when this constant was unreachable
|
|
44
|
+
// (checkForUpdate erased `rateLimited` on the same call that set it) and became
|
|
45
|
+
// load-bearing the moment that was fixed, having never once been exercised. At
|
|
46
|
+
// 24h a single 403 on a shared/NAT'd IP froze every update check for a day,
|
|
47
|
+
// `--force` included, because the backoff arm sits above the force arm below.
|
|
48
|
+
const RATE_LIMIT_INTERVAL_MS = 60 * 60 * 1000; // 1h — GitHub's reset window
|
|
43
49
|
const FETCH_TIMEOUT_MS = 3000;
|
|
44
50
|
|
|
45
51
|
function isSilentMode(argv = process.argv.slice(2), env = process.env) {
|
|
@@ -92,8 +98,10 @@ function saveState(state) {
|
|
|
92
98
|
|
|
93
99
|
// Whether to hit GitHub now. Keyed to the previous check's outcome, with a force
|
|
94
100
|
// override for high-intent triggers (session start / explicit reload). Ordering:
|
|
95
|
-
// 1. rate-limit backoff (
|
|
96
|
-
//
|
|
101
|
+
// 1. rate-limit backoff (RATE_LIMIT_INTERVAL_MS, 1h = GitHub's own reset
|
|
102
|
+
// window) wins over everything, force included — never push more requests
|
|
103
|
+
// into a GitHub 403. Safe to outrank force only because it is an hour; the
|
|
104
|
+
// 24h it said before made one 403 a silent day-long no-op for `--force`.
|
|
97
105
|
// 2. force → only the short SESSION_START_MIN_GAP_MS floor applies, so opening
|
|
98
106
|
// a new session re-checks immediately while a crash/reopen loop still can't
|
|
99
107
|
// hammer the API.
|
|
@@ -204,23 +212,29 @@ function requestJson(url, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
|
204
212
|
});
|
|
205
213
|
}
|
|
206
214
|
|
|
215
|
+
// Published by release.yml alongside the five platform binaries, each with a
|
|
216
|
+
// `.sha256` sidecar. Distinct from `tarball_url`, which is GitHub's
|
|
217
|
+
// auto-generated source archive and has no checksum published anywhere.
|
|
218
|
+
const PLUGIN_ASSET_NAME = 'claude-plugin.tar.gz';
|
|
219
|
+
|
|
207
220
|
function parseLatestRelease(data, assetName = getPlatformAssetName()) {
|
|
208
221
|
if (!data || typeof data.tag_name !== 'string' || typeof data.tarball_url !== 'string') {
|
|
209
222
|
return null;
|
|
210
223
|
}
|
|
211
224
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const asset = data.assets.find((entry) => entry && entry.name ===
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
225
|
+
const assetUrl = (name) => {
|
|
226
|
+
if (!name || !Array.isArray(data.assets)) return null;
|
|
227
|
+
const asset = data.assets.find((entry) => entry && entry.name === name);
|
|
228
|
+
return asset && typeof asset.browser_download_url === 'string'
|
|
229
|
+
? asset.browser_download_url
|
|
230
|
+
: null;
|
|
231
|
+
};
|
|
219
232
|
|
|
220
233
|
return {
|
|
221
234
|
version: data.tag_name.replace(/^v/, ''),
|
|
222
235
|
tarballUrl: data.tarball_url,
|
|
223
|
-
|
|
236
|
+
pluginTarballUrl: assetUrl(PLUGIN_ASSET_NAME),
|
|
237
|
+
binaryUrl: assetUrl(assetName),
|
|
224
238
|
};
|
|
225
239
|
}
|
|
226
240
|
|
|
@@ -330,19 +344,35 @@ async function downloadBinary(latest) {
|
|
|
330
344
|
latest.binaryUrl,
|
|
331
345
|
], { timeout: 60000, stdio: 'pipe' });
|
|
332
346
|
|
|
333
|
-
//
|
|
334
|
-
// a
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
347
|
+
// Integrity sidecar (<asset>.sha256), fail-CLOSED. `curl -f` turns a 404 into
|
|
348
|
+
// a throw. One retry, because the alternative to a transient network blip is
|
|
349
|
+
// no update this cycle — the installed binary keeps working and the next
|
|
350
|
+
// check tries again, which is a strictly safer failure than exec'ing bytes
|
|
351
|
+
// nothing vouched for.
|
|
352
|
+
//
|
|
353
|
+
// This used to fall through to a TOFU path on a missing sidecar, which made
|
|
354
|
+
// it the one download chain in the repo that was fail-OPEN while
|
|
355
|
+
// `src/snapshot/install.rs` (whose comment reads "this used to warn and fail
|
|
356
|
+
// OPEN") is fail-closed. release.yml publishes a sidecar for every binary of
|
|
357
|
+
// every release — verified back to v0.100.0 — and downloads always target
|
|
358
|
+
// `releases/latest`, so there is no reachable no-sidecar case left to serve.
|
|
359
|
+
// Same-origin, so this defends transit/CDN corruption and truncation, not a
|
|
360
|
+
// release-asset swap; the version-exec check is the backstop there.
|
|
338
361
|
let expectedSha = null;
|
|
339
362
|
const shaTmp = binaryTmp + '.sha256';
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
363
|
+
for (let attempt = 0; attempt < 2 && !expectedSha; attempt++) {
|
|
364
|
+
try {
|
|
365
|
+
execFileSync('curl', ['-sfL', '-o', shaTmp, latest.binaryUrl + '.sha256'],
|
|
366
|
+
{ timeout: 30000, stdio: 'pipe' });
|
|
367
|
+
expectedSha = (fs.readFileSync(shaTmp, 'utf8').trim().split(/\s+/)[0]) || null;
|
|
368
|
+
} catch { /* retry once, then refuse below */ } finally {
|
|
369
|
+
try { if (fs.existsSync(shaTmp)) fs.unlinkSync(shaTmp); } catch { /* ok */ }
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (!expectedSha) {
|
|
373
|
+
console.error(`[code-graph] Refusing to install: no sha256 sidecar for ${latest.binaryUrl} (fetched twice). The current binary is unchanged; the next update check will retry.`);
|
|
374
|
+
try { fs.unlinkSync(binaryTmp); } catch { /* ok */ }
|
|
375
|
+
return false;
|
|
346
376
|
}
|
|
347
377
|
|
|
348
378
|
return promoteVerifiedBinary(binaryTmp, binaryDst, latest.version, expectedSha);
|
|
@@ -370,17 +400,21 @@ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion, expectedSh
|
|
|
370
400
|
// or tampered download is never exec'd. The published <asset>.sha256 sidecar
|
|
371
401
|
// is same-origin, so this defends transit/CDN corruption + truncation, not a
|
|
372
402
|
// full release compromise (an attacker swapping the binary swaps the sidecar
|
|
373
|
-
// too — the version-exec check below is the backstop there).
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
|
|
403
|
+
// too — the version-exec check below is the backstop there).
|
|
404
|
+
//
|
|
405
|
+
// Fail-CLOSED: no expected sha, no install. The previous "warn and proceed"
|
|
406
|
+
// arm made this the only fail-open link in the four download chains, against
|
|
407
|
+
// a fail-closed `src/snapshot/install.rs` — and a warning printed to stderr
|
|
408
|
+
// during a background auto-update is seen by nobody.
|
|
409
|
+
if (!expectedSha256) {
|
|
410
|
+
console.error('[code-graph] No expected sha256 supplied — refusing to install an unverified binary.');
|
|
411
|
+
try { fs.unlinkSync(binaryTmp); } catch { /* ok */ }
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
const actualSha = sha256File(binaryTmp);
|
|
415
|
+
if (actualSha.toLowerCase() !== String(expectedSha256).toLowerCase()) {
|
|
416
|
+
console.error(`[code-graph] Binary checksum mismatch (sha256): expected ${expectedSha256}, got ${actualSha} — refusing to install.`);
|
|
417
|
+
return false;
|
|
384
418
|
}
|
|
385
419
|
|
|
386
420
|
// chmod BEFORE reading the version. readBinaryVersion executes the binary
|
|
@@ -460,16 +494,54 @@ async function downloadAndInstall(latest, {
|
|
|
460
494
|
try {
|
|
461
495
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
462
496
|
|
|
463
|
-
// ── Step 1: Download and install plugin files from
|
|
464
|
-
|
|
497
|
+
// ── Step 1: Download and install plugin files from the release asset ──
|
|
498
|
+
//
|
|
499
|
+
// Fail-CLOSED on integrity, like the binary chain. This step extracts an
|
|
500
|
+
// archive and then COPIES ITS JAVASCRIPT into the plugin cache, where Claude
|
|
501
|
+
// Code runs it as hooks on every tool call — so of the four download chains
|
|
502
|
+
// it is the one where unverified bytes become executed code, and it was the
|
|
503
|
+
// only one with no checksum at all (`tarball_url` is GitHub's generated
|
|
504
|
+
// source archive; nothing publishes a digest for it).
|
|
505
|
+
// `claude-plugin.tar.gz` + `.sha256` are published by release.yml for every
|
|
506
|
+
// release from the one carrying this change onward, and updates always
|
|
507
|
+
// target `releases/latest` — so a missing asset means something is wrong
|
|
508
|
+
// with the release, not that we are talking to an older one. Refusing leaves
|
|
509
|
+
// the user on their current, working plugin version; the binary update below
|
|
510
|
+
// still runs.
|
|
511
|
+
if (!latest.pluginTarballUrl) {
|
|
512
|
+
console.error(`[code-graph] Plugin update skipped: release ${latest.version} publishes no ${PLUGIN_ASSET_NAME} — refusing to install plugin code from an unverifiable source archive.`);
|
|
513
|
+
return { pluginUpdated: false, binaryUpdated: await downloadBin(latest), marketplaceRefreshed: false };
|
|
514
|
+
}
|
|
515
|
+
const tarballPath = path.join(tmpDir, PLUGIN_ASSET_NAME);
|
|
465
516
|
exec('curl', [
|
|
466
517
|
'-sL', '-o', tarballPath,
|
|
467
|
-
'-H', 'Accept: application/
|
|
468
|
-
latest.
|
|
518
|
+
'-H', 'Accept: application/octet-stream',
|
|
519
|
+
latest.pluginTarballUrl,
|
|
469
520
|
], { timeout: 30000, stdio: 'pipe' });
|
|
470
521
|
|
|
522
|
+
// One retry, matching the binary sidecar at :355 — same failure mode, same
|
|
523
|
+
// argument: a transient blip should cost an update cycle, not force a
|
|
524
|
+
// refusal. The first version of this gave the binary two attempts and the
|
|
525
|
+
// plugin one, for no reason anyone could state.
|
|
526
|
+
const shaPath = tarballPath + '.sha256';
|
|
527
|
+
let expectedSha = null;
|
|
528
|
+
for (let attempt = 0; attempt < 2 && !expectedSha; attempt++) {
|
|
529
|
+
try {
|
|
530
|
+
exec('curl', ['-sfL', '-o', shaPath, latest.pluginTarballUrl + '.sha256'],
|
|
531
|
+
{ timeout: 30000, stdio: 'pipe' });
|
|
532
|
+
expectedSha = (fs.readFileSync(shaPath, 'utf8').trim().split(/\s+/)[0]) || null;
|
|
533
|
+
} catch { /* retried once, then refused just below */ }
|
|
534
|
+
}
|
|
535
|
+
const actualSha = fs.existsSync(tarballPath) ? sha256File(tarballPath) : null;
|
|
536
|
+
if (!expectedSha || !actualSha || expectedSha.toLowerCase() !== actualSha.toLowerCase()) {
|
|
537
|
+
console.error(`[code-graph] Plugin tarball integrity check failed (expected ${expectedSha || '<no sidecar>'}, got ${actualSha || '<no download>'}) — refusing to extract.`);
|
|
538
|
+
return { pluginUpdated: false, binaryUpdated: await downloadBin(latest), marketplaceRefreshed: false };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// No --strip-components: the asset archives `claude-plugin/` itself, while
|
|
542
|
+
// GitHub's source tarball wraps everything in `<owner>-<repo>-<sha>/`.
|
|
471
543
|
exec('tar', [
|
|
472
|
-
'xzf', tarballPath, '-C', tmpDir,
|
|
544
|
+
'xzf', tarballPath, '-C', tmpDir,
|
|
473
545
|
], { timeout: 15000, stdio: 'pipe' });
|
|
474
546
|
|
|
475
547
|
const pluginSrc = path.join(tmpDir, 'claude-plugin');
|
|
@@ -666,9 +738,18 @@ async function selfHealGlobalPkgs(latest, state, {
|
|
|
666
738
|
const attempts = state.globalPkgHealVersion === latest.version ? (state.globalPkgHealAttempts || 0) : 0;
|
|
667
739
|
if (attempts >= GLOBAL_PKG_HEAL_MAX_ATTEMPTS) return {};
|
|
668
740
|
const ok = await install(stale.map((s) => `${s.name}@${latest.version}`));
|
|
741
|
+
// Success is "the stale copies are gone", not "npm exited 0". `npm i -g`
|
|
742
|
+
// installs into the prefix the CURRENT node resolves, which is not
|
|
743
|
+
// necessarily where the stale copy lives (nvm with several node versions,
|
|
744
|
+
// an `npm --prefix` in the user's npmrc, a sudo-owned /usr/local). In that
|
|
745
|
+
// shape npm reported success on every run while `staleGlobalPkgs` kept
|
|
746
|
+
// returning the same entry — the counter reset each time, so the retry
|
|
747
|
+
// budget never ran out and the install re-ran forever, once per throttle
|
|
748
|
+
// window. Re-read instead of trusting the exit code.
|
|
749
|
+
const remaining = ok ? readStale(latest.version) : stale;
|
|
669
750
|
return {
|
|
670
751
|
globalPkgHealVersion: latest.version,
|
|
671
|
-
globalPkgHealAttempts:
|
|
752
|
+
globalPkgHealAttempts: remaining.length === 0 ? 0 : attempts + 1,
|
|
672
753
|
};
|
|
673
754
|
}
|
|
674
755
|
|
|
@@ -687,7 +768,11 @@ function shouldHealGlobalsOnThrottle(state, { readStale = staleGlobalPkgs } = {}
|
|
|
687
768
|
return readStale(state.latestVersion).length > 0;
|
|
688
769
|
}
|
|
689
770
|
|
|
690
|
-
|
|
771
|
+
// `requestJsonFn` is a test seam, forwarded to fetchLatestRelease — the same
|
|
772
|
+
// injection point that function already exposes. It exists so the 403 path can
|
|
773
|
+
// be driven without a network: that path is where the rate-limit backoff either
|
|
774
|
+
// engages or is silently erased, and no other observable distinguishes the two.
|
|
775
|
+
async function checkForUpdate({ installMissing = false, force = false, requestJsonFn } = {}) {
|
|
691
776
|
let installLock = null;
|
|
692
777
|
try {
|
|
693
778
|
// Skip in dev mode — unless the launcher explicitly requested a missing-
|
|
@@ -729,9 +814,17 @@ async function checkForUpdate({ installMissing = false, force = false } = {}) {
|
|
|
729
814
|
}
|
|
730
815
|
|
|
731
816
|
// Check GitHub for latest release
|
|
732
|
-
const latest = await fetchLatestRelease();
|
|
817
|
+
const latest = await fetchLatestRelease(requestJsonFn || requestJson);
|
|
733
818
|
if (!latest) {
|
|
734
|
-
|
|
819
|
+
// Re-read, do NOT spread the pre-fetch `state`. On a 403 fetchLatestRelease
|
|
820
|
+
// writes `rateLimited: true` to the state file, and this is the branch it
|
|
821
|
+
// returns null through — spreading the stale snapshot wrote that flag
|
|
822
|
+
// straight back to whatever it was before (normally absent). The
|
|
823
|
+
// RATE_LIMIT_INTERVAL_MS backoff in shouldCheck() therefore never engaged:
|
|
824
|
+
// it read a state where rateLimited had just been erased by the very call
|
|
825
|
+
// that set it, and kept polling GitHub on the ordinary interval while
|
|
826
|
+
// already rate-limited. Dead code since the backoff was written.
|
|
827
|
+
saveState({ ...readState(), installedVersion, lastCheck: new Date().toISOString() });
|
|
735
828
|
return null;
|
|
736
829
|
}
|
|
737
830
|
|
|
@@ -822,6 +915,7 @@ module.exports = {
|
|
|
822
915
|
getExtractedPluginVersion, readBinaryVersion, promoteVerifiedBinary,
|
|
823
916
|
isSilentMode, isInstallMissingMode, isForceMode,
|
|
824
917
|
requestJson, resolveProxy, parseLatestRelease, fetchLatestRelease,
|
|
918
|
+
PLUGIN_ASSET_NAME,
|
|
825
919
|
downloadBinary, cachedBinaryPath, cachedBinaryNeedsUpdate, cachedBinaryStaleVsState,
|
|
826
920
|
getPlatformAssetName,
|
|
827
921
|
selfHealStaleBinary,
|
|
@@ -90,6 +90,21 @@ function readJsonResult(filePath) {
|
|
|
90
90
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
91
91
|
return { value: null, missing: false, corrupt: true, raw: bytes };
|
|
92
92
|
}
|
|
93
|
+
// VALID JSON can still have been decoded lossily. `toString('utf8')`
|
|
94
|
+
// substitutes U+FFFD for every invalid byte, and a latin-1/cp1252 byte
|
|
95
|
+
// inside a path (a non-ASCII username on a legacy code page, a hand-edited
|
|
96
|
+
// file) parses fine — so the object round-trips through JSON.stringify and
|
|
97
|
+
// the atomic write replaces those bytes with U+FFFD PERMANENTLY. The
|
|
98
|
+
// byte-exactness work above only covered the corrupt branch; this branch
|
|
99
|
+
// rewrote the file with no backup and no message at all.
|
|
100
|
+
//
|
|
101
|
+
// Re-encoding the decoded text and comparing to the original bytes detects
|
|
102
|
+
// exactly that: a lossless decode round-trips, a lossy one cannot. Reported
|
|
103
|
+
// as `lossy` rather than `corrupt` because the VALUE is usable — the caller
|
|
104
|
+
// preserves the true bytes first, then proceeds with it.
|
|
105
|
+
if (!Buffer.from(raw, 'utf8').equals(bytes)) {
|
|
106
|
+
return { value, missing: false, corrupt: false, lossy: true, raw: bytes };
|
|
107
|
+
}
|
|
93
108
|
return { value, missing: false, corrupt: false, raw: bytes };
|
|
94
109
|
} catch (err) {
|
|
95
110
|
return { value: null, missing: false, corrupt: true, raw: bytes, error: err };
|
|
@@ -145,6 +160,27 @@ function backupCorruptFile(filePath, raw) {
|
|
|
145
160
|
function readSettingsForWrite() {
|
|
146
161
|
const p = settingsPath();
|
|
147
162
|
const res = readJsonResult(p);
|
|
163
|
+
if (res.value && res.lossy) {
|
|
164
|
+
// Usable JSON whose bytes will not survive our rewrite (see readJsonResult).
|
|
165
|
+
// Preserve the true bytes, then proceed with the parsed value — refusing
|
|
166
|
+
// outright would strand the plugin over a byte we can still work around.
|
|
167
|
+
// If even the copy fails, refuse: silently destroying bytes is worse than
|
|
168
|
+
// skipping the settings work.
|
|
169
|
+
const backup = backupCorruptFile(p, res.raw);
|
|
170
|
+
if (!backup) {
|
|
171
|
+
console.error(
|
|
172
|
+
`[code-graph] ${p} contains bytes that are not valid UTF-8, and no backup copy ` +
|
|
173
|
+
`could be made. Leaving it untouched and skipping settings changes — rewriting it ` +
|
|
174
|
+
`would replace those bytes permanently.`
|
|
175
|
+
);
|
|
176
|
+
return { settings: null, backedUpTo: null };
|
|
177
|
+
}
|
|
178
|
+
console.error(
|
|
179
|
+
`[code-graph] ${p} contains bytes that are not valid UTF-8; rewriting it will replace ` +
|
|
180
|
+
`them. Saved the original to ${backup} first.`
|
|
181
|
+
);
|
|
182
|
+
return { settings: res.value, backedUpTo: backup };
|
|
183
|
+
}
|
|
148
184
|
if (res.value) return { settings: res.value, backedUpTo: null };
|
|
149
185
|
if (res.missing) return { settings: {}, backedUpTo: null };
|
|
150
186
|
const why = res.error ? res.error.message : 'it does not contain a JSON object';
|
|
@@ -642,6 +678,27 @@ function registerHooksToSettings(settings) {
|
|
|
642
678
|
|
|
643
679
|
// Extract the .js script path a hook command invokes — bare (`node "…"`) or
|
|
644
680
|
// existence-guarded (`if [ -f "…" ]; then node "…"; fi`).
|
|
681
|
+
// Is the composite command currently in the statusLine slot one we should
|
|
682
|
+
// replace? Mirrors the staleness rule `surveyHookCoverage` applies to hooks —
|
|
683
|
+
// the two slots are claimed by the same install() and drift the same way.
|
|
684
|
+
//
|
|
685
|
+
// Stale means: unparseable, pointing at a script that no longer exists (a node
|
|
686
|
+
// version was uninstalled, a checkout deleted), or pinned to an OLDER
|
|
687
|
+
// plugin-cache version dir than ours. A live composite from a different
|
|
688
|
+
// delivery surface at the same or a newer version is left alone — that is the
|
|
689
|
+
// whole point. An in-place path (global npm, dev checkout) carries no version
|
|
690
|
+
// dir and can never go version-stale: npm overwrites the same path on upgrade.
|
|
691
|
+
function compositeSlotIsStale(currentCmd) {
|
|
692
|
+
const script = hookCmdScript(currentCmd);
|
|
693
|
+
if (!script) return true;
|
|
694
|
+
if (!fs.existsSync(script)) return true;
|
|
695
|
+
const pv = cacheDirVersion(script);
|
|
696
|
+
if (!pv) return false;
|
|
697
|
+
const { compareVersions } = require('./version-utils');
|
|
698
|
+
const dv = cacheDirVersion(hookCmdScript(compositeCommand())) || getPluginVersion();
|
|
699
|
+
return compareVersions(pv, dv) < 0;
|
|
700
|
+
}
|
|
701
|
+
|
|
645
702
|
function hookCmdScript(cmd) {
|
|
646
703
|
const m = (cmd || '').match(/node "([^"]+\.js)"/) || (cmd || '').match(/"([^"]+\.js)"/);
|
|
647
704
|
return m ? m[1] : null;
|
|
@@ -652,7 +709,16 @@ function hookCmdScript(cmd) {
|
|
|
652
709
|
// dir — npm overwrites the same path on upgrade, so such a path never goes
|
|
653
710
|
// version-stale (only dead-path-stale, caught separately by fs.existsSync).
|
|
654
711
|
function cacheDirVersion(scriptPath) {
|
|
655
|
-
|
|
712
|
+
// Separator-agnostic: the command string we parse is built with path.join,
|
|
713
|
+
// which yields `\` on Windows. A `/`-only pattern returned null there, so
|
|
714
|
+
// `compositeSlotIsStale` answered "not stale" for EVERY plugin-cache path
|
|
715
|
+
// and the statusline slot was never healed on Windows — it self-corrected
|
|
716
|
+
// only once cleanupOldCacheVersions eventually deleted the old version dir.
|
|
717
|
+
// The repo's own `an older plugin-cache version dir must still be healed`
|
|
718
|
+
// test could not catch it: plugin-tests runs on ubuntu only.
|
|
719
|
+
const m = (scriptPath || '')
|
|
720
|
+
.replace(/\\/g, '/')
|
|
721
|
+
.match(/\/code-graph-mcp\/code-graph-mcp\/(\d+\.\d+\.\d+[^/]*)\//);
|
|
656
722
|
return m ? m[1] : null;
|
|
657
723
|
}
|
|
658
724
|
|
|
@@ -881,7 +947,15 @@ function install({ reclaimStatusline = false } = {}) {
|
|
|
881
947
|
const currentCmd = settings.statusLine && settings.statusLine.command;
|
|
882
948
|
if (reclaimStatusline || process.env.CODE_GRAPH_FORCE_STATUSLINE === '1') {
|
|
883
949
|
manifest.config.statuslineDisplaced = 0;
|
|
884
|
-
} else if (
|
|
950
|
+
} else if (!currentCmd) {
|
|
951
|
+
// RE-ARM: the slot is EMPTY. Stand-down exists to stop a tug-of-war with
|
|
952
|
+
// another provider, and there is nobody to fight — whoever displaced us
|
|
953
|
+
// has been uninstalled, or the user cleared the slot. Without this the
|
|
954
|
+
// counter was write-only: once past the threshold the plugin stayed
|
|
955
|
+
// silently statusline-less for the life of the manifest, and the only way
|
|
956
|
+
// back was an env var nobody knows to set.
|
|
957
|
+
manifest.config.statuslineDisplaced = 0;
|
|
958
|
+
} else if (manifest.config.statusLine === true) {
|
|
885
959
|
manifest.config.statuslineDisplaced = (manifest.config.statuslineDisplaced || 0) + 1;
|
|
886
960
|
}
|
|
887
961
|
if ((manifest.config.statuslineDisplaced || 0) > 2) {
|
|
@@ -905,9 +979,16 @@ function install({ reclaimStatusline = false } = {}) {
|
|
|
905
979
|
manifest.config.statusLine = true;
|
|
906
980
|
}
|
|
907
981
|
} else {
|
|
908
|
-
// Composite exists —
|
|
982
|
+
// Composite exists — heal it only when it is actually stale. An exact
|
|
983
|
+
// string mismatch is NOT staleness: two copies of this plugin (plugin cache
|
|
984
|
+
// + global npm, or a dev checkout) derive different absolute paths for the
|
|
985
|
+
// same current composite, and rewriting on mismatch made each install()
|
|
986
|
+
// take the slot back from the other — a 2-cycle that rewrote settings.json
|
|
987
|
+
// on every SessionStart. Identical shape to the hook ping-pong ea0166d
|
|
988
|
+
// fixed; that fix's regression test asserted only `settings.hooks`, so this
|
|
989
|
+
// half of the pair stayed open.
|
|
909
990
|
const cmd = compositeCommand();
|
|
910
|
-
if (settings.statusLine.command !== cmd) {
|
|
991
|
+
if (settings.statusLine.command !== cmd && compositeSlotIsStale(settings.statusLine.command)) {
|
|
911
992
|
settings.statusLine.command = cmd;
|
|
912
993
|
settingsChanged = true;
|
|
913
994
|
}
|
|
@@ -1398,7 +1479,9 @@ module.exports = {
|
|
|
1398
1479
|
getPluginVersion, cleanupOldCacheVersions,
|
|
1399
1480
|
removeHooksFromSettings, isOurHookEntry,
|
|
1400
1481
|
registerHooksToSettings, buildSettingsHookEntries, // v0.32.0
|
|
1401
|
-
surveyHookCoverage, compositeCommand,
|
|
1482
|
+
surveyHookCoverage, compositeCommand, compositeSlotIsStale, // v0.49.1 — version-aware self-heal
|
|
1483
|
+
cacheDirVersion, // exported for the separator-agnostic test
|
|
1484
|
+
|
|
1402
1485
|
verifyHooksFire, defaultHookFireProbes, // v0.67.0 — firing self-test
|
|
1403
1486
|
activeInstallPath, isStaleRelicContext, // v0.49.1 — stale-relic downgrade guard
|
|
1404
1487
|
SETTINGS_HOOK_DESC, OUR_HOOK_SCRIPTS, OUR_DESCRIPTIONS, // v0.32.0 — for tests
|
|
@@ -255,8 +255,18 @@ function syncLifecycleConfig() {
|
|
|
255
255
|
// v0.49.1: also self-heal when the composite path exists but is not the one
|
|
256
256
|
// we'd write now (old plugin-cache version dir that still exists on disk —
|
|
257
257
|
// invisible to the existence check above; same fault class as the binary pin).
|
|
258
|
-
|
|
259
|
-
|
|
258
|
+
//
|
|
259
|
+
// "Not the one we'd write now" is NOT the same as stale, and this is the
|
|
260
|
+
// second gate that had to learn it: two copies of the plugin derive different
|
|
261
|
+
// absolute paths for the same current composite, so a bare string mismatch
|
|
262
|
+
// made each session take the slot back from the other. It also has to agree
|
|
263
|
+
// with `install()`, which now refuses to rewrite a live composite belonging to
|
|
264
|
+
// another delivery surface — otherwise this reports
|
|
265
|
+
// 'self-healed-stale-statusline' every single session while install() quietly
|
|
266
|
+
// changes nothing.
|
|
267
|
+
const { compositeCommand, compositeSlotIsStale } = require('./lifecycle');
|
|
268
|
+
if (settings.statusLine.command !== compositeCommand()
|
|
269
|
+
&& compositeSlotIsStale(settings.statusLine.command)) {
|
|
260
270
|
installReporting();
|
|
261
271
|
return 'self-healed-stale-statusline';
|
|
262
272
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.109.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": {
|
|
@@ -29,17 +29,16 @@
|
|
|
29
29
|
],
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "cargo build --release --no-default-features && node scripts/copy-binary.js",
|
|
32
|
-
"prepare": "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath scripts/githooks || true"
|
|
33
|
-
"preuninstall": "node claude-plugin/scripts/lifecycle.js uninstall || true"
|
|
32
|
+
"prepare": "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath scripts/githooks || true"
|
|
34
33
|
},
|
|
35
34
|
"engines": {
|
|
36
35
|
"node": ">=16"
|
|
37
36
|
},
|
|
38
37
|
"optionalDependencies": {
|
|
39
|
-
"@sdsrs/code-graph-linux-x64": "0.
|
|
40
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
42
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
43
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.109.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.109.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.109.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.109.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.109.0"
|
|
44
43
|
}
|
|
45
44
|
}
|