@sdsrs/code-graph 0.117.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 +19 -0
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/auto-update.js +59 -5
- package/claude-plugin/scripts/doctor.js +48 -11
- package/claude-plugin/scripts/hook-emit.js +45 -4
- package/claude-plugin/scripts/lifecycle.js +26 -2
- package/claude-plugin/scripts/session-init.js +29 -0
- package/claude-plugin/templates/code-graph-snapshot.yml +1 -1
- package/claude-plugin/templates/plugin_code_graph_mcp.md +1 -1
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -109,6 +109,9 @@ Real-world benchmarks comparing code-graph-mcp tools against traditional approac
|
|
|
109
109
|
```
|
|
110
110
|
src/
|
|
111
111
|
├── domain.rs # Shared constants, relation types, env-var config
|
|
112
|
+
├── resolve.rs # Shared symbol resolution + ambiguity verdicts (CLI and MCP)
|
|
113
|
+
├── outcome.rs # Retrieval-adoption metrics from session transcripts
|
|
114
|
+
├── cli/ # Every `code-graph-mcp <cmd>` subcommand (one file per command)
|
|
112
115
|
├── mcp/ # MCP protocol layer (JSON-RPC, tool registry, server)
|
|
113
116
|
│ └── server/ # McpServer with IndexingState + CacheState sub-structs
|
|
114
117
|
├── parser/ # Tree-sitter parsing, relation extraction, LanguageConfig dispatch
|
|
@@ -117,6 +120,7 @@ src/
|
|
|
117
120
|
├── graph/ # Recursive CTE call graph queries
|
|
118
121
|
├── search/ # RRF fusion search combining BM25 + vector
|
|
119
122
|
├── embedding/ # Candle embedding model (optional, masked mean pooling)
|
|
123
|
+
├── snapshot/ # Portable graph snapshots (create / verify / install)
|
|
120
124
|
├── sandbox/ # Context compressor with token estimation
|
|
121
125
|
└── utils/ # Language detection, config
|
|
122
126
|
```
|
|
@@ -301,6 +305,21 @@ All tools are also available as CLI subcommands for shell scripts, hooks, and te
|
|
|
301
305
|
| `incremental-index` | — | Run incremental index update (auto-creates DB if needed) |
|
|
302
306
|
| `health-check` | `get_index_status` | Query index status and freshness |
|
|
303
307
|
| `benchmark` | — | Benchmark index speed, query latency, token savings |
|
|
308
|
+
| `affected [files…]` | — | Changed files → the test files to re-run (`--stdin`, `--depth`) |
|
|
309
|
+
| `tour [path]` | — | Dependency-ordered reading order for a repo or subtree |
|
|
310
|
+
| `centrality` | `project_map` (`include_centrality=true`) | Rank architectural chokepoints (betweenness over the call graph) |
|
|
311
|
+
| `cycles` | — | Detect circular import dependencies (file-level) |
|
|
312
|
+
| `surprising` | — | Surface unexpected cross-module couplings (uncertain edges) |
|
|
313
|
+
| `report` | — | Consolidated code-health report (summary + all analyses) |
|
|
314
|
+
| `stats` | — | Aggregate session metrics from `.code-graph/usage.jsonl` |
|
|
315
|
+
| `outcome` | — | Retrieval adoption from session transcripts (field-MRR; read-only) |
|
|
316
|
+
| `rebuild-index` | `rebuild_index` | Drop and rebuild the index from scratch (requires `--confirm`) |
|
|
317
|
+
| `reindex` | — | Incremental refresh; `--from-snapshot` refetches the published snapshot |
|
|
318
|
+
| `snapshot create\|inspect` | — | Build or inspect a portable graph snapshot |
|
|
319
|
+
| `doctor` | — | Diagnose and repair environment issues |
|
|
320
|
+
| `adopt` | — | Install the steering block into the project `CLAUDE.md` + detail doc |
|
|
321
|
+
| `unadopt` | — | Remove the steering block + detail doc |
|
|
322
|
+
| `serve` | — | Start the MCP JSON-RPC server on stdio (the default with no subcommand) |
|
|
304
323
|
|
|
305
324
|
Common options: `--json` (JSON output), `--compact` (compact output), `--limit N`, `--depth N`, `--file <path>`.
|
|
306
325
|
|
|
@@ -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 ───────────────────────────────────────────────
|
|
@@ -610,8 +649,13 @@ async function downloadAndInstall(latest, {
|
|
|
610
649
|
return { pluginUpdated: false, binaryUpdated: await downloadBin(latest), marketplaceRefreshed: false };
|
|
611
650
|
}
|
|
612
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 §四).
|
|
613
657
|
exec('curl', [
|
|
614
|
-
'-
|
|
658
|
+
'-sfL', '-o', tarballPath,
|
|
615
659
|
'-H', 'Accept: application/octet-stream',
|
|
616
660
|
latest.pluginTarballUrl,
|
|
617
661
|
], hidden({ timeout: 30000, stdio: 'pipe' }));
|
|
@@ -966,6 +1010,16 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
|
|
|
966
1010
|
// bypasses auto-update.js, so re-sync state.installedVersion every call.
|
|
967
1011
|
const installedVersion = readManifest().version || '0.0.0';
|
|
968
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
|
+
|
|
969
1023
|
// Time-based throttle. Two conditions override it: a missing cache binary
|
|
970
1024
|
// (launcher cannot start) and a present-but-stale binary (otherwise it stays
|
|
971
1025
|
// pinned to the old version for up to a full check interval — the binary
|
|
@@ -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
|
}
|
|
@@ -406,6 +409,7 @@ function runDiagnostics({ checkOnly = false } = {}) {
|
|
|
406
409
|
results.push({
|
|
407
410
|
name: 'Hooks',
|
|
408
411
|
status: 'warn',
|
|
412
|
+
advisory: true,
|
|
409
413
|
detail:
|
|
410
414
|
`settings.json was unusable and has been REBUILT — your original is at ` +
|
|
411
415
|
`${hookResult.rebuiltFrom}. Merge anything you need back by hand.`,
|
|
@@ -534,6 +538,7 @@ function runDiagnostics({ checkOnly = false } = {}) {
|
|
|
534
538
|
results.push({
|
|
535
539
|
name: 'Global npm relics',
|
|
536
540
|
status: 'warn',
|
|
541
|
+
advisory: true,
|
|
537
542
|
detail: relics.map((r) => `${r.name}@${r.version} (${r.nodeModulesDir.replace(home, '~')})`).join('; ')
|
|
538
543
|
+ ' — installed under a non-active node version; auto-heal cannot reach another node\'s prefix. '
|
|
539
544
|
+ 'Remove each via `nvm use <that node> && npm rm -g <pkg>`, or uninstall the unused node (`nvm uninstall <ver>`).',
|
|
@@ -800,6 +805,20 @@ function autoUpdateNoOpReason(state = readUpdateState(), env = process.env) {
|
|
|
800
805
|
return `auto-update is SUSPENDED after ${state.updateAttempts} failed attempts on v${state.latestVersion} `
|
|
801
806
|
+ '(it retries once a day, and immediately when a newer release is published)';
|
|
802
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
|
+
}
|
|
803
822
|
if (state.rateLimited) {
|
|
804
823
|
return 'the updater is in its GitHub rate-limit backoff (up to 1h)';
|
|
805
824
|
}
|
|
@@ -1126,17 +1145,30 @@ function runRepairs(results, {
|
|
|
1126
1145
|
// ── Main ──────────────────────────────────────────────────
|
|
1127
1146
|
|
|
1128
1147
|
// Exit status for a doctor run reflects what remains BROKEN, not what was found:
|
|
1129
|
-
// --check-only → every found issue is unresolved (report cleanliness,
|
|
1130
|
-
//
|
|
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
|
|
1131
1151
|
// everything ("N/N addressed") reports 0 so `doctor && …` and
|
|
1132
1152
|
// self-heal automation don't read a successful repair as a
|
|
1133
1153
|
// failure. runRepairs counts an issue fixed only when its repair
|
|
1134
1154
|
// reports success — and the hooks arm re-scans after install() to
|
|
1135
1155
|
// confirm, so a still-broken re-scan is NOT counted (stays
|
|
1136
|
-
// unresolved → exit 1).
|
|
1137
|
-
//
|
|
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.
|
|
1138
1170
|
function unresolvedCount({ checkOnly, issueCount, fixed }) {
|
|
1139
|
-
return checkOnly ? issueCount : issueCount - fixed;
|
|
1171
|
+
return checkOnly ? issueCount : Math.max(0, issueCount - fixed);
|
|
1140
1172
|
}
|
|
1141
1173
|
|
|
1142
1174
|
function runDoctor(opts = {}) {
|
|
@@ -1144,15 +1176,20 @@ function runDoctor(opts = {}) {
|
|
|
1144
1176
|
console.log(formatReport(results, { checkOnly: opts.checkOnly }));
|
|
1145
1177
|
|
|
1146
1178
|
const issues = results.filter(r => r.status === 'warn' || r.status === 'error');
|
|
1179
|
+
const blocking = issues.filter(r => !r.advisory);
|
|
1147
1180
|
|
|
1148
1181
|
let fixed = 0;
|
|
1149
1182
|
if (issues.length > 0 && !opts.checkOnly) {
|
|
1150
1183
|
fixed = runRepairs(results);
|
|
1151
|
-
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
|
+
}
|
|
1152
1189
|
}
|
|
1153
1190
|
|
|
1154
1191
|
const unresolved = unresolvedCount({
|
|
1155
|
-
checkOnly: opts.checkOnly, issueCount:
|
|
1192
|
+
checkOnly: opts.checkOnly, issueCount: blocking.length, fixed,
|
|
1156
1193
|
});
|
|
1157
1194
|
return { results, issueCount: issues.length, unresolved };
|
|
1158
1195
|
}
|
|
@@ -26,6 +26,44 @@
|
|
|
26
26
|
// permissionDecision), so the Bash-side grep answer can be injected without
|
|
27
27
|
// skipping CC's default permission prompt for the underlying tool call.
|
|
28
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
|
+
|
|
29
67
|
/**
|
|
30
68
|
* PreToolUse additionalContext envelope with NO permissionDecision (string, no
|
|
31
69
|
* trailing newline). The permission-neutral shape: the tool's normal permission
|
|
@@ -37,7 +75,7 @@ function emitPreToolContext(text) {
|
|
|
37
75
|
return JSON.stringify({
|
|
38
76
|
hookSpecificOutput: {
|
|
39
77
|
hookEventName: 'PreToolUse',
|
|
40
|
-
additionalContext: text,
|
|
78
|
+
additionalContext: capContext(text),
|
|
41
79
|
},
|
|
42
80
|
});
|
|
43
81
|
}
|
|
@@ -57,7 +95,7 @@ function emitPreToolAllowContext(text) {
|
|
|
57
95
|
hookSpecificOutput: {
|
|
58
96
|
hookEventName: 'PreToolUse',
|
|
59
97
|
permissionDecision: 'allow',
|
|
60
|
-
additionalContext: text,
|
|
98
|
+
additionalContext: capContext(text),
|
|
61
99
|
},
|
|
62
100
|
});
|
|
63
101
|
}
|
|
@@ -73,9 +111,12 @@ function emitPostToolContext(text) {
|
|
|
73
111
|
return JSON.stringify({
|
|
74
112
|
hookSpecificOutput: {
|
|
75
113
|
hookEventName: 'PostToolUse',
|
|
76
|
-
additionalContext: text,
|
|
114
|
+
additionalContext: capContext(text),
|
|
77
115
|
},
|
|
78
116
|
});
|
|
79
117
|
}
|
|
80
118
|
|
|
81
|
-
module.exports = {
|
|
119
|
+
module.exports = {
|
|
120
|
+
emitPreToolContext, emitPreToolAllowContext, emitPostToolContext,
|
|
121
|
+
capContext, MAX_INJECTED_BYTES,
|
|
122
|
+
};
|
|
@@ -382,10 +382,33 @@ function readRegistryForWrite() {
|
|
|
382
382
|
}
|
|
383
383
|
// Self-heal: primary missing or empty (e.g. user cleaned ~/.cache/code-graph/).
|
|
384
384
|
// Durable backup in ~/.claude/ retains `_previous` + third-party providers.
|
|
385
|
+
//
|
|
386
|
+
// Our OWN entry is dropped unless it names the composite this install would
|
|
387
|
+
// register right now. The backup lives in `~/.claude/`, which survives the
|
|
388
|
+
// plugin cache — including an uninstall that refused to rewrite the registry
|
|
389
|
+
// (`detachStatuslineIntegration`'s oneShot refusal leaves it in place by
|
|
390
|
+
// design, because rewriting is how the data got lost the first time). So the
|
|
391
|
+
// NEXT install self-healed the previous install's `code-graph` entry back to
|
|
392
|
+
// life, pointing at a versioned cache directory that no longer exists — a
|
|
393
|
+
// zombie provider in the composite chain (2026-08-16 audit §四). `_previous`
|
|
394
|
+
// and third-party entries are kept: those are the user's data and the reason
|
|
395
|
+
// this backup exists, and nothing else would restore them.
|
|
385
396
|
const backup = readJsonResult(providersBackupFile(), asArray);
|
|
386
397
|
if (backup.value && backup.value.length > 0) {
|
|
387
|
-
|
|
388
|
-
|
|
398
|
+
// `codeGraphStatuslineCommand()`, NOT `compositeCommand()`. The registry row
|
|
399
|
+
// for `code-graph` is written with the former (see the two
|
|
400
|
+
// `registerStatuslineProvider('code-graph', …)` call sites); the composite is
|
|
401
|
+
// only ever the value of `settings.statusLine.command`. Comparing against the
|
|
402
|
+
// composite made this filter drop the row unconditionally — the CURRENT
|
|
403
|
+
// install's own segment vanished after a cache wipe, which is worse than the
|
|
404
|
+
// stale-entry resurrection the filter exists to prevent (found by the
|
|
405
|
+
// v0.118.0 pre-tag review; CI could not see it).
|
|
406
|
+
const live = codeGraphStatuslineCommand();
|
|
407
|
+
const healed = backup.value.filter(p => p && (p.id !== 'code-graph' || p.command === live));
|
|
408
|
+
if (healed.length > 0) {
|
|
409
|
+
try { writeJsonAtomic(REGISTRY_FILE, healed); } catch { /* ok */ }
|
|
410
|
+
return { registry: healed, refuse: false };
|
|
411
|
+
}
|
|
389
412
|
}
|
|
390
413
|
if (backup.corrupt) {
|
|
391
414
|
return { registry: [], refuse: true, why: `${providersBackupFile()} exists but cannot be read as a provider list` };
|
|
@@ -1674,6 +1697,7 @@ module.exports = {
|
|
|
1674
1697
|
removeHooksFromSettings, isOurHookEntry,
|
|
1675
1698
|
registerHooksToSettings, buildSettingsHookEntries, // v0.32.0
|
|
1676
1699
|
surveyHookCoverage, compositeCommand, compositeSlotIsStale, // v0.49.1 — version-aware self-heal
|
|
1700
|
+
codeGraphStatuslineCommand, // exported so a test asserts the row shape the product really writes
|
|
1677
1701
|
hookCmdScript, // the ONE hook-command path parser (session-init reuses it)
|
|
1678
1702
|
cacheDirVersion, // exported for the separator-agnostic test
|
|
1679
1703
|
|
|
@@ -216,6 +216,21 @@ function reportRebuild(r) {
|
|
|
216
216
|
`still need (model / env / permissions / your own hooks) back by hand.\n`
|
|
217
217
|
);
|
|
218
218
|
}
|
|
219
|
+
// install()/update() have reported `manifestUnwritable` since they learned not
|
|
220
|
+
// to throw on it, and NOBODY read the field — so a manifest that could not be
|
|
221
|
+
// written (EACCES after a stray sudo, EROFS, a full disk) produced a silent
|
|
222
|
+
// partial install. It is not cosmetic: `syncLifecycleConfig` keys entirely off
|
|
223
|
+
// `manifest.version`, so an unwritten manifest makes every future SessionStart
|
|
224
|
+
// re-run install() and re-report 'installed', forever, with nothing to show
|
|
225
|
+
// for it (audit 2026-08-16 review Minor tail).
|
|
226
|
+
if (r && r.manifestUnwritable) {
|
|
227
|
+
process.stdout.write(
|
|
228
|
+
`[code-graph] The plugin manifest could not be written (${r.manifestUnwritable}). ` +
|
|
229
|
+
'Hooks are registered but the install will not be remembered, so this runs again ' +
|
|
230
|
+
'every session. Check permissions on ~/.claude/plugins/, then run ' +
|
|
231
|
+
'`code-graph-mcp doctor`.\n'
|
|
232
|
+
);
|
|
233
|
+
}
|
|
219
234
|
return r;
|
|
220
235
|
}
|
|
221
236
|
function installReporting(...args) { return reportRebuild(install(...args)); }
|
|
@@ -666,6 +681,20 @@ function runSessionInit({ source } = {}) {
|
|
|
666
681
|
' Reverse: code-graph-mcp unadopt\n'
|
|
667
682
|
);
|
|
668
683
|
}
|
|
684
|
+
// `adopt()` has returned `registryRecorded` since it stopped throwing on a
|
|
685
|
+
// broken registry, and nothing read it. The consequence is not cosmetic:
|
|
686
|
+
// `uninstall()` walks that registry to strip our managed block from every
|
|
687
|
+
// adopted project's CLAUDE.md, so an unrecorded project keeps the block
|
|
688
|
+
// FOREVER after uninstall, with no plugin code left to remove it — the
|
|
689
|
+
// teardown-asymmetry class this repo has already been bitten by (audit
|
|
690
|
+
// 2026-08-16 review Minor tail).
|
|
691
|
+
if (autoAdopt.result.registryRecorded === false) {
|
|
692
|
+
process.stderr.write(
|
|
693
|
+
'[code-graph] Note: this project could not be recorded in the adopted-projects registry,\n' +
|
|
694
|
+
' so `/plugin uninstall` will NOT strip the block from this CLAUDE.md.\n' +
|
|
695
|
+
' Remove it by hand with `code-graph-mcp unadopt` before uninstalling.\n'
|
|
696
|
+
);
|
|
697
|
+
}
|
|
669
698
|
}
|
|
670
699
|
|
|
671
700
|
// quietHooks: default quiet (project_map injection duplicates MEMORY.md +
|
|
@@ -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.118.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
|
|
@@ -129,7 +129,7 @@ code-graph-mcp overview src/mcp/ # 模块总览
|
|
|
129
129
|
code-graph-mcp callgraph SYMBOL # 调用图
|
|
130
130
|
code-graph-mcp impact SYMBOL # 影响面(--change-type ∈ signature|behavior|remove,默认 behavior)
|
|
131
131
|
code-graph-mcp show SYMBOL # 节点详情
|
|
132
|
-
code-graph-mcp refs SYMBOL --relation calls # --relation ∈ calls|imports|inherits|implements|references|all
|
|
132
|
+
code-graph-mcp refs SYMBOL --relation calls # --relation ∈ calls|imports|inherits|implements|references|exports|routes_to|all
|
|
133
133
|
code-graph-mcp refs SYMBOL --min-confidence extracted # ∈ extracted|inferred|ambiguous;extracted=只看精确边(callgraph/impact/trace 同款)
|
|
134
134
|
code-graph-mcp centrality # 架构咽喉(betweenness 桥节点;补 map 的 caller_count)
|
|
135
135
|
code-graph-mcp cycles # 循环导入依赖(文件级 import 环 / SCC)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.118.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,16 +29,16 @@
|
|
|
29
29
|
],
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "cargo build --release --no-default-features && node scripts/copy-binary.js",
|
|
32
|
-
"prepare": "
|
|
32
|
+
"prepare": "node scripts/setup-git-hooks.js"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
|
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.118.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.118.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.118.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.118.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.118.0"
|
|
43
43
|
}
|
|
44
44
|
}
|