@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.
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/adopt.js +7 -4
- package/claude-plugin/scripts/auto-update.js +49 -7
- package/claude-plugin/scripts/auto-update.test.js +93 -1
- package/claude-plugin/scripts/cg-answer.js +105 -2
- package/claude-plugin/scripts/cg-answer.test.js +55 -2
- package/claude-plugin/scripts/doctor.js +24 -45
- package/claude-plugin/scripts/doctor.test.js +13 -0
- package/claude-plugin/scripts/lifecycle.js +88 -0
- package/claude-plugin/scripts/lifecycle.test.js +40 -1
- package/claude-plugin/scripts/pre-edit-guide.js +21 -5
- package/claude-plugin/scripts/pre-grep-guide.js +219 -61
- package/claude-plugin/scripts/pre-grep-guide.test.js +237 -19
- package/claude-plugin/scripts/pre-read-guide.js +65 -25
- package/claude-plugin/scripts/pre-read-guide.test.js +59 -1
- package/claude-plugin/scripts/project-root.js +30 -0
- package/claude-plugin/scripts/session-init.js +38 -14
- package/claude-plugin/templates/plugin_code_graph_mcp.md +8 -0
- package/package.json +6 -6
|
@@ -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
|
-
'
|
|
49
|
-
'
|
|
48
|
+
'Bash 直呼 CLI 最快(零加载):`code-graph-mcp callgraph X / show X / overview <dir> / grep "pat" / impact X`;' +
|
|
49
|
+
'MCP 核心 7(get_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
|
-
'
|
|
239
|
-
'
|
|
241
|
+
'Bash 直呼 CLI 最快(零加载):`code-graph-mcp callgraph X / show X / overview <dir> / grep "pat" / impact X`;' +
|
|
242
|
+
'MCP 核心 7(get_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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
});
|