@sdsrs/code-graph 0.117.0 → 0.119.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
@@ -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
 
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.117.0",
7
+ "version": "0.119.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -7,7 +7,7 @@ const http = require('http');
7
7
  const crypto = require('crypto');
8
8
  const path = require('path');
9
9
  const os = require('os');
10
- const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, 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
- return readJson(STATE_FILE) || {};
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
- writeJsonAtomic(STATE_FILE, state);
128
- } catch { /* ok */ }
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
- '-sL', '-o', tarballPath,
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
- rows.push({ name: 'Schema', status: 'warn', detail: hc.issue, fixId: 'schema-mismatch' });
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, no repair).
1130
- // repair mode → issueCount minus what runRepairs resolved. A run that fixes
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). An issue with no working repair
1137
- // (schema-mismatch is advisory only) likewise keeps this > 0.
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}/${issues.length} issue(s) addressed.`);
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: issues.length, fixed,
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 = { emitPreToolContext, emitPreToolAllowContext, emitPostToolContext };
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
- try { writeJsonAtomic(REGISTRY_FILE, backup.value); } catch { /* ok */ }
388
- return { registry: backup.value, refuse: false };
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.117.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.119.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.117.0",
3
+ "version": "0.119.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": "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath scripts/githooks || true"
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.117.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.117.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.117.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.117.0",
42
- "@sdsrs/code-graph-win32-x64": "0.117.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.119.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.119.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.119.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.119.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.119.0"
43
43
  }
44
44
  }