@sdsrs/code-graph 0.116.0 → 0.118.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 +38 -15
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/adopt.js +210 -50
- package/claude-plugin/scripts/auto-update.js +147 -11
- package/claude-plugin/scripts/doctor.js +158 -12
- package/claude-plugin/scripts/hook-emit.js +80 -11
- package/claude-plugin/scripts/lifecycle.js +193 -28
- package/claude-plugin/scripts/pr-impact-comment.js +33 -1
- package/claude-plugin/scripts/pre-edit-guide.js +16 -8
- package/claude-plugin/scripts/proc-opts.js +15 -0
- package/claude-plugin/scripts/session-init.js +73 -2
- package/claude-plugin/scripts/statusline-chain.js +17 -1
- package/claude-plugin/scripts/statusline-composite.js +3 -0
- package/claude-plugin/scripts/statusline.js +3 -0
- package/claude-plugin/templates/code-graph-snapshot.yml +8 -3
- package/claude-plugin/templates/plugin_code_graph_mcp.md +15 -7
- package/package.json +7 -7
|
@@ -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, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
|
|
10
|
+
const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, readJsonResult, 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');
|
|
@@ -118,14 +118,53 @@ function getPlatformAssetName({ platform = os.platform(), arch = os.arch(), libc
|
|
|
118
118
|
|
|
119
119
|
// ── State Persistence ──────────────────────────────────────
|
|
120
120
|
|
|
121
|
+
// `readJson(STATE_FILE) || {}` was the lossy-read shape the audit swept for on
|
|
122
|
+
// settings.json — still live here, on the one file that holds THREE independent
|
|
123
|
+
// give-up budgets: the update suspension (`updateAttempts` / `suspendedAt`), the
|
|
124
|
+
// binary self-heal budget (`binaryHealAttempts`) and the GitHub rate-limit
|
|
125
|
+
// backoff (`rateLimited` + `lastCheck`). Collapsing "could not read it" into
|
|
126
|
+
// "fresh install" re-armed all three at once, so one corrupt or unreadable cache
|
|
127
|
+
// file turned off every guard that exists to stop an unbounded retry loop —
|
|
128
|
+
// silently, and on every session thereafter (audit 2026-08-16 review Minor tail).
|
|
129
|
+
//
|
|
130
|
+
// Only a genuine ENOENT (or an empty file, which is what a crash mid-write
|
|
131
|
+
// leaves) may be read as a fresh start. Anything else returns the marker below;
|
|
132
|
+
// `checkForUpdate` skips the session and rewrites a clean file, so the next
|
|
133
|
+
// session starts from real state rather than looping here.
|
|
121
134
|
function readState() {
|
|
122
|
-
|
|
135
|
+
const res = readJsonResult(STATE_FILE);
|
|
136
|
+
if (res.value) return res.value;
|
|
137
|
+
if (res.missing) return {};
|
|
138
|
+
return { stateUnreadable: (res.error && res.error.code) || 'invalid-json' };
|
|
123
139
|
}
|
|
124
140
|
|
|
141
|
+
// One stderr line per process when the state file cannot be written. Not a
|
|
142
|
+
// throw: the caller's job (checking for an update) is unaffected, and a hook that
|
|
143
|
+
// dies over its own bookkeeping is worse than one that keeps going. But not
|
|
144
|
+
// silence either — every throttle in this file (update cooldown, GitHub
|
|
145
|
+
// rate-limit backoff, binary self-heal budget) is stored in that one file, so a
|
|
146
|
+
// read-only or full ~/.claude means the updater re-runs its whole check EVERY
|
|
147
|
+
// session, forever, with nothing anywhere saying why (2026-08-16 audit §四).
|
|
148
|
+
// The unlink/cleanup `catch {}`s elsewhere in this file stay silent on purpose:
|
|
149
|
+
// a failed cleanup costs a stale temp file, not a broken invariant.
|
|
150
|
+
let stateWriteWarned = false;
|
|
125
151
|
function saveState(state) {
|
|
126
152
|
try {
|
|
127
|
-
|
|
128
|
-
|
|
153
|
+
// The marker is an in-memory signal, never a persisted field: several call
|
|
154
|
+
// sites do `saveState({ ...readState(), ... })`, and a persisted
|
|
155
|
+
// `stateUnreadable` would park the updater permanently.
|
|
156
|
+
const { stateUnreadable, ...clean } = state || {};
|
|
157
|
+
void stateUnreadable;
|
|
158
|
+
writeJsonAtomic(STATE_FILE, clean);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
if (!stateWriteWarned) {
|
|
161
|
+
stateWriteWarned = true;
|
|
162
|
+
console.error(
|
|
163
|
+
`[code-graph] Could not save update state to ${STATE_FILE} (${e && e.message ? e.message : e}). ` +
|
|
164
|
+
'Update throttling and rate-limit backoff will not persist across sessions.',
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
129
168
|
}
|
|
130
169
|
|
|
131
170
|
// ── Throttle ───────────────────────────────────────────────
|
|
@@ -177,7 +216,11 @@ function shouldCheck(state, { force = false, binaryMissing = false, binaryStale
|
|
|
177
216
|
if (state.rateLimited) return elapsed >= RATE_LIMIT_INTERVAL_MS;
|
|
178
217
|
if (binaryMissing) return true;
|
|
179
218
|
if (!isUpdateSuspended(state)) {
|
|
180
|
-
|
|
219
|
+
// ...and only while that heal still has a retry budget. Once it is spent,
|
|
220
|
+
// the bypass re-fetched the API and re-entered the ~40MB download on every
|
|
221
|
+
// single session (P1-14) — the same reasoning that keeps `binaryStale` out
|
|
222
|
+
// of the suspended branch.
|
|
223
|
+
if (binaryStale && !isBinaryHealExhausted(state)) return true;
|
|
181
224
|
if (force) return elapsed >= SESSION_START_MIN_GAP_MS;
|
|
182
225
|
}
|
|
183
226
|
const interval = state.updateAvailable === false ? UP_TO_DATE_RECHECK_MS : CHECK_INTERVAL_MS;
|
|
@@ -606,8 +649,13 @@ async function downloadAndInstall(latest, {
|
|
|
606
649
|
return { pluginUpdated: false, binaryUpdated: await downloadBin(latest), marketplaceRefreshed: false };
|
|
607
650
|
}
|
|
608
651
|
const tarballPath = path.join(tmpDir, PLUGIN_ASSET_NAME);
|
|
652
|
+
// `-f` like every sibling fetch in this file (the binary at :435 and both
|
|
653
|
+
// sha256 sidecars). This was the one download without it, so a 404/503 wrote
|
|
654
|
+
// GitHub's HTML body here and exited 0; the checksum below still failed
|
|
655
|
+
// closed, but as "sha mismatch" — a wrong diagnosis of a fetch that never
|
|
656
|
+
// succeeded (2026-08-16 audit §四).
|
|
609
657
|
exec('curl', [
|
|
610
|
-
'-
|
|
658
|
+
'-sfL', '-o', tarballPath,
|
|
611
659
|
'-H', 'Accept: application/octet-stream',
|
|
612
660
|
latest.pluginTarballUrl,
|
|
613
661
|
], hidden({ timeout: 30000, stdio: 'pipe' }));
|
|
@@ -726,9 +774,82 @@ async function downloadAndInstall(latest, {
|
|
|
726
774
|
* on the shell-matches-latest path. Extracted + injectable so the wiring itself is
|
|
727
775
|
* regression-tested, not just the predicate. Returns true iff a download promoted.
|
|
728
776
|
*/
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
777
|
+
/**
|
|
778
|
+
* Replace a missing/stale cached binary — BOUNDED, per target version.
|
|
779
|
+
*
|
|
780
|
+
* This had no counter, so a promote that could not land (the Windows case named
|
|
781
|
+
* at promoteVerifiedBinary: the running MCP server holds the .exe, rename →
|
|
782
|
+
* EACCES) re-downloaded ~40MB on every session forever: the caller cleared
|
|
783
|
+
* `updateAttempts`/`suspendedAt` unconditionally right after calling this, and
|
|
784
|
+
* `shouldCheck`'s `binaryStale` arm bypasses the throttle (audit 2026-08-16
|
|
785
|
+
* P1-14; measured 8 calls → 8 downloads).
|
|
786
|
+
*
|
|
787
|
+
* Counted the same way as selfHealGlobalPkgs, including its hard-won rule:
|
|
788
|
+
* success is "the binary is no longer stale", NOT "download() returned true".
|
|
789
|
+
* A download whose promote silently failed used to reset the budget on every
|
|
790
|
+
* run, which is a cap that can never be reached.
|
|
791
|
+
*
|
|
792
|
+
* The counter is deliberately SEPARATE from `updateAttempts`: that one tracks
|
|
793
|
+
* the plugin-shell update, and the branch this runs in resets it because the
|
|
794
|
+
* shell IS current. Sharing it would have made each reset re-arm the other.
|
|
795
|
+
*
|
|
796
|
+
* @returns {{healed: boolean, patch: object}} patch is spread into the state save
|
|
797
|
+
*/
|
|
798
|
+
async function selfHealStaleBinary(latest, {
|
|
799
|
+
state = {}, needsUpdate = cachedBinaryNeedsUpdate, download = downloadBinary,
|
|
800
|
+
// "Present" must mean USABLE, not merely on disk. A truncated, non-executable
|
|
801
|
+
// or wrong-arch cached binary leaves the MCP server exactly as dead as a
|
|
802
|
+
// missing one, and every sibling predicate here already treats unreadable as
|
|
803
|
+
// needing replacement (cachedBinaryNeedsUpdate, cachedBinaryStaleVsState).
|
|
804
|
+
// Keying on existsSync alone put a corrupt binary under the stale budget,
|
|
805
|
+
// which isBinaryHealExhausted only re-arms when a NEW release ships — so five
|
|
806
|
+
// quick failures parked the only recovery path permanently (pre-tag review of
|
|
807
|
+
// the P1-14 fix).
|
|
808
|
+
binaryPresent = () => {
|
|
809
|
+
const p = cachedBinaryPath();
|
|
810
|
+
return fs.existsSync(p) && readBinaryVersion(p) !== null;
|
|
811
|
+
},
|
|
812
|
+
} = {}) {
|
|
813
|
+
if (!latest || !needsUpdate(latest)) {
|
|
814
|
+
// Healthy → clear any leftover counter so the next real staleness starts fresh.
|
|
815
|
+
return {
|
|
816
|
+
healed: false,
|
|
817
|
+
patch: state.binaryHealAttempts ? { binaryHealAttempts: 0, binaryHealVersion: null } : {},
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
// A MISSING binary is exempt from the attempt budget: with no engine at all
|
|
821
|
+
// the MCP server is dead, and `needsUpdate` returns true for "absent" too —
|
|
822
|
+
// letting the stale-heal counter absorb those failures would permanently
|
|
823
|
+
// park the only recovery path after five offline session starts (batch
|
|
824
|
+
// review of the P1-14 fix). The budget exists to stop re-downloading over a
|
|
825
|
+
// binary that RUNS but cannot be replaced (Windows EACCES-on-rename); a
|
|
826
|
+
// missing binary keeps the pre-P1-14 unbounded retry on purpose.
|
|
827
|
+
const missing = !binaryPresent();
|
|
828
|
+
const attempts = state.binaryHealVersion === latest.version ? (state.binaryHealAttempts || 0) : 0;
|
|
829
|
+
if (!missing && attempts >= MAX_UPDATE_ATTEMPTS) return { healed: false, patch: {} };
|
|
830
|
+
await download(latest);
|
|
831
|
+
// Re-read the disk, not the return value (see above).
|
|
832
|
+
const stillStale = needsUpdate(latest);
|
|
833
|
+
return {
|
|
834
|
+
healed: !stillStale,
|
|
835
|
+
patch: {
|
|
836
|
+
binaryHealVersion: latest.version,
|
|
837
|
+
binaryHealAttempts: !stillStale ? 0 : missing ? attempts : attempts + 1,
|
|
838
|
+
},
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* The stale-binary heal has spent its budget on the release we are tracking.
|
|
844
|
+
* Read by shouldCheck: with the heal parked, the `binaryStale` throttle bypass
|
|
845
|
+
* can accomplish nothing and would just re-fetch the API (and, worse, re-enter
|
|
846
|
+
* the download path) every session. Re-arms itself when `latestVersion` moves.
|
|
847
|
+
*/
|
|
848
|
+
function isBinaryHealExhausted(state) {
|
|
849
|
+
return Boolean(state)
|
|
850
|
+
&& Boolean(state.binaryHealVersion)
|
|
851
|
+
&& state.binaryHealVersion === state.latestVersion
|
|
852
|
+
&& (state.binaryHealAttempts || 0) >= MAX_UPDATE_ATTEMPTS;
|
|
732
853
|
}
|
|
733
854
|
|
|
734
855
|
// ── Global npm package self-heal ───────────────────────────
|
|
@@ -889,6 +1010,16 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
|
|
|
889
1010
|
// bypasses auto-update.js, so re-sync state.installedVersion every call.
|
|
890
1011
|
const installedVersion = readManifest().version || '0.0.0';
|
|
891
1012
|
|
|
1013
|
+
// A state we could not read authorises nothing: every throttle, budget and
|
|
1014
|
+
// suspension below is derived from it, so acting on a blank stand-in would
|
|
1015
|
+
// bypass all of them at once. Skip this session and rewrite a clean file —
|
|
1016
|
+
// `lastCheck` stamped now means the ordinary interval applies from here, so
|
|
1017
|
+
// the recovery is bounded rather than an immediate retry.
|
|
1018
|
+
if (state.stateUnreadable) {
|
|
1019
|
+
saveState({ installedVersion, lastCheck: new Date().toISOString() });
|
|
1020
|
+
return null;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
892
1023
|
// Time-based throttle. Two conditions override it: a missing cache binary
|
|
893
1024
|
// (launcher cannot start) and a present-but-stale binary (otherwise it stays
|
|
894
1025
|
// pinned to the old version for up to a full check interval — the binary
|
|
@@ -1052,7 +1183,8 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
|
|
|
1052
1183
|
// OR stale (see selfHealStaleBinary). The shell version (manifest.version)
|
|
1053
1184
|
// can match latest while the cached binary lags — this is exactly the wild
|
|
1054
1185
|
// failure observed in the field (shell at v0.45, binary pinned at v0.16.6).
|
|
1055
|
-
const
|
|
1186
|
+
const binaryHeal = await selfHealStaleBinary(latest, { state });
|
|
1187
|
+
const selfHealedBinary = binaryHeal.healed;
|
|
1056
1188
|
|
|
1057
1189
|
// Same for the GLOBAL npm delivery surface (the `code-graph-mcp` CLI on
|
|
1058
1190
|
// PATH + any explicitly-installed platform package): nothing else ever
|
|
@@ -1078,6 +1210,10 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
|
|
|
1078
1210
|
suspendedAt: null,
|
|
1079
1211
|
rateLimited: false,
|
|
1080
1212
|
binaryUpdated: selfHealedBinary || state.binaryUpdated,
|
|
1213
|
+
// The shell-update counters above reset because the shell IS current.
|
|
1214
|
+
// The BINARY heal keeps its own, un-reset budget — clearing it here is
|
|
1215
|
+
// what made the stale-binary re-download unbounded (P1-14).
|
|
1216
|
+
...binaryHeal.patch,
|
|
1081
1217
|
...globalHeal,
|
|
1082
1218
|
});
|
|
1083
1219
|
return selfHealedBinary
|
|
@@ -1101,7 +1237,7 @@ module.exports = {
|
|
|
1101
1237
|
PLUGIN_ASSET_NAME,
|
|
1102
1238
|
downloadBinary, cachedBinaryPath, cachedBinaryNeedsUpdate, cachedBinaryStaleVsState,
|
|
1103
1239
|
getPlatformAssetName,
|
|
1104
|
-
selfHealStaleBinary,
|
|
1240
|
+
selfHealStaleBinary, isBinaryHealExhausted,
|
|
1105
1241
|
selfHealGlobalPkgs, staleGlobalPkgs, globalPkgVersion, npmInstallGlobal,
|
|
1106
1242
|
shouldHealGlobalsOnThrottle, inactiveNodeGlobalRelics,
|
|
1107
1243
|
downloadAndInstall, refreshMarketplaceClone, marketplaceCloneDir,
|
|
@@ -12,7 +12,7 @@ const {
|
|
|
12
12
|
} = require('./lifecycle');
|
|
13
13
|
const { findBinary, clearCache: clearBinaryCache } = require('./find-binary');
|
|
14
14
|
const { hidden } = require('./proc-opts');
|
|
15
|
-
const { MAX_UPDATE_ATTEMPTS } = require('./auto-update');
|
|
15
|
+
const { MAX_UPDATE_ATTEMPTS, isBinaryHealExhausted } = require('./auto-update');
|
|
16
16
|
|
|
17
17
|
// ── Diagnostics ───────────────────────────────────────────
|
|
18
18
|
|
|
@@ -29,7 +29,7 @@ function classifyEmbeddings(hc) {
|
|
|
29
29
|
const ep = (hc && hc.embedding_progress) || '0/0';
|
|
30
30
|
const [done, total] = ep.split('/').map(Number);
|
|
31
31
|
if (hc && hc.model_available === false) {
|
|
32
|
-
return { name: 'Embeddings', status: 'warn',
|
|
32
|
+
return { name: 'Embeddings', status: 'warn', advisory: true,
|
|
33
33
|
detail: 'binary built without embed-model — semantic search is FTS5-only; reinstall via npm/plugin for the hybrid binary' };
|
|
34
34
|
}
|
|
35
35
|
if (!total) {
|
|
@@ -65,7 +65,7 @@ function classifyEmbeddings(hc) {
|
|
|
65
65
|
? `last model download: ${hc.model_download}`
|
|
66
66
|
: 'model not loaded and NO download has ever been attempted on this machine — restart the MCP server, or set CODE_GRAPH_MODEL_DIR to a manually populated model dir (see README → Offline usage)')
|
|
67
67
|
: `embedding_status=${(hc && hc.embedding_status) || 'unknown'}`;
|
|
68
|
-
return { name: 'Embeddings', status: 'warn',
|
|
68
|
+
return { name: 'Embeddings', status: 'warn', advisory: true,
|
|
69
69
|
detail: `vector INACTIVE — ${total} embeddable nodes, 0 embedded; semantic search is FTS5-only (${why})` };
|
|
70
70
|
}
|
|
71
71
|
if (done < total) {
|
|
@@ -156,7 +156,10 @@ function classifyHealthReport(hc) {
|
|
|
156
156
|
}
|
|
157
157
|
const rows = [];
|
|
158
158
|
if (hc.issue && String(hc.issue).includes('schema')) {
|
|
159
|
-
|
|
159
|
+
// advisory: its "repair" only prints guidance (migration happens when the
|
|
160
|
+
// binary next runs), so it can never be counted fixed and would pin the
|
|
161
|
+
// exit code at 1 forever.
|
|
162
|
+
rows.push({ name: 'Schema', status: 'warn', advisory: true, detail: hc.issue, fixId: 'schema-mismatch' });
|
|
160
163
|
} else {
|
|
161
164
|
rows.push({ name: 'Schema', status: 'ok', detail: `v${hc.schema_version}` });
|
|
162
165
|
}
|
|
@@ -215,6 +218,10 @@ function runHealthCheckCli(binary) {
|
|
|
215
218
|
return execFileSync(binary, ['health-check', '--json'], hidden({
|
|
216
219
|
cwd: process.cwd(),
|
|
217
220
|
timeout: 5000,
|
|
221
|
+
// Same shape statusline.js hardened (audit P1-17): a wedged binary that
|
|
222
|
+
// ignores SIGTERM makes Node's timeout unreachable, so doctor — the tool
|
|
223
|
+
// you run BECAUSE something is wrong — would hang instead of diagnosing.
|
|
224
|
+
killSignal: 'SIGKILL',
|
|
218
225
|
encoding: 'utf8',
|
|
219
226
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
220
227
|
})).trim();
|
|
@@ -402,6 +409,7 @@ function runDiagnostics({ checkOnly = false } = {}) {
|
|
|
402
409
|
results.push({
|
|
403
410
|
name: 'Hooks',
|
|
404
411
|
status: 'warn',
|
|
412
|
+
advisory: true,
|
|
405
413
|
detail:
|
|
406
414
|
`settings.json was unusable and has been REBUILT — your original is at ` +
|
|
407
415
|
`${hookResult.rebuiltFrom}. Merge anything you need back by hand.`,
|
|
@@ -530,6 +538,7 @@ function runDiagnostics({ checkOnly = false } = {}) {
|
|
|
530
538
|
results.push({
|
|
531
539
|
name: 'Global npm relics',
|
|
532
540
|
status: 'warn',
|
|
541
|
+
advisory: true,
|
|
533
542
|
detail: relics.map((r) => `${r.name}@${r.version} (${r.nodeModulesDir.replace(home, '~')})`).join('; ')
|
|
534
543
|
+ ' — installed under a non-active node version; auto-heal cannot reach another node\'s prefix. '
|
|
535
544
|
+ 'Remove each via `nvm use <that node> && npm rm -g <pkg>`, or uninstall the unused node (`nvm uninstall <ver>`).',
|
|
@@ -676,6 +685,52 @@ function binaryVersionResolved({
|
|
|
676
685
|
return Boolean(actual) && actual === pluginVersion();
|
|
677
686
|
}
|
|
678
687
|
|
|
688
|
+
/**
|
|
689
|
+
* Mirror of BOTH `binary-broken` diagnoses: the binary is on disk but does not
|
|
690
|
+
* run (runDiagnostics step 2, `--version` unreadable) or its health-check failed
|
|
691
|
+
* with no recoverable payload (healthRows' last arm). Re-asks the same two
|
|
692
|
+
* questions the diagnosis asked, so "resolved" cannot mean something weaker than
|
|
693
|
+
* "not raised". Cache dropped first: the promote happens in a CHILD process and
|
|
694
|
+
* find-binary memoizes.
|
|
695
|
+
*/
|
|
696
|
+
function binaryBrokenResolved({
|
|
697
|
+
find = findBinary, readVersion = readBinaryVersion, rows = healthRows,
|
|
698
|
+
} = {}) {
|
|
699
|
+
clearBinaryCache();
|
|
700
|
+
const binary = find();
|
|
701
|
+
if (!binary) return false;
|
|
702
|
+
if (!readVersion(binary)) return false;
|
|
703
|
+
try {
|
|
704
|
+
return !rows(binary).some((r) => r.fixId === 'binary-broken');
|
|
705
|
+
} catch { return false; }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Build the binary in the source checkout. Injectable for the same reason
|
|
710
|
+
* rebuildIndexInPlace is: `execSync` is destructured at load, so a test that
|
|
711
|
+
* patches child_process afterwards would silently run a real 10-minute cargo
|
|
712
|
+
* build. Returns true on exit 0; throws what the build threw.
|
|
713
|
+
*/
|
|
714
|
+
function buildBinaryFromSource(cmd) {
|
|
715
|
+
execSync(cmd, hidden({
|
|
716
|
+
cwd: path.resolve(__dirname, '..', '..'),
|
|
717
|
+
stdio: 'inherit',
|
|
718
|
+
timeout: 600000, // embed-model (Candle) builds exceed the old 5min
|
|
719
|
+
}));
|
|
720
|
+
clearBinaryCache();
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Manual recovery for a binary we could not repair — the end of every failed arm. */
|
|
725
|
+
function printBinaryRecovery() {
|
|
726
|
+
console.log(' Reinstall: npm install -g @sdsrs/code-graph');
|
|
727
|
+
console.log(' Or download the release asset for your platform:');
|
|
728
|
+
console.log(' https://github.com/sdsrss/code-graph-mcp/releases');
|
|
729
|
+
if (os.platform() === 'darwin') {
|
|
730
|
+
console.log(' macOS may also have quarantined it: xattr -d com.apple.quarantine <binary>');
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
679
734
|
/** Mirror of the `update-incomplete` diagnosis (runDiagnostics step 5). */
|
|
680
735
|
function updateIncompleteResolved({ readStateFile = readUpdateState } = {}) {
|
|
681
736
|
const state = readStateFile();
|
|
@@ -750,6 +805,20 @@ function autoUpdateNoOpReason(state = readUpdateState(), env = process.env) {
|
|
|
750
805
|
return `auto-update is SUSPENDED after ${state.updateAttempts} failed attempts on v${state.latestVersion} `
|
|
751
806
|
+ '(it retries once a day, and immediately when a newer release is published)';
|
|
752
807
|
}
|
|
808
|
+
// The BINARY self-heal carries its own budget, independent of the update
|
|
809
|
+
// suspension above: the updater can be perfectly healthy while the binary
|
|
810
|
+
// download has given up on this release. That is precisely the state a
|
|
811
|
+
// `binary-broken` / stale-version row comes from, and it was the one parked
|
|
812
|
+
// state doctor could not name — so the user was told to update manually with
|
|
813
|
+
// no hint that the automatic repair had already stopped trying (audit
|
|
814
|
+
// 2026-08-16 review Minor tail). Uses the updater's own predicate rather than
|
|
815
|
+
// a second copy of the condition, which is keyed to `latestVersion` and so
|
|
816
|
+
// re-arms itself when a newer release appears.
|
|
817
|
+
if (isBinaryHealExhausted(state)) {
|
|
818
|
+
return `the binary self-heal has given up on v${state.latestVersion} after `
|
|
819
|
+
+ `${state.binaryHealAttempts} failed download attempts (it re-arms when a newer `
|
|
820
|
+
+ 'release is published)';
|
|
821
|
+
}
|
|
753
822
|
if (state.rateLimited) {
|
|
754
823
|
return 'the updater is in its GitHub rate-limit backoff (up to 1h)';
|
|
755
824
|
}
|
|
@@ -770,6 +839,8 @@ function runRepairs(results, {
|
|
|
770
839
|
updateResolved = updateIncompleteResolved,
|
|
771
840
|
integrityOk = integrityResolved,
|
|
772
841
|
rebuildIndex = rebuildIndexInPlace,
|
|
842
|
+
binaryUsable = binaryBrokenResolved,
|
|
843
|
+
buildBinary = buildBinaryFromSource,
|
|
773
844
|
} = {}) {
|
|
774
845
|
const fixable = results.filter(r => r.fixId);
|
|
775
846
|
if (fixable.length === 0) return 0;
|
|
@@ -851,6 +922,63 @@ function runRepairs(results, {
|
|
|
851
922
|
break;
|
|
852
923
|
}
|
|
853
924
|
|
|
925
|
+
case 'binary-broken': {
|
|
926
|
+
// The binary EXISTS but cannot run: a truncated or corrupted download, a
|
|
927
|
+
// wrong-arch asset, a missing libc, macOS quarantine, or a real crash.
|
|
928
|
+
// This fixId had no arm at all, so doctor printed "1 issue(s) found.
|
|
929
|
+
// Fixing..." and then "0/1 addressed" with nothing between the two
|
|
930
|
+
// (audit 2026-08-16 P1-13).
|
|
931
|
+
if (devMode()) {
|
|
932
|
+
// A source checkout is never repaired by downloading a release asset.
|
|
933
|
+
// Preserve the feature set for the same reason the binary-stale arm
|
|
934
|
+
// does — never silently downgrade a hybrid dev binary to FTS5-only.
|
|
935
|
+
const embed = detectEmbedModel(findBinary());
|
|
936
|
+
const buildCmd = devBuildCommand(embed === true);
|
|
937
|
+
console.log('\n Binary is present but does not run — rebuilding from source...');
|
|
938
|
+
if (embed === null) {
|
|
939
|
+
console.log(' (could not probe the current feature set — building FTS5-only;');
|
|
940
|
+
console.log(' for semantic search rebuild with `cargo build --release --features embed-model`)');
|
|
941
|
+
}
|
|
942
|
+
console.log(` → ${buildCmd}`);
|
|
943
|
+
try {
|
|
944
|
+
if (!buildBinary(buildCmd)) {
|
|
945
|
+
console.log(' ❌ Build failed');
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
948
|
+
} catch {
|
|
949
|
+
console.log(' ❌ Build failed');
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
} else {
|
|
953
|
+
console.log('\n Binary is present but does not run — re-downloading it...');
|
|
954
|
+
try {
|
|
955
|
+
// The updater's stale-binary self-heal treats an unreadable
|
|
956
|
+
// `--version` as "replace it", so this reaches the verified
|
|
957
|
+
// download+promote path even without a newer release.
|
|
958
|
+
runAutoUpdate();
|
|
959
|
+
} catch {
|
|
960
|
+
console.log(' ❌ Update check failed');
|
|
961
|
+
printBinaryRecovery();
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
// Exit 0 proves the command ran, not that the binary works. Ask it.
|
|
966
|
+
if (binaryUsable()) {
|
|
967
|
+
console.log(' ✅ Binary runs again');
|
|
968
|
+
fixed++;
|
|
969
|
+
} else {
|
|
970
|
+
console.log(' ❌ The binary still cannot run');
|
|
971
|
+
if (!devMode()) {
|
|
972
|
+
const why = autoUpdateNoOpReason();
|
|
973
|
+
if (why) console.log(` Why the re-download may have done nothing: ${why}.`);
|
|
974
|
+
printBinaryRecovery();
|
|
975
|
+
} else {
|
|
976
|
+
console.log(' The build completed but the produced binary still fails --version/health-check.');
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
break;
|
|
980
|
+
}
|
|
981
|
+
|
|
854
982
|
case 'binary-not-exec': {
|
|
855
983
|
const binary = findBinary();
|
|
856
984
|
if (binary) {
|
|
@@ -1017,17 +1145,30 @@ function runRepairs(results, {
|
|
|
1017
1145
|
// ── Main ──────────────────────────────────────────────────
|
|
1018
1146
|
|
|
1019
1147
|
// Exit status for a doctor run reflects what remains BROKEN, not what was found:
|
|
1020
|
-
// --check-only → every found issue is unresolved (report cleanliness,
|
|
1021
|
-
//
|
|
1148
|
+
// --check-only → every found BLOCKING issue is unresolved (report cleanliness,
|
|
1149
|
+
// no repair).
|
|
1150
|
+
// repair mode → blocking count minus what runRepairs resolved. A run that fixes
|
|
1022
1151
|
// everything ("N/N addressed") reports 0 so `doctor && …` and
|
|
1023
1152
|
// self-heal automation don't read a successful repair as a
|
|
1024
1153
|
// failure. runRepairs counts an issue fixed only when its repair
|
|
1025
1154
|
// reports success — and the hooks arm re-scans after install() to
|
|
1026
1155
|
// confirm, so a still-broken re-scan is NOT counted (stays
|
|
1027
|
-
// unresolved → exit 1).
|
|
1028
|
-
//
|
|
1156
|
+
// unresolved → exit 1).
|
|
1157
|
+
//
|
|
1158
|
+
// `advisory: true` rows are excluded. They are reported like any other warn but
|
|
1159
|
+
// describe something this tool cannot act on and that is not broken: a binary
|
|
1160
|
+
// deliberately built without embed-model, npm relics under a node version whose
|
|
1161
|
+
// prefix we cannot reach, a settings.json we already rebuilt, a schema note whose
|
|
1162
|
+
// "repair" only prints guidance. Every one of those used to pin the exit code at
|
|
1163
|
+
// 1 for the life of the install, so `doctor && <next step>` could never proceed —
|
|
1164
|
+
// a permanently-red check is one nobody reads (2026-08-16 audit §四).
|
|
1165
|
+
//
|
|
1166
|
+
// Advisory is an EXPLICIT marker, never inferred from a missing fixId: inferring
|
|
1167
|
+
// it would silently exempt the next row somebody forgets to wire to a repair,
|
|
1168
|
+
// which is the opposite failure. `doctor_rows_are_repairable_or_advisory`
|
|
1169
|
+
// (doctor.test.js) holds that line.
|
|
1029
1170
|
function unresolvedCount({ checkOnly, issueCount, fixed }) {
|
|
1030
|
-
return checkOnly ? issueCount : issueCount - fixed;
|
|
1171
|
+
return checkOnly ? issueCount : Math.max(0, issueCount - fixed);
|
|
1031
1172
|
}
|
|
1032
1173
|
|
|
1033
1174
|
function runDoctor(opts = {}) {
|
|
@@ -1035,20 +1176,25 @@ function runDoctor(opts = {}) {
|
|
|
1035
1176
|
console.log(formatReport(results, { checkOnly: opts.checkOnly }));
|
|
1036
1177
|
|
|
1037
1178
|
const issues = results.filter(r => r.status === 'warn' || r.status === 'error');
|
|
1179
|
+
const blocking = issues.filter(r => !r.advisory);
|
|
1038
1180
|
|
|
1039
1181
|
let fixed = 0;
|
|
1040
1182
|
if (issues.length > 0 && !opts.checkOnly) {
|
|
1041
1183
|
fixed = runRepairs(results);
|
|
1042
|
-
console.log(`\n ${fixed}/${
|
|
1184
|
+
console.log(`\n ${fixed}/${blocking.length} issue(s) addressed.`);
|
|
1185
|
+
const advisoryCount = issues.length - blocking.length;
|
|
1186
|
+
if (advisoryCount > 0) {
|
|
1187
|
+
console.log(` ${advisoryCount} advisory note(s) above need no action here.`);
|
|
1188
|
+
}
|
|
1043
1189
|
}
|
|
1044
1190
|
|
|
1045
1191
|
const unresolved = unresolvedCount({
|
|
1046
|
-
checkOnly: opts.checkOnly, issueCount:
|
|
1192
|
+
checkOnly: opts.checkOnly, issueCount: blocking.length, fixed,
|
|
1047
1193
|
});
|
|
1048
1194
|
return { results, issueCount: issues.length, unresolved };
|
|
1049
1195
|
}
|
|
1050
1196
|
|
|
1051
|
-
module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, runDoctorCli, parseDoctorArgs, unresolvedCount, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, classifyIntegrity, classifyHealthReport, parseHealthPayload, integrityResolved, healthRows, detectEmbedModel, devBuildCommand, binaryVersionResolved, updateIncompleteResolved, autoUpdateNoOpReason };
|
|
1197
|
+
module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, runDoctorCli, parseDoctorArgs, unresolvedCount, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, classifyIntegrity, classifyHealthReport, parseHealthPayload, integrityResolved, healthRows, detectEmbedModel, devBuildCommand, binaryVersionResolved, updateIncompleteResolved, binaryBrokenResolved, autoUpdateNoOpReason };
|
|
1052
1198
|
|
|
1053
1199
|
// Shared by BOTH doctor entry points: `node doctor.js …` and `node lifecycle.js
|
|
1054
1200
|
// doctor …`. It exists as one function because the first version of this guard
|
|
@@ -5,22 +5,88 @@
|
|
|
5
5
|
// post-grep-inject) cannot drift apart (feedback_hook_class_bug_sweep — no
|
|
6
6
|
// inline copies of shared logic). DRY mirror of the project-root.js precedent.
|
|
7
7
|
//
|
|
8
|
-
// Why these
|
|
8
|
+
// Why these three shapes:
|
|
9
9
|
// - PreToolUse plain stdout on exit 0 goes to the DEBUG LOG ONLY — it never
|
|
10
10
|
// reaches the model (CC docs, code.claude.com/docs/en/hooks.md, v2026-06).
|
|
11
|
-
// `additionalContext`
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// `additionalContext` is what surfaces the carried text.
|
|
12
|
+
// - `permissionDecision: 'allow'` is NOT a delivery detail. The CC hooks
|
|
13
|
+
// reference defines it as: "skip the interactive permission prompt" (deny
|
|
14
|
+
// rules and connector/`requiresUserInteraction` prompts still apply). On a
|
|
15
|
+
// machine that prompts for a tool, a hook sending `allow` has answered the
|
|
16
|
+
// user's prompt for them. That is defensible for READ-ONLY Read; it is not
|
|
17
|
+
// for Edit, which writes to disk — a delivery hook must never buy context
|
|
18
|
+
// visibility with the user's write consent (audit 2026-08-16 P0-2).
|
|
19
|
+
// Therefore: `emitPreToolAllowContext` is for Read ONLY, and every
|
|
20
|
+
// write-capable tool uses `emitPreToolContext` (no decision at all, which
|
|
21
|
+
// the docs' own PreToolUse example marks as "no decision; normal permission
|
|
22
|
+
// flow applies"). If a future CC drops additionalContext without a decision,
|
|
23
|
+
// the correct outcome is that the Edit impact summary goes quiet — NOT that
|
|
24
|
+
// it re-acquires the elevation.
|
|
15
25
|
// - PostToolUse honors `additionalContext` permission-neutrally (no
|
|
16
26
|
// permissionDecision), so the Bash-side grep answer can be injected without
|
|
17
27
|
// skipping CC's default permission prompt for the underlying tool call.
|
|
18
28
|
|
|
29
|
+
// Ceiling on injected context, applied at the ONE place all three hooks emit
|
|
30
|
+
// through. `cg-answer.js` has capped its own output at 4000 bytes since it was
|
|
31
|
+
// written; the hook payloads it sits alongside had no cap at all, and they are
|
|
32
|
+
// assembled from unbounded lists — pre-edit-guide joins every direct caller's
|
|
33
|
+
// `name (file)` onto a single line, so editing a 200-caller symbol injected a
|
|
34
|
+
// multi-kilobyte wall into the model's context on every Edit (2026-08-16 audit
|
|
35
|
+
// §四). This is the model's context window, not a log: the whole value of an
|
|
36
|
+
// impact summary is that it is small enough to read.
|
|
37
|
+
//
|
|
38
|
+
// Truncation is announced, never silent — a summary that stops mid-list without
|
|
39
|
+
// saying so is worse than one that says it was cut, because the reader cannot
|
|
40
|
+
// tell a short blast radius from a clipped one.
|
|
41
|
+
const MAX_INJECTED_BYTES = 4000;
|
|
42
|
+
|
|
43
|
+
function capContext(text) {
|
|
44
|
+
const s = String(text == null ? '' : text);
|
|
45
|
+
if (Buffer.byteLength(s, 'utf8') <= MAX_INJECTED_BYTES) return s;
|
|
46
|
+
const notice = `\n … truncated at ${MAX_INJECTED_BYTES} bytes — re-run the CLI command above for the full result.\n`;
|
|
47
|
+
const budget = MAX_INJECTED_BYTES - Buffer.byteLength(notice, 'utf8');
|
|
48
|
+
// Slice on a UTF-16 code-unit boundary that fits the byte budget. This keeps
|
|
49
|
+
// the byte cap exact and never splits a 1-3 byte UTF-8 character (ASCII, Latin,
|
|
50
|
+
// CJK — what file paths and symbol names actually contain). It CAN split an
|
|
51
|
+
// astral-plane character (emoji, 2 code units) into a lone surrogate:
|
|
52
|
+
// `JSON.stringify` escapes that, so the envelope stays parseable and the model
|
|
53
|
+
// sees one replacement character at the cut. Saying so rather than claiming
|
|
54
|
+
// "never cut in half", which is what this comment used to claim (v0.118.0
|
|
55
|
+
// pre-tag review verified the emoji case).
|
|
56
|
+
let end = s.length;
|
|
57
|
+
while (end > 0 && Buffer.byteLength(s.slice(0, end), 'utf8') > budget) {
|
|
58
|
+
end -= Math.max(1, Math.ceil((Buffer.byteLength(s.slice(0, end), 'utf8') - budget) / 4));
|
|
59
|
+
}
|
|
60
|
+
// Prefer cutting at the last newline inside the budget, so the truncated text
|
|
61
|
+
// ends on a whole line rather than mid-token.
|
|
62
|
+
const nl = s.lastIndexOf('\n', end);
|
|
63
|
+
if (nl > budget / 2) end = nl;
|
|
64
|
+
return s.slice(0, end) + notice;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* PreToolUse additionalContext envelope with NO permissionDecision (string, no
|
|
69
|
+
* trailing newline). The permission-neutral shape: the tool's normal permission
|
|
70
|
+
* flow is untouched. Use this for every write-capable tool (Edit/Write/…).
|
|
71
|
+
* @param {string} text
|
|
72
|
+
* @returns {string} JSON line
|
|
73
|
+
*/
|
|
74
|
+
function emitPreToolContext(text) {
|
|
75
|
+
return JSON.stringify({
|
|
76
|
+
hookSpecificOutput: {
|
|
77
|
+
hookEventName: 'PreToolUse',
|
|
78
|
+
additionalContext: capContext(text),
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
19
83
|
/**
|
|
20
84
|
* PreToolUse allow + additionalContext envelope (string, no trailing newline).
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
85
|
+
*
|
|
86
|
+
* READ-ONLY TOOLS ONLY (pre-read-guide). `allow` skips the user's interactive
|
|
87
|
+
* permission prompt; for Read that grants nothing the model could not already
|
|
88
|
+
* get, and it is what keeps the fanout hint visible. Do not reuse it for a tool
|
|
89
|
+
* that mutates state — see the note above.
|
|
24
90
|
* @param {string} text
|
|
25
91
|
* @returns {string} JSON line
|
|
26
92
|
*/
|
|
@@ -29,7 +95,7 @@ function emitPreToolAllowContext(text) {
|
|
|
29
95
|
hookSpecificOutput: {
|
|
30
96
|
hookEventName: 'PreToolUse',
|
|
31
97
|
permissionDecision: 'allow',
|
|
32
|
-
additionalContext: text,
|
|
98
|
+
additionalContext: capContext(text),
|
|
33
99
|
},
|
|
34
100
|
});
|
|
35
101
|
}
|
|
@@ -45,9 +111,12 @@ function emitPostToolContext(text) {
|
|
|
45
111
|
return JSON.stringify({
|
|
46
112
|
hookSpecificOutput: {
|
|
47
113
|
hookEventName: 'PostToolUse',
|
|
48
|
-
additionalContext: text,
|
|
114
|
+
additionalContext: capContext(text),
|
|
49
115
|
},
|
|
50
116
|
});
|
|
51
117
|
}
|
|
52
118
|
|
|
53
|
-
module.exports = {
|
|
119
|
+
module.exports = {
|
|
120
|
+
emitPreToolContext, emitPreToolAllowContext, emitPostToolContext,
|
|
121
|
+
capContext, MAX_INJECTED_BYTES,
|
|
122
|
+
};
|