@sdsrs/code-graph 0.47.1 → 0.49.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/cg-answer.js +117 -2
- package/claude-plugin/scripts/cg-answer.test.js +72 -1
- package/claude-plugin/scripts/pre-edit-guide.js +21 -5
- package/claude-plugin/scripts/pre-grep-guide.js +255 -49
- package/claude-plugin/scripts/pre-grep-guide.test.js +295 -16
- 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/templates/plugin_code_graph_mcp.md +5 -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':
|
|
@@ -48,6 +48,24 @@ function truncateAtLine(text, maxBytes) {
|
|
|
48
48
|
return { text: buf.subarray(0, maxBytes).toString('latin1'), truncated: true };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* v0.48 — drop glob segments from a search path. The hook extracts path tokens
|
|
53
|
+
* verbatim from the denied command, and spawnSync runs WITHOUT a shell, so a
|
|
54
|
+
* literal `backend/…/llm_engine/*.py` reaches rg as a nonexistent file →
|
|
55
|
+
* exit 1 → `unavailable` → static deny with no answer (daagu 2026-06-11: the
|
|
56
|
+
* night's only deny failed exactly this way). Truncate at the first segment
|
|
57
|
+
* containing a glob metacharacter; widening the scope to the parent dir is
|
|
58
|
+
* always safe. A leading glob (`*.py`) drops the scope entirely (repo-wide).
|
|
59
|
+
*/
|
|
60
|
+
function sanitizeSearchPath(searchPath) {
|
|
61
|
+
if (!searchPath || typeof searchPath !== 'string') return undefined;
|
|
62
|
+
const segs = searchPath.split('/');
|
|
63
|
+
const i = segs.findIndex((s) => /[*?[\]{}]/.test(s));
|
|
64
|
+
if (i === -1) return searchPath;
|
|
65
|
+
const kept = segs.slice(0, i).join('/');
|
|
66
|
+
return kept || undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
51
69
|
/**
|
|
52
70
|
* Run `code-graph-mcp grep <pattern> [searchPath]` synchronously.
|
|
53
71
|
*
|
|
@@ -81,14 +99,20 @@ function runGrepAnswer(opts = {}) {
|
|
|
81
99
|
}
|
|
82
100
|
if (!binary) return { status: 'unavailable' };
|
|
83
101
|
|
|
102
|
+
// Defensive re-sanitize: callers should pass a clean path, but a glob
|
|
103
|
+
// reaching argv is a guaranteed nonzero exit (see sanitizeSearchPath).
|
|
104
|
+
const scope = sanitizeSearchPath(searchPath);
|
|
84
105
|
const args = ['grep', pattern];
|
|
85
|
-
if (
|
|
106
|
+
if (scope) args.push(scope);
|
|
86
107
|
const res = spawnSync(binary, args, {
|
|
87
108
|
cwd,
|
|
88
109
|
timeout: timeoutMs,
|
|
89
110
|
encoding: 'utf8',
|
|
90
111
|
maxBuffer: 4 * 1024 * 1024,
|
|
91
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' },
|
|
92
116
|
});
|
|
93
117
|
if (res.error || res.signal || res.status !== 0) {
|
|
94
118
|
return { status: 'unavailable' };
|
|
@@ -104,4 +128,95 @@ function runGrepAnswer(opts = {}) {
|
|
|
104
128
|
}
|
|
105
129
|
}
|
|
106
130
|
|
|
107
|
-
|
|
131
|
+
/**
|
|
132
|
+
* v0.49 — Run `code-graph-mcp show <symbol>` for up to 3 declaration symbols
|
|
133
|
+
* and concatenate the bodies. Powers the show-mode deny (declaration-anchor +
|
|
134
|
+
* context-flag greps: the model wants to READ the functions, so hand it the
|
|
135
|
+
* functions). Same bounded/best-effort posture as runGrepAnswer; symbols that
|
|
136
|
+
* fail to resolve are skipped, all-fail → no-hits (caller falls back to grep).
|
|
137
|
+
*/
|
|
138
|
+
function runShowAnswer(opts = {}) {
|
|
139
|
+
const {
|
|
140
|
+
cwd,
|
|
141
|
+
symbols,
|
|
142
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
143
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
144
|
+
} = opts;
|
|
145
|
+
try {
|
|
146
|
+
if (!Array.isArray(symbols) || symbols.length === 0) {
|
|
147
|
+
return { status: 'unavailable' };
|
|
148
|
+
}
|
|
149
|
+
let binary = opts.binary;
|
|
150
|
+
if (binary === undefined) {
|
|
151
|
+
binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
|
|
152
|
+
}
|
|
153
|
+
if (!binary) return { status: 'unavailable' };
|
|
154
|
+
|
|
155
|
+
const parts = [];
|
|
156
|
+
for (const sym of symbols.slice(0, 3)) {
|
|
157
|
+
if (typeof sym !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sym)) continue;
|
|
158
|
+
const res = spawnSync(binary, ['show', sym], {
|
|
159
|
+
cwd,
|
|
160
|
+
timeout: timeoutMs,
|
|
161
|
+
encoding: 'utf8',
|
|
162
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
163
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
164
|
+
env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
|
|
165
|
+
});
|
|
166
|
+
if (res.error || res.signal || res.status !== 0) continue;
|
|
167
|
+
const out = (res.stdout || '').trim();
|
|
168
|
+
if (!out || out.startsWith(NO_MATCH_PREFIX)) continue;
|
|
169
|
+
parts.push(`$ code-graph-mcp show ${sym}\n${out}`);
|
|
170
|
+
}
|
|
171
|
+
if (parts.length === 0) return { status: 'no-hits' };
|
|
172
|
+
const { text, truncated } = truncateAtLine(parts.join('\n\n'), maxBytes);
|
|
173
|
+
return { status: 'hits', text, truncated };
|
|
174
|
+
} catch {
|
|
175
|
+
return { status: 'unavailable' };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* v0.49 — Run `code-graph-mcp overview <dir>` for the read-fanout hint, so the
|
|
181
|
+
* hint DELIVERS the module map instead of advising a tool call (hints measured
|
|
182
|
+
* 0/40 transfer on 2026-06-12; delivered answers satisfied 5/5 in place).
|
|
183
|
+
*/
|
|
184
|
+
function runOverviewAnswer(opts = {}) {
|
|
185
|
+
const {
|
|
186
|
+
cwd,
|
|
187
|
+
dir,
|
|
188
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
189
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
190
|
+
} = opts;
|
|
191
|
+
try {
|
|
192
|
+
if (!dir || typeof dir !== 'string' || dir.length > 300) {
|
|
193
|
+
return { status: 'unavailable' };
|
|
194
|
+
}
|
|
195
|
+
let binary = opts.binary;
|
|
196
|
+
if (binary === undefined) {
|
|
197
|
+
binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
|
|
198
|
+
}
|
|
199
|
+
if (!binary) return { status: 'unavailable' };
|
|
200
|
+
const res = spawnSync(binary, ['overview', dir], {
|
|
201
|
+
cwd,
|
|
202
|
+
timeout: timeoutMs,
|
|
203
|
+
encoding: 'utf8',
|
|
204
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
205
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
206
|
+
env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
|
|
207
|
+
});
|
|
208
|
+
if (res.error || res.signal || res.status !== 0) {
|
|
209
|
+
return { status: 'unavailable' };
|
|
210
|
+
}
|
|
211
|
+
const out = (res.stdout || '').trim();
|
|
212
|
+
if (!out || out.startsWith(NO_MATCH_PREFIX)) return { status: 'no-hits' };
|
|
213
|
+
const { text, truncated } = truncateAtLine(out, maxBytes);
|
|
214
|
+
return { status: 'hits', text, truncated };
|
|
215
|
+
} catch {
|
|
216
|
+
return { status: 'unavailable' };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
runGrepAnswer, runShowAnswer, runOverviewAnswer, truncateAtLine, sanitizeSearchPath,
|
|
222
|
+
};
|
|
@@ -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.
|
|
@@ -58,6 +58,19 @@ test('runGrepAnswer: passes grep subcommand, pattern and path as argv', () => {
|
|
|
58
58
|
assert.match(r.text, /args=\["grep","fts5_search","src\/storage\/"\]/);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
test('runGrepAnswer: child env carries CODE_GRAPH_INTERNAL=1 (not a funnel conversion)', () => {
|
|
62
|
+
// Stub variant that echoes the marker back in its output.
|
|
63
|
+
const envStub = path.join(stubDir, 'cg-env-stub.js');
|
|
64
|
+
fs.writeFileSync(envStub, `#!/usr/bin/env node
|
|
65
|
+
process.stdout.write('internal=' + (process.env.CODE_GRAPH_INTERNAL || '') + '\\n');
|
|
66
|
+
`);
|
|
67
|
+
fs.chmodSync(envStub, 0o755);
|
|
68
|
+
const r = runGrepAnswer({ cwd: stubDir, pattern: 'whatever', binary: envStub });
|
|
69
|
+
assert.equal(r.status, 'hits');
|
|
70
|
+
assert.match(r.text, /internal=1/,
|
|
71
|
+
'hook-internal CLI runs must be marked so record_cli_use skips them');
|
|
72
|
+
});
|
|
73
|
+
|
|
61
74
|
test('runGrepAnswer: omits path argv when no searchPath', () => {
|
|
62
75
|
const r = runGrepAnswer({ cwd: stubDir, pattern: 'fts5_search', binary: stubBinary() });
|
|
63
76
|
assert.match(r.text, /args=\["grep","fts5_search"\]/);
|
|
@@ -133,3 +146,61 @@ test('truncateAtLine: single oversized line → hard cut', () => {
|
|
|
133
146
|
assert.equal(truncated, true);
|
|
134
147
|
assert.equal(Buffer.byteLength(text, 'utf8'), 10);
|
|
135
148
|
});
|
|
149
|
+
|
|
150
|
+
// ── v0.48 sanitizeSearchPath: glob args reach rg literally (no shell) ──
|
|
151
|
+
|
|
152
|
+
test('sanitizeSearchPath: truncates at first glob segment (daagu denied command)', () => {
|
|
153
|
+
const { sanitizeSearchPath } = require('./cg-answer');
|
|
154
|
+
assert.equal(
|
|
155
|
+
sanitizeSearchPath('backend/app/services/llm_engine/*.py'),
|
|
156
|
+
'backend/app/services/llm_engine');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('sanitizeSearchPath: clean path unchanged; leading glob drops scope; falsy → undefined', () => {
|
|
160
|
+
const { sanitizeSearchPath } = require('./cg-answer');
|
|
161
|
+
assert.equal(sanitizeSearchPath('src/storage/'), 'src/storage/');
|
|
162
|
+
assert.equal(sanitizeSearchPath('*.py'), undefined);
|
|
163
|
+
assert.equal(sanitizeSearchPath('src/**/x.rs'), 'src');
|
|
164
|
+
assert.equal(sanitizeSearchPath('src/file[1].rs'), 'src');
|
|
165
|
+
assert.equal(sanitizeSearchPath(''), undefined);
|
|
166
|
+
assert.equal(sanitizeSearchPath(undefined), undefined);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('runGrepAnswer: glob searchPath is truncated before spawn (defensive layer)', () => {
|
|
170
|
+
const r = runGrepAnswer({
|
|
171
|
+
cwd: stubDir, pattern: 'fts5_search', searchPath: 'src/storage/*.rs', binary: stubBinary(),
|
|
172
|
+
});
|
|
173
|
+
assert.equal(r.status, 'hits');
|
|
174
|
+
assert.match(r.text, /args=\["grep","fts5_search","src\/storage"\]/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ── runShowAnswer (v0.49) — show-mode deny bodies ────────────────────
|
|
178
|
+
|
|
179
|
+
test('runShowAnswer: concatenates per-symbol show output with $ headers', () => {
|
|
180
|
+
const r = runShowAnswer({ cwd: stubDir, symbols: ['alpha_one', 'beta_two'], binary: stubBinary() });
|
|
181
|
+
assert.equal(r.status, 'hits');
|
|
182
|
+
assert.match(r.text, /\$ code-graph-mcp show alpha_one/);
|
|
183
|
+
assert.match(r.text, /\$ code-graph-mcp show beta_two/);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('runShowAnswer: skips non-identifier symbols, all-skipped → unavailable-safe no-hits', () => {
|
|
187
|
+
const r = runShowAnswer({ cwd: stubDir, symbols: ['$(rm -rf)', 'a|b'], binary: stubBinary() });
|
|
188
|
+
assert.equal(r.status, 'no-hits');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('runShowAnswer: caps at 3 symbols', () => {
|
|
192
|
+
const r = runShowAnswer({
|
|
193
|
+
cwd: stubDir, symbols: ['s_one', 's_two', 's_three', 's_four'], binary: stubBinary(),
|
|
194
|
+
});
|
|
195
|
+
assert.equal(r.status, 'hits');
|
|
196
|
+
assert.doesNotMatch(r.text, /show s_four/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('runShowAnswer: empty symbol list → unavailable', () => {
|
|
200
|
+
assert.equal(runShowAnswer({ cwd: stubDir, symbols: [], binary: stubBinary() }).status, 'unavailable');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('runShowAnswer: failing binary → no-hits (caller falls back to grep answer)', () => {
|
|
204
|
+
const r = runShowAnswer({ cwd: stubDir, symbols: ['ExplodePlease'], binary: stubBinary() });
|
|
205
|
+
assert.equal(r.status, 'no-hits');
|
|
206
|
+
});
|
|
@@ -11,10 +11,14 @@ const fs = require('fs');
|
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { findBinary } = require('./find-binary');
|
|
13
13
|
const { cgTmpDir } = require('./tmp-dir');
|
|
14
|
+
const { resolveProjectRoot } = require('./project-root');
|
|
15
|
+
const { recordRecommendation } = require('./recommendation-log');
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
// v0.49 — walk up from the shell cwd (subdir-cwd fix). The per-cwd index.db
|
|
18
|
+
// gate kept this hook dark for entire sessions after `cd backend/` — daagu
|
|
19
|
+
// 2026-06-12: 115 edits, zero impact injections.
|
|
20
|
+
const cwd = resolveProjectRoot(process.cwd());
|
|
21
|
+
if (cwd === null) process.exit(0);
|
|
18
22
|
|
|
19
23
|
// Resolve binary the same way the other hooks do — bare PATH lookup misses
|
|
20
24
|
// npm-global installs on systems where the global bin dir isn't on PATH for
|
|
@@ -22,10 +26,15 @@ if (!fs.existsSync(dbPath)) process.exit(0);
|
|
|
22
26
|
const binary = findBinary();
|
|
23
27
|
if (!binary) process.exit(0);
|
|
24
28
|
|
|
29
|
+
// Hook-internal CLI runs are deliveries, not model-initiated conversions —
|
|
30
|
+
// the marker keeps them out of the recommendations.jsonl `use` funnel leg.
|
|
31
|
+
const internalEnv = { ...process.env, CODE_GRAPH_INTERNAL: '1' };
|
|
32
|
+
|
|
25
33
|
// --- Parse tool input ---
|
|
26
34
|
let input;
|
|
27
35
|
try {
|
|
28
|
-
|
|
36
|
+
// fd 0, not '/dev/stdin': the path form fails ENXIO on socketpair stdin.
|
|
37
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
29
38
|
} catch { process.exit(0); }
|
|
30
39
|
|
|
31
40
|
const oldStr = (input.tool_input && input.tool_input.old_string) || '';
|
|
@@ -72,6 +81,7 @@ if (!symbol || symbol.length < 3) {
|
|
|
72
81
|
try {
|
|
73
82
|
const raw = execFileSync(binary, ['grep', candidate, filePath, '--json'], {
|
|
74
83
|
cwd, timeout: 2000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
84
|
+
env: internalEnv,
|
|
75
85
|
});
|
|
76
86
|
const grepResult = JSON.parse(raw);
|
|
77
87
|
// Pick this candidate if it has few matches (precise location)
|
|
@@ -122,11 +132,14 @@ let jsonResult;
|
|
|
122
132
|
try {
|
|
123
133
|
const args = ['impact', symbol, '--json'];
|
|
124
134
|
if (relFile && !relFile.startsWith('..')) args.push('--file', relFile);
|
|
125
|
-
|
|
135
|
+
// v0.49 — use the resolved binary (bare 'code-graph-mcp' was PATH-dependent,
|
|
136
|
+
// diverging from the findBinary() result the rest of the hook trusts).
|
|
137
|
+
const raw = execFileSync(binary, args, {
|
|
126
138
|
cwd,
|
|
127
139
|
timeout: 2500,
|
|
128
140
|
encoding: 'utf8',
|
|
129
141
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
142
|
+
env: internalEnv,
|
|
130
143
|
});
|
|
131
144
|
jsonResult = JSON.parse(raw);
|
|
132
145
|
} catch {
|
|
@@ -154,6 +167,9 @@ if (directCallers < 1) process.exit(0);
|
|
|
154
167
|
// Mark cooldown
|
|
155
168
|
try { fs.writeFileSync(cooldownFile, ''); } catch { /* ok */ }
|
|
156
169
|
|
|
170
|
+
// Funnel visibility (v0.49): an injected impact summary is a delivered answer.
|
|
171
|
+
recordRecommendation(cwd, { hook: 'edit', action: 'hint', answered: true });
|
|
172
|
+
|
|
157
173
|
// --- Inject compact impact summary ---
|
|
158
174
|
const routeCount = jsonResult.affected_routes || 0;
|
|
159
175
|
const testCount = jsonResult.tests_affected || 0;
|