@sdsrs/code-graph 0.48.0 → 0.50.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.48.0",
7
+ "version": "0.50.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -45,8 +45,8 @@ const INDEX_LINE =
45
45
  '- [code-graph-mcp](plugin_code_graph_mcp.md) ' +
46
46
  '[impact-analysis, callgraph, find-references, module-overview, semantic-search, ast-search, dead-code, find-similar-code, dependency-graph, trace-http-chain] — ' +
47
47
  '改 X 影响面/谁调用 X/X 被谁用/看 X 源码/Y 模块长啥样/概念查询 优先于 Grep;字面匹配走 Grep。' +
48
- '核心 7(get_call_graph/module_overview/semantic_code_search/ast_search/find_references/get_ast_node/project_map)' +
49
- '+ 进阶 5impact_analysis/trace_http_chain/dependency_graph/find_similar_code/find_dead_code),决策表见全文';
48
+ 'Bash 直呼 CLI 最快(零加载):`code-graph-mcp callgraph X / show X / overview <dir> / grep "pat" / impact X`;' +
49
+ 'MCP 核心 7get_call_graph/module_overview/semantic_code_search/ast_search/find_references/get_ast_node/project_map),决策表见全文';
50
50
 
51
51
  // memdir L1 升格 (per sdscc 重构方案 §5.0): the INDEX_LINE that lands in
52
52
  // MEMORY.md is what Claude sees first on every keyword match. Tailoring it
