@sdsrs/code-graph 0.147.0 → 0.148.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.147.0",
7
+ "version": "0.148.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -102,20 +102,40 @@ function main({ argv = process.argv, findBinaryRoot = null } = {}) {
102
102
  "deletes ~/.cache/code-graph, and removes this project's CLAUDE.md adoption\n" +
103
103
  "block. --unadopt-all also removes the managed block + detail file from every\n" +
104
104
  "registered adopted project; --purge-global removes the globally-installed\n" +
105
- "@sdsrs npm packages even without the plugin-install marker. Also run\n" +
106
- "`/plugin uninstall code-graph-mcp` in Claude Code to sync its UI.\n");
105
+ "@sdsrs npm packages even without the plugin-install marker.\n\n" +
106
+ "This also removes Claude Code's own registration, so `claude plugin uninstall\n" +
107
+ "code-graph-mcp` afterwards answers \"not found\" — expected. Only a Claude Code\n" +
108
+ "session still listing the plugin needs `/plugin uninstall code-graph-mcp`.\n");
107
109
  process.exit(0);
108
110
  }
109
111
  rejectUnknownFlags(argv, "uninstall", new Set(["--help", "-h", "--unadopt-all", "--purge-global"]));
110
112
  const lifecycle = require("./lifecycle");
111
113
  const { unadopt } = require("./adopt");
114
+ // Unadopt THIS project BEFORE the teardown, not after.
115
+ //
116
+ // `lifecycle.removeCacheResidue` (step 6) holds two rules: PRESERVE a
117
+ // non-empty adopted-projects registry — those projects still carry a managed
118
+ // block someone has to be able to find (JS-17) — and never re-create
119
+ // CACHE_DIR merely to hold `[]`, which its own comment calls "just new
120
+ // residue". Unadopting afterwards produced exactly that `[]`, one step too
121
+ // late for anything to sweep it, so the file step 6 had carefully preserved
122
+ // for us became the residue step 6 exists to prevent. Unadopt first and the
123
+ // registry is already empty when step 6 reads it, so the whole cache
124
+ // directory goes. It is also why that comment describes "the normal
125
+ // SessionStart teardown order, which unadopts first" — this was the one
126
+ // caller that did not.
127
+ //
128
+ // Deliberate knock-on: `r.adoptedProjects` (captured at step 5.5) and the
129
+ // `--unadopt-all` sweep no longer see this project, so it is reported once,
130
+ // on the `this project unadopted=` line, instead of being counted twice.
131
+ // `otherAdopted` below already filtered `process.cwd()` out either way.
132
+ let ua = { ok: false };
133
+ try { ua = unadopt(); } catch { /* best-effort — the teardown below still runs */ }
134
+ const projectUnadopted = !!(ua && (ua.blockPruned || ua.fileRemoved || ua.claudeMdRemoved));
112
135
  const r = lifecycle.uninstall({
113
136
  purgeGlobal: argv.slice(3).includes("--purge-global"),
114
137
  unadoptAll: argv.slice(3).includes("--unadopt-all"),
115
138
  });
116
- let ua = { ok: false };
117
- try { ua = unadopt(); } catch { /* best-effort — settings/cache already cleaned */ }
118
- const projectUnadopted = !!(ua && (ua.blockPruned || ua.fileRemoved || ua.claudeMdRemoved));
119
139
  let out =
120
140
  `Uninstalled code-graph-mcp | settings cleaned=${r.settingsChanged}` +
121
141
  ` | this project unadopted=${projectUnadopted}\n`;
@@ -137,7 +157,14 @@ function main({ argv = process.argv, findBinaryRoot = null } = {}) {
137
157
  " `code-graph-mcp unadopt` + `rm -rf .code-graph`\n" +
138
158
  otherAdopted.map((p) => ` ${p}\n`).join("");
139
159
  }
140
- out += " Also run `/plugin uninstall code-graph-mcp` in Claude Code to sync its UI state.\n";
160
+ // Only when the registration actually went. `uninstall` reports the arm
161
+ // where it could not (unreadable / unwritable installed_plugins.json) via
162
+ // `installedPluginsUnusable`, and in that arm it has ALREADY printed the
163
+ // opposite instruction to stderr — so printing this too told the user both
164
+ // to run `/plugin uninstall` and that its failure was expected, in one run.
165
+ if (!r.installedPluginsUnusable) {
166
+ for (const line of lifecycle.POST_TEARDOWN_UI_NOTE) out += ` ${line}\n`;
167
+ }
141
168
  process.stdout.write(out);