@@ -234,9 +234,12 @@ function detectProjectType(cwd = process.cwd(), env = process.env) {
234
234
  // most for THIS project.
235
235
  function buildIndexLine(projectType = 'generic') {
236
236
  const prefix = '- [code-graph-mcp](plugin_code_graph_mcp.md) ';
237
+ // v0.49 — CLI form leads: in Claude Code the MCP tools are deferred (need a
238
+ // ToolSearch load before first call) while Bash is always live; the only
239
+ // conversions observed in real coding nights were CLI invocations.
237
240
  const coreSuffix =
238
- '核心 7(get_call_graph/module_overview/semantic_code_search/ast_search/find_references/get_ast_node/project_map)' +
239
- '+ 进阶 5impact_analysis/trace_http_chain/dependency_graph/find_similar_code/find_dead_code),决策表见全文';
241
+ 'Bash 直呼 CLI 最快(零加载):`code-graph-mcp callgraph X / show X / overview <dir> / grep "pat" / impact X`;' +
242
+ 'MCP 核心 7get_call_graph/module_overview/semantic_code_search/ast_search/find_references/get_ast_node/project_map),决策表见全文';
240
243
  switch (projectType) {
241
244
  case 'web-rs':
242
245
  case 'web-node':
@@ -6,6 +6,7 @@ const https = require('https');
6
6
  const path = require('path');
7
7
  const os = require('os');
8
8
  const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
9
+ const { claudeHome } = require('./claude-config');
9
10
  const { clearCache: clearBinaryCache } = require('./find-binary');
10
11
  const { readBinaryVersion, isDevMode } = require('./version-utils');
11
12
  const { cgTmpDir } = require('./tmp-dir');
@@ -277,9 +278,41 @@ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion) {
277
278
  }
278
279
  }
279
280
 
281
+ // ── Marketplace clone refresh ──────────────────────────────
282
+
283
+ function marketplaceCloneDir() {
284
+ return path.join(claudeHome(), 'plugins', 'marketplaces', MARKETPLACE_NAME);
285
+ }
286
+
287
+ /**
288
+ * Fast-forward the Claude Code marketplace clone after a plugin update.
289
+ *
290
+ * Auto-update writes the plugin cache + installed_plugins.json directly and
291
+ * never touched the marketplace clone, so its marketplace.json stayed pinned
292
+ * at the version present when the user last ran a /plugin command (observed
293
+ * live: clone at 0.48.0 four days after 0.49.0 shipped). A stale clone makes
294
+ * the /plugin UI report the old version and lets Claude Code re-install the
295
+ * old plugin files from it. --ff-only + silent failure: a dirty or diverged
296
+ * clone is Claude Code's property — never force anything there.
297
+ */
298
+ function refreshMarketplaceClone({ dir = marketplaceCloneDir(), exec = execFileSync, timeoutMs = 15000 } = {}) {
299
+ try {
300
+ if (!fs.existsSync(path.join(dir, '.git'))) return false;
301
+ if (!commandExists('git')) return false;
302
+ exec('git', ['-C', dir, 'pull', '--ff-only', '--quiet'], { timeout: timeoutMs, stdio: 'pipe' });
303
+ return true;
304
+ } catch {
305
+ return false;
306
+ }
307
+ }
308
+
280
309
  // ── Download & Install ─────────────────────────────────────
281
310
 
282
- async function downloadAndInstall(latest) {
311
+ async function downloadAndInstall(latest, {
312
+ exec = execFileSync,
313
+ downloadBin = downloadBinary,
314
+ refreshMarketplace = refreshMarketplaceClone,
315
+ } = {}) {
283
316
  // Pre-flight: check required CLI tools before attempting any download
284
317
  const missingTools = ['curl', 'tar'].filter(cmd => !commandExists(cmd));
285
318
  if (missingTools.length > 0) {
@@ -290,19 +323,20 @@ async function downloadAndInstall(latest) {
290
323
  const tmpDir = path.join(cgTmpDir(), `update-${Date.now()}`);
291
324
  let pluginUpdated = false;
292
325
  let binaryUpdated = false;
326
+ let marketplaceRefreshed = false;
293
327
 
294
328
  try {
295
329
  fs.mkdirSync(tmpDir, { recursive: true });
296
330
 
297
331
  // ── Step 1: Download and install plugin files from tarball ──
298
332
  const tarballPath = path.join(tmpDir, 'release.tar.gz');
299
- execFileSync('curl', [
333
+ exec('curl', [
300
334
  '-sL', '-o', tarballPath,
301
335
  '-H', 'Accept: application/vnd.github+json',
302
336
  latest.tarballUrl,
303
337
  ], { timeout: 30000, stdio: 'pipe' });
304
338
 
305
- execFileSync('tar', [
339
+ exec('tar', [
306
340
  'xzf', tarballPath, '-C', tmpDir, '--strip-components=1',
307
341
  ], { timeout: 15000, stdio: 'pipe' });
308
342
 
@@ -344,22 +378,28 @@ async function downloadAndInstall(latest) {
344
378
  try {
345
379
  const newLifecycle = path.join(pluginDst, 'scripts', 'lifecycle.js');
346
380
  if (fs.existsSync(newLifecycle)) {
347
- execFileSync(process.execPath, [newLifecycle, 'update'], {
381
+ exec(process.execPath, [newLifecycle, 'update'], {
348
382
  timeout: 5000, stdio: 'pipe',
349
383
  });
350
384
  }
351
385
  } catch { /* not fatal — syncLifecycleConfig will self-heal on next session */ }
352
386
  }
353
387
 
388
+ // ── Step 1.5: Fast-forward the marketplace clone so /plugin UI and any
389
+ // Claude-Code-side reinstall see the version we just installed.
390
+ if (pluginUpdated) {
391
+ marketplaceRefreshed = refreshMarketplace();
392
+ }
393
+
354
394
  // ── Step 2: Download platform binary directly from GitHub release ──
355
- if (await downloadBinary(latest)) {
395
+ if (await downloadBin(latest)) {
356
396
  binaryUpdated = true;
357
397
  }
358
398
 
359
- return { pluginUpdated, binaryUpdated };
399
+ return { pluginUpdated, binaryUpdated, marketplaceRefreshed };
360
400
  } catch (e) {
361
401
  console.error(`[code-graph] Plugin download/extract failed: ${e.message}`);
362
- return { pluginUpdated: false, binaryUpdated: false };
402
+ return { pluginUpdated: false, binaryUpdated: false, marketplaceRefreshed };
363
403
  } finally {
364
404
  try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ok */ }
365
405
  }
@@ -431,6 +471,7 @@ async function checkForUpdate({ installMissing = false } = {}) {
431
471
  lastUpdate: success ? new Date().toISOString() : state.lastUpdate,
432
472
  rateLimited: false,
433
473
  binaryUpdated: result.binaryUpdated,
474
+ marketplaceRefreshed: result.marketplaceRefreshed,
434
475
  };
435
476
  saveState(newState);
436
477
 
@@ -474,6 +515,7 @@ module.exports = {
474
515
  requestJson, parseLatestRelease, fetchLatestRelease,
475
516
  downloadBinary, cachedBinaryPath, cachedBinaryNeedsUpdate, cachedBinaryStaleVsState,
476
517
  selfHealStaleBinary,
518
+ downloadAndInstall, refreshMarketplaceClone, marketplaceCloneDir,
477
519
  };
478
520
 
479
521
  // CLI: node auto-update.js [check|status] [--silent] [--install-missing]
@@ -247,4 +247,96 @@ test('fetchLatestRelease parses JSON without relying on global fetch', async ()
247
247
 
248
248
  assert.equal(latest.version, '2.0.0');
249
249
  assert.equal(latest.tarballUrl, 'https://example.com/release.tgz');
250
- });
250
+ });
251
+ // ── refreshMarketplaceClone (v0.49.1 marketplace-staleness fix) ────────────
252
+
253
+ const { execFileSync: execGit } = require('child_process');
254
+ const { refreshMarketplaceClone, downloadAndInstall } = require('./auto-update');
255
+
256
+ function git(cwd, ...args) {
257
+ return execGit('git', ['-C', cwd, '-c', 'user.email=t@t', '-c', 'user.name=t', ...args],
258
+ { stdio: 'pipe', encoding: 'utf8' });
259
+ }
260
+
261
+ test('refreshMarketplaceClone fast-forwards a stale clone', (t) => {
262
+ const root = mkDir(t, 'code-graph-mp-');
263
+ const remote = path.join(root, 'remote');
264
+ const clone = path.join(root, 'clone');
265
+
266
+ fs.mkdirSync(remote);
267
+ git(remote, 'init', '-q', '-b', 'main');
268
+ fs.writeFileSync(path.join(remote, 'marketplace.json'), '{"version":"0.48.0"}');
269
+ git(remote, 'add', '.');
270
+ git(remote, 'commit', '-q', '-m', 'v0.48.0');
271
+ execGit('git', ['clone', '-q', remote, clone], { stdio: 'pipe' });
272
+
273
+ // Remote advances (a release bumped marketplace.json) — clone is now stale.
274
+ fs.writeFileSync(path.join(remote, 'marketplace.json'), '{"version":"0.49.0"}');
275
+ git(remote, 'commit', '-q', '-am', 'v0.49.0');
276
+
277
+ assert.equal(refreshMarketplaceClone({ dir: clone }), true);
278
+ assert.match(fs.readFileSync(path.join(clone, 'marketplace.json'), 'utf8'), /0\.49\.0/);
279
+ });
280
+
281
+ test('refreshMarketplaceClone is a safe no-op on non-git dirs and pull failures', (t) => {
282
+ const root = mkDir(t, 'code-graph-mp-');
283
+ // Not a git repo → false, no throw.
284
+ assert.equal(refreshMarketplaceClone({ dir: root }), false);
285
+ // Missing dir → false, no throw.
286
+ assert.equal(refreshMarketplaceClone({ dir: path.join(root, 'nope') }), false);
287
+ // exec throws (diverged / dirty clone) → false, no throw.
288
+ const fakeGitDir = path.join(root, 'repo');
289
+ fs.mkdirSync(path.join(fakeGitDir, '.git'), { recursive: true });
290
+ assert.equal(refreshMarketplaceClone({
291
+ dir: fakeGitDir,
292
+ exec: () => { throw new Error('not a fast-forward'); },
293
+ }), false);
294
+ });
295
+
296
+ test('downloadAndInstall wires the marketplace refresh + binary download (orchestration glue)', async (t) => {
297
+ // In-process with all side-effectful deps injected would still write the
298
+ // manifest into the REAL ~/.cache (CACHE_DIR is bound at module load), so
299
+ // run in a subprocess with a sandboxed HOME — same pattern as install-e2e.
300
+ const sandboxHome = mkDir(t, 'code-graph-dai-');
301
+ const script = `
302
+ const fs = require('fs');
303
+ const path = require('path');
304
+ const { downloadAndInstall } = require(${JSON.stringify(path.join(__dirname, 'auto-update.js'))});
305
+ const latest = { version: '9.9.9', tarballUrl: 'https://example/tar', binaryUrl: null };
306
+ const calls = [];
307
+ const exec = (cmd, args) => {
308
+ calls.push(cmd);
309
+ if (cmd === 'tar') {
310
+ // Simulate extraction: produce claude-plugin/ with a matching version.
311
+ const tmpDir = args[args.indexOf('-C') + 1];
312
+ const mDir = path.join(tmpDir, 'claude-plugin', '.claude-plugin');
313
+ fs.mkdirSync(mDir, { recursive: true });
314
+ fs.writeFileSync(path.join(mDir, 'plugin.json'), JSON.stringify({ version: '9.9.9' }));
315
+ }
316
+ };
317
+ (async () => {
318
+ let refreshed = 0;
319
+ let binDownloads = 0;
320
+ const result = await downloadAndInstall(latest, {
321
+ exec,
322
+ refreshMarketplace: () => { refreshed++; return true; },
323
+ downloadBin: async () => { binDownloads++; return true; },
324
+ });
325
+ console.log(JSON.stringify({ result, refreshed, binDownloads, calls }));
326
+ })();
327
+ `;
328
+ const out = execGit(process.execPath, ['-e', script], {
329
+ env: { ...process.env, HOME: sandboxHome },
330
+ encoding: 'utf8',
331
+ });
332
+ const { result, refreshed, binDownloads } = JSON.parse(out.trim().split('\n').pop());
333
+ assert.equal(result.pluginUpdated, true, 'plugin files must install from the extracted tarball');
334
+ assert.equal(refreshed, 1, 'marketplace refresh must run exactly once after a plugin update');
335
+ assert.equal(result.marketplaceRefreshed, true);
336
+ assert.equal(binDownloads, 1, 'binary download must run');
337
+ assert.equal(result.binaryUpdated, true);
338
+ // Plugin landed in the sandboxed cache, not the real one.
339
+ const dst = path.join(sandboxHome, '.claude', 'plugins', 'cache',
340
+ 'code-graph-mcp', 'code-graph-mcp', '9.9.9', '.claude-plugin', 'plugin.json');
341
+ assert.equal(fs.existsSync(dst), true, 'plugin copied into sandbox plugins cache');
342
+ });
@@ -110,8 +110,20 @@ function runGrepAnswer(opts = {}) {
110
110
  encoding: 'utf8',
111
111
  maxBuffer: 4 * 1024 * 1024,
112
112
  stdio: ['ignore', 'pipe', 'ignore'],
113
+ // Hook-internal run: a delivered answer, not a model-initiated conversion.
114
+ // The CLI skips its recommendations.jsonl `use` record when this is set.
115
+ env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
113
116
  });
114
- if (res.error || res.signal || res.status !== 0) {
117
+ if (res.error || res.signal) {
118
+ return { status: 'unavailable' };
119
+ }
120
+ // v0.50 grep-parity exit codes: 0 = matched, 1 = no match, 2 = error.
121
+ // Older binaries exit 0 on no-match with the NO_MATCH_PREFIX on stderr
122
+ // (stdout empty) — both shapes resolve to 'no-hits' below.
123
+ if (res.status === 1) {
124
+ return { status: 'no-hits' };
125
+ }
126
+ if (res.status !== 0) {
115
127
  return { status: 'unavailable' };
116
128
  }
117
129
  const out = (res.stdout || '').trim();
@@ -125,4 +137,95 @@ function runGrepAnswer(opts = {}) {
125
137
  }
126
138
  }
127
139
 
128
- module.exports = { runGrepAnswer, truncateAtLine, sanitizeSearchPath };
140
+ /**
141
+ * v0.49 — Run `code-graph-mcp show <symbol>` for up to 3 declaration symbols
142
+ * and concatenate the bodies. Powers the show-mode deny (declaration-anchor +
143
+ * context-flag greps: the model wants to READ the functions, so hand it the
144
+ * functions). Same bounded/best-effort posture as runGrepAnswer; symbols that
145
+ * fail to resolve are skipped, all-fail → no-hits (caller falls back to grep).
146
+ */
147
+ function runShowAnswer(opts = {}) {
148
+ const {
149
+ cwd,
150
+ symbols,
151
+ timeoutMs = DEFAULT_TIMEOUT_MS,
152
+ maxBytes = DEFAULT_MAX_BYTES,
153
+ } = opts;
154
+ try {
155
+ if (!Array.isArray(symbols) || symbols.length === 0) {
156
+ return { status: 'unavailable' };
157
+ }
158
+ let binary = opts.binary;
159
+ if (binary === undefined) {
160
+ binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
161
+ }
162
+ if (!binary) return { status: 'unavailable' };
163
+
164
+ const parts = [];
165
+ for (const sym of symbols.slice(0, 3)) {
166
+ if (typeof sym !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sym)) continue;
167
+ const res = spawnSync(binary, ['show', sym], {
168
+ cwd,
169
+ timeout: timeoutMs,
170
+ encoding: 'utf8',
171
+ maxBuffer: 4 * 1024 * 1024,
172
+ stdio: ['ignore', 'pipe', 'ignore'],
173
+ env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
174
+ });
175
+ if (res.error || res.signal || res.status !== 0) continue;
176
+ const out = (res.stdout || '').trim();
177
+ if (!out || out.startsWith(NO_MATCH_PREFIX)) continue;
178
+ parts.push(`$ code-graph-mcp show ${sym}\n${out}`);
179
+ }
180
+ if (parts.length === 0) return { status: 'no-hits' };
181
+ const { text, truncated } = truncateAtLine(parts.join('\n\n'), maxBytes);
182
+ return { status: 'hits', text, truncated };
183
+ } catch {
184
+ return { status: 'unavailable' };
185
+ }
186
+ }
187
+
188
+ /**
189
+ * v0.49 — Run `code-graph-mcp overview <dir>` for the read-fanout hint, so the
190
+ * hint DELIVERS the module map instead of advising a tool call (hints measured
191
+ * 0/40 transfer on 2026-06-12; delivered answers satisfied 5/5 in place).
192
+ */
193
+ function runOverviewAnswer(opts = {}) {
194
+ const {
195
+ cwd,
196
+ dir,
197
+ timeoutMs = DEFAULT_TIMEOUT_MS,
198
+ maxBytes = DEFAULT_MAX_BYTES,
199
+ } = opts;
200
+ try {
201
+ if (!dir || typeof dir !== 'string' || dir.length > 300) {
202
+ return { status: 'unavailable' };
203
+ }
204
+ let binary = opts.binary;
205
+ if (binary === undefined) {
206
+ binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
207
+ }
208
+ if (!binary) return { status: 'unavailable' };
209
+ const res = spawnSync(binary, ['overview', dir], {
210
+ cwd,
211
+ timeout: timeoutMs,
212
+ encoding: 'utf8',
213
+ maxBuffer: 4 * 1024 * 1024,
214
+ stdio: ['ignore', 'pipe', 'ignore'],
215
+ env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
216
+ });
217
+ if (res.error || res.signal || res.status !== 0) {
218
+ return { status: 'unavailable' };
219
+ }
220
+ const out = (res.stdout || '').trim();
221
+ if (!out || out.startsWith(NO_MATCH_PREFIX)) return { status: 'no-hits' };
222
+ const { text, truncated } = truncateAtLine(out, maxBytes);
223
+ return { status: 'hits', text, truncated };
224
+ } catch {
225
+ return { status: 'unavailable' };
226
+ }
227
+ }
228
+
229
+ module.exports = {
230
+ runGrepAnswer, runShowAnswer, runOverviewAnswer, truncateAtLine, sanitizeSearchPath,
231
+ };
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
4
4
  const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
- const { runGrepAnswer, truncateAtLine } = require('./cg-answer');
7
+ const { runGrepAnswer, runShowAnswer, truncateAtLine } = require('./cg-answer');
8
8
 
9
9
  // Stub "binary": a node script that reacts to its first real arg so one stub
10
10
  // covers hits / no-hits / error / timeout cases.
@@ -21,6 +21,9 @@ if (pattern === 'HangForever') { setTimeout(() => {}, 60000); }
21
21
  else if (pattern === 'ExplodePlease') { process.exit(3); }
22
22
  else if (pattern === 'NothingHere') {
23
23
  process.stdout.write('[code-graph] No matches for: NothingHere\\n');
24
+ } else if (pattern === 'NothingHereExit1') {
25
+ // v0.50 grep-parity binary: no match → empty stdout + exit 1
26
+ process.exit(1);
24
27
  } else {
25
28
  process.stdout.write(
26
29
  'src/storage/db.rs:42 fn ' + pattern + '() {\\n' +
@@ -58,6 +61,19 @@ test('runGrepAnswer: passes grep subcommand, pattern and path as argv', () => {
58
61
  assert.match(r.text, /args=\["grep","fts5_search","src\/storage\/"\]/);
59
62
  });
60
63
 
64
+ test('runGrepAnswer: child env carries CODE_GRAPH_INTERNAL=1 (not a funnel conversion)', () => {
65
+ // Stub variant that echoes the marker back in its output.
66
+ const envStub = path.join(stubDir, 'cg-env-stub.js');
67
+ fs.writeFileSync(envStub, `#!/usr/bin/env node
68
+ process.stdout.write('internal=' + (process.env.CODE_GRAPH_INTERNAL || '') + '\\n');
69
+ `);
70
+ fs.chmodSync(envStub, 0o755);
71
+ const r = runGrepAnswer({ cwd: stubDir, pattern: 'whatever', binary: envStub });
72
+ assert.equal(r.status, 'hits');
73
+ assert.match(r.text, /internal=1/,
74
+ 'hook-internal CLI runs must be marked so record_cli_use skips them');
75
+ });
76
+
61
77
  test('runGrepAnswer: omits path argv when no searchPath', () => {
62
78
  const r = runGrepAnswer({ cwd: stubDir, pattern: 'fts5_search', binary: stubBinary() });
63
79
  assert.match(r.text, /args=\["grep","fts5_search"\]/);
@@ -68,7 +84,13 @@ test('runGrepAnswer: CLI "[code-graph] No matches" → status no-hits', () => {
68
84
  assert.equal(r.status, 'no-hits');
69
85
  });
70
86
 
71
- test('runGrepAnswer: nonzero exit → unavailable', () => {
87
+ test('runGrepAnswer: exit 1 (v0.50 grep-parity no-match) status no-hits', () => {
88
+ const r = runGrepAnswer({ cwd: stubDir, pattern: 'NothingHereExit1', binary: stubBinary() });
89
+ assert.equal(r.status, 'no-hits',
90
+ 'grep-parity exit 1 means no match, not a failed binary');
91
+ });
92
+
93
+ test('runGrepAnswer: exit >1 → unavailable', () => {
72
94
  const r = runGrepAnswer({ cwd: stubDir, pattern: 'ExplodePlease', binary: stubBinary() });
73
95
  assert.equal(r.status, 'unavailable');
74
96
  });
@@ -160,3 +182,34 @@ test('runGrepAnswer: glob searchPath is truncated before spawn (defensive layer)
160
182
  assert.equal(r.status, 'hits');
161
183
  assert.match(r.text, /args=\["grep","fts5_search","src\/storage"\]/);
162
184
  });
185
+
186
+ // ── runShowAnswer (v0.49) — show-mode deny bodies ────────────────────
187
+
188
+ test('runShowAnswer: concatenates per-symbol show output with $ headers', () => {
189
+ const r = runShowAnswer({ cwd: stubDir, symbols: ['alpha_one', 'beta_two'], binary: stubBinary() });
190
+ assert.equal(r.status, 'hits');
191
+ assert.match(r.text, /\$ code-graph-mcp show alpha_one/);
192
+ assert.match(r.text, /\$ code-graph-mcp show beta_two/);
193
+ });
194
+
195
+ test('runShowAnswer: skips non-identifier symbols, all-skipped → unavailable-safe no-hits', () => {
196
+ const r = runShowAnswer({ cwd: stubDir, symbols: ['$(rm -rf)', 'a|b'], binary: stubBinary() });
197
+ assert.equal(r.status, 'no-hits');
198
+ });
199
+
200
+ test('runShowAnswer: caps at 3 symbols', () => {
201
+ const r = runShowAnswer({
202
+ cwd: stubDir, symbols: ['s_one', 's_two', 's_three', 's_four'], binary: stubBinary(),
203
+ });
204
+ assert.equal(r.status, 'hits');
205
+ assert.doesNotMatch(r.text, /show s_four/);
206
+ });
207
+
208
+ test('runShowAnswer: empty symbol list → unavailable', () => {
209
+ assert.equal(runShowAnswer({ cwd: stubDir, symbols: [], binary: stubBinary() }).status, 'unavailable');
210
+ });
211
+
212
+ test('runShowAnswer: failing binary → no-hits (caller falls back to grep answer)', () => {
213
+ const r = runShowAnswer({ cwd: stubDir, symbols: ['ExplodePlease'], binary: stubBinary() });
214
+ assert.equal(r.status, 'no-hits');
215
+ });
@@ -7,7 +7,7 @@ const os = require('os');
7
7
  const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
8
8
  const {
9
9
  getPluginVersion, readJson, healthCheck, CACHE_DIR,
10
- isOurHookEntry, settingsPath, buildSettingsHookEntries,
10
+ settingsPath, surveyHookCoverage,
11
11
  } = require('./lifecycle');
12
12
  const { findBinary, clearCache: clearBinaryCache } = require('./find-binary');
13
13
 
@@ -234,49 +234,6 @@ function runDiagnostics() {
234
234
  return results;
235
235
  }
236
236
 
237
- // Inventory of (event, matcher) tuples we expect to find in settings.json after
238
- // install. Used by doctor to detect missing entries.
239
- function surveyHookCoverage(settings) {
240
- const desired = buildSettingsHookEntries();
241
- const expected = [];
242
- const desiredCmd = {}; // key -> command string we would write now
243
- for (const [event, entries] of Object.entries(desired)) {
244
- for (const e of entries) {
245
- const key = `${event}:${e.matcher || '*'}`;
246
- expected.push(key);
247
- desiredCmd[key] = e.hooks && e.hooks[0] && e.hooks[0].command;
248
- }
249
- }
250
-
251
- const present = new Set();
252
- const presentCmd = {}; // key -> command currently registered
253
- if (settings && settings.hooks) {
254
- for (const [event, entries] of Object.entries(settings.hooks)) {
255
- if (!Array.isArray(entries)) continue;
256
- for (const entry of entries) {
257
- if (isOurHookEntry(entry)) {
258
- const key = `${event}:${entry.matcher || '*'}`;
259
- present.add(key);
260
- if (entry.hooks && entry.hooks[0] && entry.hooks[0].command) {
261
- presentCmd[key] = entry.hooks[0].command;
262
- }
263
- }
264
- }
265
- }
266
- }
267
-
268
- const missing = expected.filter(k => !present.has(k));
269
- // Stale = present but the registered command no longer matches what we'd write
270
- // now (points at an old plugin-cache version dir / moved path). A stale path can
271
- // run pre-recordRecommendation hook code, so the hook fires but the conversion
272
- // metric stays dark — invisible to a present/absent check. This is the
273
- // 0.45.1-registered-while-0.45.4-active case the RCA surfaced.
274
- const stale = expected.filter(k =>
275
- present.has(k) && desiredCmd[k] && presentCmd[k] && presentCmd[k] !== desiredCmd[k]
276
- );
277
- return { expected, present: [...present], missing, stale };
278
- }
279
-
280
237
  // ── Report Formatting ─────────────────────────────────────
281
238
 
282
239
  const STATUS_ICONS = { ok: '\u2705', warn: '\u26a0\ufe0f', error: '\u274c', skip: '\u2796' };
@@ -306,6 +263,26 @@ function formatReport(results) {
306
263
 
307
264
  // ── Repair Actions ────────────────────────────────────────
308
265
 
266
+ /**
267
+ * v0.50.0: settings-writing repairs get the same stale-relic guard as
268
+ * session-init. A doctor launched from an old plugin-cache version dir would
269
+ * otherwise install() and re-anchor manifest + settings.json hook paths to the
270
+ * relic — the exact downgrade war the guard exists for, just user-triggered.
271
+ * Returns true (and prints redirection) when this copy must NOT write config.
272
+ * `relic` is injectable for tests.
273
+ */
274
+ function relicRepairGuard({ log = console.log, relic = undefined } = {}) {
275
+ const { isStaleRelicContext, activeInstallPath } = require('./lifecycle');
276
+ const isRelic = relic !== undefined ? relic : isStaleRelicContext();
277
+ if (!isRelic) return false;
278
+ const active = activeInstallPath();
279
+ log(' ⚠ This doctor copy is not the active install (installed_plugins.json points elsewhere) — skipping settings repair.');
280
+ if (active) {
281
+ log(` Run the active copy instead: node "${path.join(active, 'scripts', 'doctor.js')}"`);
282
+ }
283
+ return true;
284
+ }
285
+
309
286
  function runRepairs(results) {
310
287
  const fixable = results.filter(r => r.fixId);
311
288
  if (fixable.length === 0) return 0;
@@ -425,6 +402,7 @@ function runRepairs(results) {
425
402
 
426
403
  case 'hooks-invalid': {
427
404
  console.log('\n Repairing hooks...');
405
+ if (relicRepairGuard()) break;
428
406
  const { install } = require('./lifecycle');
429
407
  install();
430
408
  console.log(' \u2705 Hooks repaired \u2014 restart Claude Code to apply');
@@ -434,6 +412,7 @@ function runRepairs(results) {
434
412
 
435
413
  case 'missing-hooks-in-settings': {
436
414
  console.log('\n Registering code-graph hooks in settings.json...');
415
+ if (relicRepairGuard()) break;
437
416
  const { install } = require('./lifecycle');
438
417
  const r = install();
439
418
  if (r.hooksRegistered) {
@@ -474,7 +453,7 @@ function runDoctor(opts = {}) {
474
453
  return { results, issueCount: issues.length };
475
454
  }
476
455
 
477
- module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, surveyHookCoverage };
456
+ module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, surveyHookCoverage, relicRepairGuard };
478
457
 
479
458
  if (require.main === module) {
480
459
  const args = process.argv.slice(2);
@@ -80,3 +80,16 @@ test('surveyHookCoverage flags missing entries when settings empty', () => {
80
80
  assert.ok(cov.missing.length === cov.expected.length, 'all expected entries missing');
81
81
  assert.equal(cov.stale.length, 0, 'nothing present to be stale');
82
82
  });
83
+
84
+ // ── relicRepairGuard (v0.50.0 — doctor twin of the session-init relic guard) ──
85
+
86
+ test('relicRepairGuard blocks settings repair from a relic copy and redirects', () => {
87
+ const { relicRepairGuard } = require('./doctor');
88
+ const lines = [];
89
+ // Relic context → guard fires, prints the redirect, returns true (skip install).
90
+ assert.equal(relicRepairGuard({ relic: true, log: (s) => lines.push(s) }), true);
91
+ assert.ok(lines.some(l => l.includes('not the active install')),
92
+ `guard must explain why repair is skipped, got: ${lines.join(' | ')}`);
93
+ // Active (or dev/npm) context → repair proceeds.
94
+ assert.equal(relicRepairGuard({ relic: false, log: () => {} }), false);
95
+ });