142
169
  process.exit(0);
143
170
  }
@@ -1989,6 +1989,35 @@ function isPluginUninstalled(settings = readJson(settingsPath()) || {}) {
1989
1989
  // here now. So the preservation belongs here, at the wipe, rather than at each
1990
1990
  // caller — the same "fix it at the shared layer, not per surface" the
1991
1991
  // <external> query filter needed.
1992
+ /**
1993
+ * What to say about Claude Code's own plugin registry after a teardown that
1994
+ * SUCCEEDED.
1995
+ *
1996
+ * `uninstall` step 3 drops the plugin from `enabledPlugins` and step 5 from
1997
+ * `installed_plugins.json`, so by the time this prints there is nothing left for
1998
+ * `claude plugin uninstall` to find: in a fresh terminal it exits non-zero with
1999
+ * `Plugin "code-graph-mcp" not found in installed plugins`. Both printers used to
2000
+ * say "also run `/plugin uninstall …`" with no such warning, which hands the user
2001
+ * an error message as the last word on an uninstall that worked.
2002
+ *
2003
+ * Deliberately NOT the same text as the two notes in `uninstall` itself
2004
+ * (`cannot write installed_plugins.json` / `cannot read …`): those fire exactly
2005
+ * when the registration could NOT be removed, so there `/plugin uninstall` is
2006
+ * both necessary and successful. That distinction is ENFORCED, not just
2007
+ * described — both printers gate on `installedPluginsUnusable`. It was only
2008
+ * described for one commit, and in that commit a corrupt installed_plugins.json
2009
+ * got both messages at once (pre-ship review of 7dc7eb4). One string because these two
2010
+ * printers are the pair that drifted before (v0.142.0 shipped a fix for two
2011
+ * printers of the same `unadopt` string, and the release before it fixed the
2012
+ * first of them).
2013
+ */
2014
+ const POST_TEARDOWN_UI_NOTE = [
2015
+ 'Claude Code UI: the registration is already gone, so `claude plugin uninstall',
2016
+ 'code-graph-mcp` in a new terminal answers "not found" — that is the expected',
2017
+ 'result here, not a failure. In a session that still lists the plugin, run',
2018
+ '`/plugin uninstall code-graph-mcp` there to sync its UI.',
2019
+ ];
2020
+
1992
2021
  function removeCacheResidue() {
1993
2022
  // Path comes from adopt.js rather than a second spelling of the basename —
1994
2023
  // a literal here would silently stop matching the day adopt.js renames it,
@@ -2051,6 +2080,7 @@ module.exports = {
2051
2080
  PLUGIN_ROOT, // v0.32.1 — for tests / consumers
2052
2081
  registerStatuslineProvider, unregisterStatuslineProvider, detachStatuslineIntegration,
2053
2082
  installedGlobalPkgs, GLOBAL_INSTALL_MARKER, INSTALL_LOCK_FILE, SHELL_PKG, // uninstall residue
2083
+ POST_TEARDOWN_UI_NOTE, // one wording, two printers
2054
2084
  PLUGIN_ID, OLD_PLUGIN_IDS, MARKETPLACE_NAME, CACHE_DIR, REGISTRY_FILE,
2055
2085
  settingsPath, installedPluginsPath, providersBackupFile, pluginsCacheDir,
2056
2086
  };
@@ -2100,7 +2130,12 @@ if (require.main === module) {
2100
2130
  for (const p of r.adoptedProjects) console.log(` ${p}`);
2101
2131
  console.log(' Clean all at once: re-run with --unadopt-all, or per project `code-graph-mcp unadopt` + `rm -rf .code-graph`.');
2102
2132
  }
2103
- console.log(' Note: also run `/plugin uninstall code-graph-mcp` inside Claude Code to sync its UI state.');
2133
+ // Suppressed in the arm where the registration could NOT be removed — the
2134
+ // stderr note printed there says the opposite, and both at once is a
2135
+ // contradiction. See the constant's own docstring.
2136
+ if (!r.installedPluginsUnusable) {
2137
+ for (const line of POST_TEARDOWN_UI_NOTE) console.log(` ${line}`);
2138
+ }
2104
2139
  } else if (cmd === 'update') {
2105
2140
  const r = update();
2106
2141
  if (r.settingsUnreadable || r.settingsUnwritable) {
@@ -151,6 +151,11 @@ if (!binary) {
151
151
 
152
152
  const stub = serveEmptyMcpStub({
153
153
  upgrade: {
154
+ // What a tool call gets told while this gate holds. Without it the stub
155
+ // answered with the OTHER gate's reason ("upgrades when cwd becomes a
156
+ // project"), which is both wrong here and unactionable — the cwd is fine,
157
+ // the binary is what is missing.
158
+ hint: `binary not installed yet — installing @sdsrs/code-graph@${version} in the background; if tools never appear, run \`code-graph-mcp doctor\``,
154
159
  // Each probe is a full discovery walk (incl. `npm root -g`, up to 2s);
155
160
  // offline the binary never appears, so back the poll off toward 60s.
156
161
  // The install chain's onInstalled nudge below still upgrades instantly.
@@ -205,13 +210,18 @@ try {
205
210
  process.exit(1);
206
211
  }
207
212
 
208
- // Spawn binary with stdio inheritance for MCP JSON-RPC
209
- const child = spawn(binary, ['serve'], hidden({
210
- stdio: 'inherit',
211
- env: process.env,
212
- }));
213
-
214
- child.on('error', (err) => {
213
+ // Both ways a spawn failure can reach us, reported through one function so
214
+ // neither path can drift into being the one with the good message.
215
+ //
216
+ // node hands exactly FIVE errnos to the async 'error' event — EACCES, EAGAIN,
217
+ // EMFILE, ENFILE, ENOENT ("Run-time errors should emit an error, not throw an
218
+ // exception", internal/child_process.js) — and throws every other one
219
+ // synchronously out of `spawn`. So the EACCES branch below always did reach its
220
+ // handler; what had no path at all was the synchronous side, where an ETXTBSY
221
+ // from a binary an auto-update replaced moments ago printed a raw stack instead
222
+ // of any of this. Unlike the stub's, this process exits 1 either way: here the
223
+ // catch buys the message, not the process.
224
+ function reportSpawnFailure(err) {
215
225
  process.stderr.write(`[code-graph] Failed to start: ${err.message}\n`);
216
226
  if (process.platform === 'darwin' && (err.code === 'EACCES' || err.code === 'EPERM')) {
217
227
  process.stderr.write(
@@ -219,12 +229,34 @@ child.on('error', (err) => {
219
229
  ` xattr -d com.apple.quarantine "${binary}"\n`
220
230
  );
221
231
  }
232
+ // ETXTBSY: something still holds a writer fd on the binary — an auto-update
233
+ // that replaced it moments ago. Retryable, unlike the rest of this function.
234
+ if (err.code === 'ETXTBSY') {
235
+ process.stderr.write(
236
+ 'The binary was still being written when we tried to run it.\n' +
237
+ ' Retry: restart Claude Code, or run `code-graph-mcp doctor`\n'
238
+ );
239
+ }
222
240
  // A glibc binary installed on musl (older npm ignores the `libc` field) is present
223
241
  // but execs into a loader error — surface the actionable platform hint.
224
242
  const platformHint = unsupportedPlatformHint();
225
243
  if (platformHint) process.stderr.write(platformHint + '\n');
226
244
  process.exit(1);
227
- });
245
+ }
246
+
247
+ // Spawn binary with stdio inheritance for MCP JSON-RPC
248
+ let child;
249
+ try {
250
+ child = spawn(binary, ['serve'], hidden({
251
+ stdio: 'inherit',
252
+ env: process.env,
253
+ }));
254
+ } catch (err) {
255
+ reportSpawnFailure(err); // exits; the return keeps `child` from being read as undefined
256
+ return;
257
+ }
258
+
259
+ child.on('error', reportSpawnFailure);
228
260
 
229
261
  child.on('exit', (code, signal) => {
230
262
  if (signal) {
@@ -84,10 +84,15 @@ function serveEmptyMcpStub(opts = {}) {
84
84
  else if (m === 'tools/list') result = { tools: [] };
85
85
  else if (m === 'resources/list') result = { resources: [] };
86
86
  else if (m === 'prompts/list') result = { prompts: [] };
87
+ // The reason a caller is talking to a 0-tool stub differs per gate, and this
88
+ // string is the ONLY place it reaches the model — the launcher's stderr goes
89
+ // to the Claude Code log, not into the tool result. One shared wording named
90
+ // the non-project cause for both gates, so the missing-binary gate told a
91
+ // caller sitting in a perfectly good git repo to make it a project.
87
92
  else error = {
88
93
  code: -32601,
89
94
  message: canUpgrade
90
- ? 'method not found (plugin MCP stub; upgrades when cwd becomes a project)'
95
+ ? `method not found (plugin MCP stub; ${upgrade.hint || 'upgrades when cwd becomes a project'})`
91
96
  : 'method not found (plugin MCP is in dedup stub mode)',
92
97
  };
93
98
  writeCc(error ? { jsonrpc: '2.0', id: req.id, error } : { jsonrpc: '2.0', id: req.id, result });
@@ -149,7 +154,38 @@ function serveEmptyMcpStub(opts = {}) {
149
154
  }
150
155
  return;
151
156
  }
152
- const spawned = upgrade.spawnReal();
157
+ // `spawnReal` may THROW rather than return null. node hands exactly FIVE
158
+ // errnos to the async 'error' event that `beginProxy` listens on — EACCES,
159
+ // EAGAIN, EMFILE, ENFILE, ENOENT ("Run-time errors should emit an error, not
160
+ // throw an exception", internal/child_process.js) — and throws every other
161
+ // one synchronously out of child_process.spawn. ETXTBSY is outside that set,
162
+ // and it is the one this path invites: the missing-binary gate nudges
163
+ // attemptUpgrade() from the install chain's onInstalled, so it execs a ~40MB
164
+ // binary at the instant the installer finished writing it, and Linux answers
165
+ // ETXTBSY for as long as any writer fd is still open. Uncaught, the throw
166
+ // escaped this function, escaped the poll-timer callback that called it, and
167
+ // killed the whole MCP server — at the moment the install had SUCCEEDED
168
+ // (QA 2026-09-13: 3/3 against a cold npm cache, 0/3 warm).
169
+ let spawned;
170
+ try {
171
+ spawned = upgrade.spawnReal();
172
+ } catch (err) {
173
+ const code = (err && err.code) || 'unknown';
174
+ process.stderr.write(`[code-graph] plugin MCP upgrade spawn failed (${code}); staying in 0-tool stub, will retry\n`);
175
+ // Clear the offline backoff. It models "the binary is not there yet", and
176
+ // `shouldUpgrade()` just returned TRUE — the binary IS there, this is a
177
+ // transient exec failure. Without this the throw returns into whatever
178
+ // `backoffTicks` a whole offline stretch ratcheted up (capped at 14 ticks
179
+ // = 56 s), and `pollTick` burns that down one tick at a time before it
180
+ // will even probe again: measured 4 s / 16 s / 36 s / 52 s of 0-tool stub
181
+ // after a 10 s / 30 s / 45 s / 90 s install (pre-ship review of 7dc7eb4).
182
+ // The retry is what makes containment a fix rather than a downgrade, so it
183
+ // has to be the next tick.
184
+ backoffTicks = 0;
185
+ backoffNext = 1;
186
+ noteUpgradeFailure(`spawn-threw-${code}`);
187
+ return;
188
+ }
153
189
  if (!spawned) { noteUpgradeFailure('binary-unresolved'); return; }
154
190
  if (poller) { clearIv(poller); poller = null; }
155
191
  child = spawned;
@@ -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.147.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.148.0 code-graph-mcp snapshot create --out snapshot.db
39
39
  zstd -9 snapshot.db -o snapshot.db.zst
40
40
  mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
41
41
  - name: Upload to release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.147.0",
3
+ "version": "0.148.0",
4
4
  "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,10 +35,10 @@
35
35
  "node": ">=16"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@sdsrs/code-graph-linux-x64": "0.147.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.147.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.147.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.147.0",
42
- "@sdsrs/code-graph-win32-x64": "0.147.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.148.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.148.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.148.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.148.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.148.0"
43
43
  }
44
44
  }