@sdsrs/code-graph 0.48.0 → 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 +95 -1
- package/claude-plugin/scripts/cg-answer.test.js +45 -1
- package/claude-plugin/scripts/pre-edit-guide.js +21 -5
- package/claude-plugin/scripts/pre-grep-guide.js +163 -54
- package/claude-plugin/scripts/pre-grep-guide.test.js +95 -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/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':
|
|
@@ -110,6 +110,9 @@ 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
117
|
if (res.error || res.signal || res.status !== 0) {
|
|
115
118
|
return { status: 'unavailable' };
|
|
@@ -125,4 +128,95 @@ function runGrepAnswer(opts = {}) {
|
|
|
125
128
|
}
|
|
126
129
|
}
|
|
127
130
|
|
|
128
|
-
|
|
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"\]/);
|
|
@@ -160,3 +173,34 @@ test('runGrepAnswer: glob searchPath is truncated before spawn (defensive layer)
|
|
|
160
173
|
assert.equal(r.status, 'hits');
|
|
161
174
|
assert.match(r.text, /args=\["grep","fts5_search","src\/storage"\]/);
|
|
162
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;
|
|
@@ -18,12 +18,15 @@
|
|
|
18
18
|
// 2026-06-11 replay: 38/40 head-greps dark to this)
|
|
19
19
|
// 6. Same command-hash not hinted within last 60s (per-command cooldown)
|
|
20
20
|
//
|
|
21
|
-
// BLOCK fires when shouldHint AND (
|
|
22
|
-
// 7.
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// 9.
|
|
21
|
+
// BLOCK fires when shouldHint AND (classifyBlock, v0.49 intent-aware):
|
|
22
|
+
// 7. Pattern looks identifier-like (CamelCase ≥4ch, or snake_case with _, or
|
|
23
|
+
// a declaration anchor like `fn X` / `class X` / `def X`), quoted
|
|
24
|
+
// 8. Pattern is not a bare marker word (TODO/FIXME/XXX/HACK/WARN/ERROR/NOTE)
|
|
25
|
+
// 9. No unanswerable-intent flag (-L / -v / --exclude*) — those stay hint
|
|
26
26
|
// 10. CODE_GRAPH_NO_BLOCK_GREP != "1" (block escape, independent of QUIET_HOOKS)
|
|
27
|
+
// Mode: context flags (-A/-B/-C) + declaration anchors → 'show' (deny carries
|
|
28
|
+
// the symbol BODIES via `cg show`); context flags without named decls → hint;
|
|
29
|
+
// everything else (incl. -l / --include) → 'grep' (deny carries the hits).
|
|
27
30
|
//
|
|
28
31
|
// A `CODE_GRAPH_NO_BLOCK_GREP=1`-prefixed grep that would otherwise hint is
|
|
29
32
|
// recorded as `action:'bypass'` and allowed silently (v0.48) — previously the
|
|
@@ -34,12 +37,11 @@
|
|
|
34
37
|
// lookups, or the rare legitimate use of raw grep on indexed source.
|
|
35
38
|
|
|
36
39
|
const fs = require('fs');
|
|
37
|
-
const os = require('os');
|
|
38
40
|
const path = require('path');
|
|
39
41
|
const crypto = require('crypto');
|
|
40
42
|
const { cgTmpDir } = require('./tmp-dir');
|
|
41
43
|
const { recordRecommendation } = require('./recommendation-log');
|
|
42
|
-
const { runGrepAnswer, sanitizeSearchPath } = require('./cg-answer');
|
|
44
|
+
const { runGrepAnswer, runShowAnswer, sanitizeSearchPath } = require('./cg-answer');
|
|
43
45
|
|
|
44
46
|
// --- Pure logic (testable) ---
|
|
45
47
|
|
|
@@ -83,17 +85,21 @@ function shouldHint(cmd) {
|
|
|
83
85
|
return true;
|
|
84
86
|
}
|
|
85
87
|
|
|
86
|
-
// v0.
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
88
|
+
// v0.49 intent-aware block tiers. The v0.32 rationale ("precision flags mean
|
|
89
|
+
// the user is filtering — a blanket *suggestion* would be wrong") was written
|
|
90
|
+
// for the suggestion era; in the answer era the deny CARRIES the result, so a
|
|
91
|
+
// flag only disqualifies when the answer cannot honor its intent. 2026-06-12
|
|
92
|
+
// daagu replay: 22/128 head-greps were `rg "def X|class Y" -A 25` — function-
|
|
93
|
+
// body reads the old rule exempted to (ignored) hints; `cg show` answers them.
|
|
94
|
+
//
|
|
95
|
+
// Context flags (-A/-B/-C): intent = read surrounding code. Answerable via
|
|
96
|
+
// `show` only when the pattern names declarations; bare-identifier + context
|
|
97
|
+
// stays hint (a grep-style answer can't honor ±N lines).
|
|
98
|
+
const CONTEXT_FLAG =
|
|
99
|
+
/(?:^|\s)-[a-zA-Z]*[ABC][a-zA-Z]*(?:\s|=|\d|$)|--(?:after-context|before-context|context)\b/;
|
|
100
|
+
// Intents the cg answer cannot honor: inverted file lists, exclusion scoping.
|
|
101
|
+
const UNANSWERABLE_FLAGS =
|
|
102
|
+
/(?:^|\s)-[a-zA-Z]*[Lv][a-zA-Z]*(?:\s|=|\d|$)|--(?:files-without-match|invert-match|exclude|exclude-dir)\b/;
|
|
97
103
|
// v0.32.1: drop the `type` declaration keyword (too common in English prose
|
|
98
104
|
// like "# type checking") and anchor declaration anchors to pattern start
|
|
99
105
|
// (otherwise `"some type X"` matches). CamelCase and snake_case still match
|
|
@@ -120,13 +126,41 @@ function extractPatterns(cmd) {
|
|
|
120
126
|
return matches.map(m => m[1] !== undefined ? m[1] : m[2]);
|
|
121
127
|
}
|
|
122
128
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
// Declaration anchors inside a pattern (`def cascade_failure|class TaskState`)
|
|
130
|
+
// name the exact symbols the model wants to READ — extract them for `show`.
|
|
131
|
+
const DECL_SYMBOL = /(?:fn|def|class|function|struct|impl|trait)\s+([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
132
|
+
|
|
133
|
+
function extractDeclSymbols(patterns) {
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const p of patterns) {
|
|
136
|
+
for (const m of p.matchAll(DECL_SYMBOL)) {
|
|
137
|
+
if (!out.includes(m[1])) out.push(m[1]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// Block-tier classification (strictly narrower than shouldHint):
|
|
144
|
+
/// {mode:'show', symbols} — declaration anchors + context flags → deliver bodies
|
|
145
|
+
/// {mode:'grep'} — symbol search (incl. -l / --include) → deliver hits
|
|
146
|
+
/// null — hint tier (marker scans, unquoted, unanswerable flags)
|
|
147
|
+
function classifyBlock(cmd) {
|
|
148
|
+
if (!shouldHint(cmd)) return null; // narrower than hint
|
|
149
|
+
if (UNANSWERABLE_FLAGS.test(cmd)) return null; // intent the answer can't honor
|
|
150
|
+
if (MARKER_ONLY.test(cmd)) return null; // bare TODO/FIXME — no cg equivalent
|
|
127
151
|
const patterns = extractPatterns(cmd);
|
|
128
|
-
if (patterns.length === 0) return
|
|
129
|
-
|
|
152
|
+
if (patterns.length === 0) return null; // unquoted pattern — conservative, hint
|
|
153
|
+
if (!patterns.some(p => IDENTIFIER_LIKE.test(p))) return null;
|
|
154
|
+
if (CONTEXT_FLAG.test(cmd)) {
|
|
155
|
+
const symbols = extractDeclSymbols(patterns);
|
|
156
|
+
if (symbols.length === 0) return null; // context read without named decls
|
|
157
|
+
return { mode: 'show', symbols: symbols.slice(0, 3) };
|
|
158
|
+
}
|
|
159
|
+
return { mode: 'grep' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function shouldBlock(cmd) {
|
|
163
|
+
return classifyBlock(cmd) !== null;
|
|
130
164
|
}
|
|
131
165
|
|
|
132
166
|
// v0.47.1 — CC harness steers Bash toward ABSOLUTE paths (cd in compound
|
|
@@ -143,23 +177,9 @@ function normalizeCommandPaths(cmd, cwd) {
|
|
|
143
177
|
return cmd.split(cwd.endsWith('/') ? cwd : cwd + '/').join('');
|
|
144
178
|
}
|
|
145
179
|
|
|
146
|
-
// v0.48 —
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
// 2026-06-11: 38/40 head-greps dark). Walk up to the nearest ancestor holding
|
|
150
|
-
// `.code-graph/index.db`; stop at $HOME (checked, not crossed) and fs root.
|
|
151
|
-
function resolveProjectRoot(startDir, opts = {}) {
|
|
152
|
-
const home = opts.home !== undefined ? opts.home : os.homedir();
|
|
153
|
-
const exists = opts.exists || fs.existsSync;
|
|
154
|
-
let dir = path.resolve(startDir || '.');
|
|
155
|
-
for (;;) {
|
|
156
|
-
if (exists(path.join(dir, '.code-graph', 'index.db'))) return dir;
|
|
157
|
-
if (dir === home) return null;
|
|
158
|
-
const parent = path.dirname(dir);
|
|
159
|
-
if (parent === dir) return null;
|
|
160
|
-
dir = parent;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
180
|
+
// v0.48 — subdir-cwd fix; v0.49 — extracted to project-root.js so the read
|
|
181
|
+
// hook shares it. Re-exported below for test/back-compat.
|
|
182
|
+
const { resolveProjectRoot } = require('./project-root');
|
|
163
183
|
|
|
164
184
|
// v0.48 — companion to resolveProjectRoot: when the shell sits in a subdir,
|
|
165
185
|
// bare relative path args (`app --include=*.py` from backend/) are
|
|
@@ -199,13 +219,29 @@ function rebaseRelativePaths(cmd, relPrefix, rootDir, exists = fs.existsSync) {
|
|
|
199
219
|
}).join('');
|
|
200
220
|
}
|
|
201
221
|
|
|
202
|
-
// v0.48 — bypass detection on the RAW command. The deny
|
|
203
|
-
//
|
|
204
|
-
//
|
|
222
|
+
// v0.48 — bypass detection on the RAW command. (The deny copy stopped teaching
|
|
223
|
+
// the escape in v0.49, but models that already know it — or learned it from a
|
|
224
|
+
// session summary — must stay visible to the funnel.)
|
|
205
225
|
function commandHasBypass(cmd) {
|
|
206
226
|
return typeof cmd === 'string' && /(?:^|\s)CODE_GRAPH_NO_BLOCK_GREP=1(?:\s|$)/.test(cmd);
|
|
207
227
|
}
|
|
208
228
|
|
|
229
|
+
// v0.49 — `sed -n X,Yp file.py` is a Read the Read hook can't see; the
|
|
230
|
+
// 2026-06-12 night used it heavily for structure exploration (four sed-range
|
|
231
|
+
// reads of stock_picker/ in 3 min). Extract targets so they count toward the
|
|
232
|
+
// shared read-fanout state.
|
|
233
|
+
const SED_RANGE = /(?:^|[|;&]\s*)sed\s+-n\s+(?:['"]\d+,\d+p['"]|\d+,\d+p)\s+("[^"]+"|'[^']+'|[^\s;|&]+)/g;
|
|
234
|
+
|
|
235
|
+
function extractSedReadTargets(cmd) {
|
|
236
|
+
if (!cmd || typeof cmd !== 'string' || cmd.length > 2000) return [];
|
|
237
|
+
const out = [];
|
|
238
|
+
for (const m of cmd.matchAll(SED_RANGE)) {
|
|
239
|
+
const tok = m[1].replace(/^["']|["']$/g, '');
|
|
240
|
+
if (tok && !out.includes(tok)) out.push(tok);
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
209
245
|
// v0.47.0 — pull the first source-tree path token out of the denied command so
|
|
210
246
|
// the inline answer can scope its search the same way the raw grep would have.
|
|
211
247
|
function extractSearchPath(cmd) {
|
|
@@ -257,14 +293,16 @@ function buildHint() {
|
|
|
257
293
|
function buildBlockReason() {
|
|
258
294
|
// Shown to Claude via PreToolUse `decision: block` reason. Must give a
|
|
259
295
|
// concrete alternate command Claude can re-issue without further thinking.
|
|
296
|
+
// v0.49 — NO escape-hatch line anywhere in deny copy: the daagu 2026-06-12
|
|
297
|
+
// night proved even the "THIS command only" scoping reads as a teachable
|
|
298
|
+
// permanent prefix (adopted in 8s, reused 11×, incl. on the exact identifier
|
|
299
|
+
// searches this hook targets). The env opt-out stays documented in README.
|
|
260
300
|
return [
|
|
261
301
|
'[code-graph] Raw `grep -rn` on indexed source — denied by code-graph hook.',
|
|
262
302
|
'Use the AST-aware equivalent (returns containing fn/module per hit, repo-wide):',
|
|
263
303
|
' code-graph-mcp grep "<pattern>" <path> # FTS + AST context per hit',
|
|
264
304
|
' code-graph-mcp ast-search "<pattern>" --type fn # filter by node type',
|
|
265
305
|
' code-graph-mcp callgraph SYMBOL # callers + callees',
|
|
266
|
-
'If this specific search truly needs raw-text regex (log/comment scan), prepend',
|
|
267
|
-
'`CODE_GRAPH_NO_BLOCK_GREP=1` to THIS command only — a per-command escape, not a default prefix.',
|
|
268
306
|
].join('\n');
|
|
269
307
|
}
|
|
270
308
|
|
|
@@ -291,6 +329,37 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
|
|
|
291
329
|
return lines.join('\n');
|
|
292
330
|
}
|
|
293
331
|
|
|
332
|
+
// v0.49 — show-mode deny: the model grepped for symbol DEFINITIONS with
|
|
333
|
+
// context flags (-A/-B/-C = "show me the body"); the answer IS the bodies,
|
|
334
|
+
// fetched via `code-graph-mcp show`. answer.text already carries per-symbol
|
|
335
|
+
// `$ code-graph-mcp show <sym>` headers.
|
|
336
|
+
function buildShowDenyReason(answer) {
|
|
337
|
+
const lines = [
|
|
338
|
+
'[code-graph] Raw grep for symbol definitions — denied; here are the definitions from the AST index:',
|
|
339
|
+
answer.text,
|
|
340
|
+
];
|
|
341
|
+
if (answer.truncated) {
|
|
342
|
+
lines.push('(truncated — re-run the `code-graph-mcp show <symbol>` command above for full source)');
|
|
343
|
+
}
|
|
344
|
+
lines.push('Use these directly instead of re-running the search.');
|
|
345
|
+
return lines.join('\n');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// v0.49 — plain `grep` speaks BRE: alternation/grouping arrive escaped
|
|
349
|
+
// (`a\|b`, `\(x\)`) and 0-hit against cg grep's rust-regex dialect, wasting
|
|
350
|
+
// the answer on the ALLOW fallthrough (2026-06-12: both answered:false denies
|
|
351
|
+
// were dialect/path-shape misses). Unescape for plain grep only — rg/ag and
|
|
352
|
+
// grep -E/-P are already extended.
|
|
353
|
+
function translateBreToRg(cmd, pattern) {
|
|
354
|
+
if (typeof pattern !== 'string' || !pattern) return pattern;
|
|
355
|
+
const verb = (cmd.match(GREP_HEAD) || [])[1];
|
|
356
|
+
if (verb !== 'grep') return pattern;
|
|
357
|
+
if (/(?:^|\s)-[a-zA-Z]*[EP][a-zA-Z]*(?:\s|=|\d|$)|--(?:extended-regexp|perl-regexp)\b/.test(cmd)) {
|
|
358
|
+
return pattern;
|
|
359
|
+
}
|
|
360
|
+
return pattern.replace(/\\([|(){}+?])/g, '$1');
|
|
361
|
+
}
|
|
362
|
+
|
|
294
363
|
// v0.47.0 — cg grep found nothing. Regex-dialect differences (BRE `\|` vs
|
|
295
364
|
// ripgrep) mean 0 hits is NOT proof of absence, so denying here could mislead.
|
|
296
365
|
// Let the raw grep through with an honest one-liner.
|
|
@@ -338,6 +407,24 @@ function runMain() {
|
|
|
338
407
|
} catch { return; }
|
|
339
408
|
|
|
340
409
|
const rawCmd = (input.tool_input && input.tool_input.command) || '';
|
|
410
|
+
|
|
411
|
+
// v0.49 — sed-range reads count toward the read-fanout state (the Read hook
|
|
412
|
+
// never sees Bash-side file reads). A fired fanout hint already delivered an
|
|
413
|
+
// overview — skip grep hinting for this command to avoid double output.
|
|
414
|
+
const sedTargets = extractSedReadTargets(rawCmd);
|
|
415
|
+
if (sedTargets.length > 0) {
|
|
416
|
+
const readGuide = require('./pre-read-guide');
|
|
417
|
+
let fanoutFired = false;
|
|
418
|
+
for (const t of sedTargets) {
|
|
419
|
+
if (!readGuide.isSourceFile(t)) continue;
|
|
420
|
+
const abs = path.isAbsolute(t) ? t : path.resolve(shellCwd, t);
|
|
421
|
+
if (readGuide.trackReadAndMaybeHint(root, path.relative(root, abs))) {
|
|
422
|
+
fanoutFired = true;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (fanoutFired) return;
|
|
426
|
+
}
|
|
427
|
+
|
|
341
428
|
// v0.47.1 — match against the root-stripped form so absolute paths under the
|
|
342
429
|
// project root behave exactly like their relative spelling. v0.48 — then
|
|
343
430
|
// rebase bare subdir-relative tokens onto the root. Cooldown stays keyed on
|
|
@@ -358,18 +445,30 @@ function runMain() {
|
|
|
358
445
|
|
|
359
446
|
markCooldown(rawCmd);
|
|
360
447
|
|
|
361
|
-
|
|
448
|
+
const block = isBlockDisabled() ? null : classifyBlock(cmd);
|
|
449
|
+
if (block) {
|
|
362
450
|
// v0.47.0 — run the AST-aware equivalent inside the hook and embed the
|
|
363
451
|
// results in the deny reason ("answer in the deny"). Degrades to the
|
|
364
452
|
// v0.46 static deny on any failure; downgrades to allow+FYI on 0 hits
|
|
365
453
|
// (regex-dialect differences mean 0 hits ≠ proof of absence).
|
|
454
|
+
// v0.49 — intent-aware: declaration+context greps get `show` bodies,
|
|
455
|
+
// falling back to the grep answer, then the static deny.
|
|
366
456
|
let answer = { status: 'unavailable' };
|
|
367
|
-
const pattern = pickBlockPattern(cmd);
|
|
457
|
+
const pattern = translateBreToRg(cmd, pickBlockPattern(cmd));
|
|
368
458
|
// v0.48 — glob-truncated once, shared by the run and the deny message
|
|
369
459
|
// (a literal `dir/*.py` argv made rg exit 1 → answered:false static deny).
|
|
370
460
|
const searchPath = sanitizeSearchPath(extractSearchPath(cmd));
|
|
371
|
-
|
|
372
|
-
|
|
461
|
+
let answeredMode = block.mode;
|
|
462
|
+
if (!isAnswerDisabled()) {
|
|
463
|
+
if (block.mode === 'show') {
|
|
464
|
+
answer = runShowAnswer({ cwd: root, symbols: block.symbols });
|
|
465
|
+
if (answer.status !== 'hits' && pattern) {
|
|
466
|
+
answeredMode = 'grep';
|
|
467
|
+
answer = runGrepAnswer({ cwd: root, pattern, searchPath });
|
|
468
|
+
}
|
|
469
|
+
} else if (pattern) {
|
|
470
|
+
answer = runGrepAnswer({ cwd: root, pattern, searchPath });
|
|
471
|
+
}
|
|
373
472
|
}
|
|
374
473
|
|
|
375
474
|
if (answer.status === 'no-hits') {
|
|
@@ -383,16 +482,21 @@ function runMain() {
|
|
|
383
482
|
// ignored by Claude Code — the grep ran anyway. The hookSpecificOutput form
|
|
384
483
|
// is the documented modern path. Exit 0 — this is a routing decision, not
|
|
385
484
|
// a hook failure (exit 2 would mark the tool call as "hook errored").
|
|
485
|
+
const answered = answer.status === 'hits';
|
|
386
486
|
recordRecommendation(root, {
|
|
387
|
-
hook: 'grep', action: 'deny', answered
|
|
487
|
+
hook: 'grep', action: 'deny', answered,
|
|
488
|
+
// mode segments which answer type converts (show=bodies, grep=hits).
|
|
489
|
+
...(answered ? { mode: answeredMode } : {}),
|
|
388
490
|
});
|
|
389
491
|
process.stdout.write(JSON.stringify({
|
|
390
492
|
hookSpecificOutput: {
|
|
391
493
|
hookEventName: 'PreToolUse',
|
|
392
494
|
permissionDecision: 'deny',
|
|
393
|
-
permissionDecisionReason:
|
|
394
|
-
?
|
|
395
|
-
:
|
|
495
|
+
permissionDecisionReason: !answered
|
|
496
|
+
? buildBlockReason()
|
|
497
|
+
: answeredMode === 'show'
|
|
498
|
+
? buildShowDenyReason(answer)
|
|
499
|
+
: buildBlockReasonWithAnswer(pattern, searchPath, answer),
|
|
396
500
|
},
|
|
397
501
|
}) + '\n');
|
|
398
502
|
return;
|
|
@@ -409,6 +513,11 @@ if (require.main === module) {
|
|
|
409
513
|
module.exports = {
|
|
410
514
|
shouldHint,
|
|
411
515
|
shouldBlock,
|
|
516
|
+
classifyBlock, // v0.49 — intent-aware block tiers
|
|
517
|
+
extractDeclSymbols, // v0.49 — show-mode symbol extraction
|
|
518
|
+
translateBreToRg, // v0.49 — BRE→rust-regex dialect bridge
|
|
519
|
+
buildShowDenyReason, // v0.49 — show-mode deny copy
|
|
520
|
+
extractSedReadTargets, // v0.49 — sed-range reads feed the read-fanout state
|
|
412
521
|
extractPatterns, // v0.32.1 — exposed for tests
|
|
413
522
|
extractSearchPath, // v0.47.0 — deny-with-answer
|
|
414
523
|
normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
|
|
@@ -4,6 +4,11 @@ const assert = require('node:assert/strict');
|
|
|
4
4
|
const {
|
|
5
5
|
shouldHint,
|
|
6
6
|
shouldBlock,
|
|
7
|
+
classifyBlock,
|
|
8
|
+
extractDeclSymbols,
|
|
9
|
+
translateBreToRg,
|
|
10
|
+
buildShowDenyReason,
|
|
11
|
+
extractSedReadTargets,
|
|
7
12
|
extractPatterns,
|
|
8
13
|
extractSearchPath,
|
|
9
14
|
normalizeCommandPaths,
|
|
@@ -317,30 +322,81 @@ test('shouldBlock: rg with CamelCase on lib/', () => {
|
|
|
317
322
|
|
|
318
323
|
// ── shouldBlock: should NOT block (downgrade to hint) — precision flags ─
|
|
319
324
|
|
|
320
|
-
test('shouldBlock: grep -l (files-with-matches) →
|
|
321
|
-
assert.equal(shouldBlock('grep -rl "EmbeddingModel" src/'),
|
|
325
|
+
test('shouldBlock: grep -l (files-with-matches) → deny, grep answer covers file lists (v0.49)', () => {
|
|
326
|
+
assert.equal(shouldBlock('grep -rl "EmbeddingModel" src/'), true);
|
|
327
|
+
assert.deepEqual(classifyBlock('grep -rl "EmbeddingModel" src/'), { mode: 'grep' });
|
|
322
328
|
});
|
|
323
329
|
|
|
324
|
-
test('shouldBlock: --include=*.rs →
|
|
325
|
-
assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'),
|
|
330
|
+
test('shouldBlock: --include=*.rs → deny, path-scoped grep answer covers it (v0.49)', () => {
|
|
331
|
+
assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'), true);
|
|
326
332
|
});
|
|
327
333
|
|
|
328
|
-
test('shouldBlock: --exclude
|
|
334
|
+
test('shouldBlock: --exclude=tests → hint only (answer cannot honor exclusion)', () => {
|
|
329
335
|
assert.equal(shouldBlock('grep -rn --exclude=tests "EmbeddingModel" src/'), false);
|
|
330
336
|
});
|
|
331
337
|
|
|
332
|
-
test('shouldBlock: -
|
|
338
|
+
test('shouldBlock: -L / -v inverted intents → hint only', () => {
|
|
339
|
+
assert.equal(shouldBlock('grep -rL "EmbeddingModel" src/'), false);
|
|
340
|
+
assert.equal(shouldBlock('grep -rnv "EmbeddingModel" src/'), false);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('shouldBlock: -A 3 with bare identifier → hint only (cannot honor ±N lines)', () => {
|
|
333
344
|
assert.equal(shouldBlock('grep -rn -A 3 "EmbeddingModel" src/'), false);
|
|
334
345
|
});
|
|
335
346
|
|
|
336
|
-
test('shouldBlock: -B 2
|
|
347
|
+
test('shouldBlock: -B 2 with bare identifier → hint only', () => {
|
|
337
348
|
assert.equal(shouldBlock('grep -rn -B 2 "EmbeddingModel" src/'), false);
|
|
338
349
|
});
|
|
339
350
|
|
|
340
|
-
test('shouldBlock: -C 5
|
|
351
|
+
test('shouldBlock: -C 5 with bare identifier → hint only', () => {
|
|
341
352
|
assert.equal(shouldBlock('grep -rn -C 5 "EmbeddingModel" src/'), false);
|
|
342
353
|
});
|
|
343
354
|
|
|
355
|
+
// ── translateBreToRg (v0.49) — BRE→rust-regex dialect bridge ─────────
|
|
356
|
+
|
|
357
|
+
test('translateBreToRg: plain grep BRE alternation unescaped', () => {
|
|
358
|
+
assert.equal(
|
|
359
|
+
translateBreToRg('grep -rn "UnifiedPickerEngine\\|engine.run" src/', 'UnifiedPickerEngine\\|engine.run'),
|
|
360
|
+
'UnifiedPickerEngine|engine.run');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('translateBreToRg: rg patterns untouched (already extended)', () => {
|
|
364
|
+
assert.equal(translateBreToRg('rg "a\\|b" src/', 'a\\|b'), 'a\\|b');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test('translateBreToRg: grep -E untouched', () => {
|
|
368
|
+
assert.equal(translateBreToRg('grep -rnE "a\\|b" src/', 'a\\|b'), 'a\\|b');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('translateBreToRg: unescapes groups/braces/quantifiers for plain grep', () => {
|
|
372
|
+
assert.equal(translateBreToRg('grep "fn \\(x\\)\\+" src/', 'fn \\(x\\)\\+'), 'fn (x)+');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// ── classifyBlock: show mode (v0.49) — the daagu 22/128 function-body reads ──
|
|
376
|
+
|
|
377
|
+
test('classifyBlock: declaration anchor + -A → show mode with symbols', () => {
|
|
378
|
+
assert.deepEqual(
|
|
379
|
+
classifyBlock('rg -n "def cascade_failure|def reset_task" -A 25 backend/app/'),
|
|
380
|
+
{ mode: 'show', symbols: ['cascade_failure', 'reset_task'] });
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('classifyBlock: multi-decl alternation caps at 3 symbols', () => {
|
|
384
|
+
const c = classifyBlock('rg -n "def a_one|def b_two|class CThree|fn d_four" -A 10 src/');
|
|
385
|
+
assert.equal(c.mode, 'show');
|
|
386
|
+
assert.deepEqual(c.symbols, ['a_one', 'b_two', 'CThree']);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('classifyBlock: declaration anchor WITHOUT context flag → plain grep deny', () => {
|
|
390
|
+
assert.deepEqual(classifyBlock('grep -rn "def fetch_user" backend/app/services/'),
|
|
391
|
+
{ mode: 'grep' });
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('extractDeclSymbols: dedupes and spans fn/def/class/struct anchors', () => {
|
|
395
|
+
assert.deepEqual(
|
|
396
|
+
extractDeclSymbols(['fn alpha_one', 'struct BetaTwo', 'fn alpha_one']),
|
|
397
|
+
['alpha_one', 'BetaTwo']);
|
|
398
|
+
});
|
|
399
|
+
|
|
344
400
|
// ── shouldBlock: should NOT block — marker-only patterns ────────────
|
|
345
401
|
|
|
346
402
|
test('shouldBlock: bare TODO marker → hint only (no cg equivalent)', () => {
|
|
@@ -402,8 +458,8 @@ test('buildBlockReason: lists cg grep + ast-search + callgraph', () => {
|
|
|
402
458
|
assert.match(out, /code-graph-mcp callgraph/);
|
|
403
459
|
});
|
|
404
460
|
|
|
405
|
-
test('buildBlockReason: documents the escape hatch
|
|
406
|
-
assert.
|
|
461
|
+
test('buildBlockReason: NEVER documents the escape hatch (v0.49 — the "THIS command only" scoping was adopted as a permanent prefix in 8s on 2026-06-12)', () => {
|
|
462
|
+
assert.doesNotMatch(buildBlockReason(), /CODE_GRAPH_NO_BLOCK_GREP/);
|
|
407
463
|
});
|
|
408
464
|
|
|
409
465
|
test('buildBlockReason: under 700-byte budget (single CC message)', () => {
|
|
@@ -583,11 +639,11 @@ test('replay: real abs-path symbol grep → BLOCK after normalization', () => {
|
|
|
583
639
|
assert.equal(shouldBlock(norm), true);
|
|
584
640
|
});
|
|
585
641
|
|
|
586
|
-
test('replay: real abs-path -rln grep →
|
|
642
|
+
test('replay: real abs-path -rln grep → DENY after normalization (v0.49: file lists answerable)', () => {
|
|
587
643
|
const cmd = `grep -rln "load_active_config_standalone" ${DAAGU}/backend/tests/ | head -5`;
|
|
588
644
|
const norm = normalizeCommandPaths(cmd, DAAGU);
|
|
589
645
|
assert.equal(shouldHint(norm), true);
|
|
590
|
-
assert.
|
|
646
|
+
assert.deepEqual(classifyBlock(norm), { mode: 'grep' }); // grep answer lists files per hit
|
|
591
647
|
});
|
|
592
648
|
|
|
593
649
|
test('replay: abs-path config-only grep stays silent after normalization', () => {
|
|
@@ -671,13 +727,6 @@ test('buildBlockReasonWithAnswer: NEVER advertises the bypass (v0.48 — one den
|
|
|
671
727
|
assert.doesNotMatch(reason, /CODE_GRAPH_NO_BLOCK_GREP/);
|
|
672
728
|
});
|
|
673
729
|
|
|
674
|
-
test('buildBlockReason: scopes the escape to THIS command only', () => {
|
|
675
|
-
const reason = buildBlockReason();
|
|
676
|
-
assert.match(reason, /CODE_GRAPH_NO_BLOCK_GREP=1/);
|
|
677
|
-
assert.match(reason, /THIS command only/);
|
|
678
|
-
assert.match(reason, /not a default/);
|
|
679
|
-
});
|
|
680
|
-
|
|
681
730
|
test('buildBlockReasonWithAnswer: no searchPath → command has no path arg', () => {
|
|
682
731
|
const reason = buildBlockReasonWithAnswer('fts5_search', undefined, {
|
|
683
732
|
status: 'hits', text: 'hit', truncated: false,
|
|
@@ -1040,3 +1089,30 @@ test('rebaseRelativePaths: glob token rebases when its glob-truncated dir exists
|
|
|
1040
1089
|
assert.equal(
|
|
1041
1090
|
sanitizeSearchPath(extractSearchPath(rebased)), 'backend/app/services/llm_engine');
|
|
1042
1091
|
});
|
|
1092
|
+
|
|
1093
|
+
// ── extractSedReadTargets (v0.49) — sed-range reads feed read-fanout ──
|
|
1094
|
+
|
|
1095
|
+
test('extractSedReadTargets: plain and quoted ranges, abs and rel paths', () => {
|
|
1096
|
+
assert.deepEqual(
|
|
1097
|
+
extractSedReadTargets('sed -n 620,700p /abs/proj/backend/app/services/market.py'),
|
|
1098
|
+
['/abs/proj/backend/app/services/market.py']);
|
|
1099
|
+
assert.deepEqual(
|
|
1100
|
+
extractSedReadTargets("sed -n '230,310p' backend/app/services/tushare.py"),
|
|
1101
|
+
['backend/app/services/tushare.py']);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
test('extractSedReadTargets: multiple segments in one command, deduped', () => {
|
|
1105
|
+
const cmd = 'sed -n 60,200p src/a.py; echo ===; sed -n 250,300p src/b.py && sed -n 250,300p src/b.py';
|
|
1106
|
+
assert.deepEqual(extractSedReadTargets(cmd), ['src/a.py', 'src/b.py']);
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
test('extractSedReadTargets: non-range sed (substitution) ignored', () => {
|
|
1110
|
+
assert.deepEqual(extractSedReadTargets("sed -i 's/a/b/' src/a.py"), []);
|
|
1111
|
+
assert.deepEqual(extractSedReadTargets('sed -n /pattern/p src/a.py'), []);
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
test('extractSedReadTargets: pipeline sed after grep still extracted', () => {
|
|
1115
|
+
assert.deepEqual(
|
|
1116
|
+
extractSedReadTargets('grep -n "x" src/a.py | sed -n 1,5p src/b.py'),
|
|
1117
|
+
['src/b.py']);
|
|
1118
|
+
});
|
|
@@ -27,6 +27,8 @@ const path = require('path');
|
|
|
27
27
|
const crypto = require('crypto');
|
|
28
28
|
const { cgTmpDir } = require('./tmp-dir');
|
|
29
29
|
const { recordRecommendation } = require('./recommendation-log');
|
|
30
|
+
const { resolveProjectRoot } = require('./project-root');
|
|
31
|
+
const { runOverviewAnswer } = require('./cg-answer');
|
|
30
32
|
|
|
31
33
|
// --- Configuration ---
|
|
32
34
|
|
|
@@ -119,50 +121,87 @@ function buildHint(dir) {
|
|
|
119
121
|
return `[code-graph] 5+ Reads into ${dir}/ — \`code-graph-mcp overview ${dir}/\` gives symbols+callers in one call (MCP: \`module_overview path=${dir}\`). Skip if you need raw file contents.`;
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
// v0.49 — the hint DELIVERS the overview instead of advising a tool call
|
|
125
|
+
// (advice measured 0/40 transfer on 2026-06-12; delivered answers satisfied
|
|
126
|
+
// 5/5 in place). Falls back to the advice-only line when the CLI is
|
|
127
|
+
// unavailable or the dir has no overview.
|
|
128
|
+
function buildHintWithAnswer(dir, answer) {
|
|
129
|
+
const lines = [
|
|
130
|
+
`[code-graph] 5+ Reads into ${dir}/ — module overview from the AST index (saves the remaining file-by-file reads):`,
|
|
131
|
+
answer.text,
|
|
132
|
+
];
|
|
133
|
+
if (answer.truncated) {
|
|
134
|
+
lines.push(`(truncated — \`code-graph-mcp overview ${dir}/\` for the full map)`);
|
|
135
|
+
}
|
|
136
|
+
return lines.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
122
139
|
function isSilenced(env = process.env) {
|
|
123
140
|
return env.CODE_GRAPH_QUIET_HOOKS === '1';
|
|
124
141
|
}
|
|
125
142
|
|
|
143
|
+
// v0.49 — answer tier opt-out, shared name with the grep hook's deny-answer
|
|
144
|
+
// opt-out: =1 restores advice-only hints (no CLI run inside the hook).
|
|
145
|
+
function isAnswerDisabled(env = process.env) {
|
|
146
|
+
return env.CODE_GRAPH_NO_ANSWER_IN_DENY === '1';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- Shared tracking core (also driven by pre-grep-guide's sed-range path) ---
|
|
150
|
+
|
|
151
|
+
/// Record one read of `rel` (project-root-relative source path) and fire the
|
|
152
|
+
/// fanout hint when the threshold crosses. Emits to stdout + records the
|
|
153
|
+
/// recommendation. Returns true when a hint fired.
|
|
154
|
+
function trackReadAndMaybeHint(root, rel, now = Date.now()) {
|
|
155
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
|
156
|
+
const dir = path.dirname(rel);
|
|
157
|
+
if (!dir || dir === '.' || dir === '') return false; // top-level file: not fanout
|
|
158
|
+
|
|
159
|
+
const state = loadState(root, now);
|
|
160
|
+
recordRead(state, dir, now);
|
|
161
|
+
let fired = false;
|
|
162
|
+
if (shouldHint(state, dir, now)) {
|
|
163
|
+
markHint(state, dir, now);
|
|
164
|
+
fired = true;
|
|
165
|
+
}
|
|
166
|
+
saveState(root, state);
|
|
167
|
+
if (!fired) return false;
|
|
168
|
+
|
|
169
|
+
let answer = { status: 'unavailable' };
|
|
170
|
+
if (!isAnswerDisabled()) {
|
|
171
|
+
answer = runOverviewAnswer({ cwd: root, dir });
|
|
172
|
+
}
|
|
173
|
+
const answered = answer.status === 'hits';
|
|
174
|
+
recordRecommendation(root, { hook: 'read', action: 'hint', answered });
|
|
175
|
+
process.stdout.write((answered ? buildHintWithAnswer(dir, answer) : buildHint(dir)) + '\n');
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
126
179
|
// --- Main execution ---
|
|
127
180
|
|
|
128
181
|
function runMain() {
|
|
129
182
|
if (isSilenced()) return;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
183
|
+
// v0.49 — walk up from the shell cwd (subdir-cwd fix; the read hook had
|
|
184
|
+
// recorded NOTHING in daagu history because sessions sat in backend/).
|
|
185
|
+
const root = resolveProjectRoot(process.cwd());
|
|
186
|
+
if (root === null) return;
|
|
133
187
|
|
|
134
188
|
let input;
|
|
135
189
|
try {
|
|
136
|
-
|
|
190
|
+
// fd 0, not '/dev/stdin': the path form fails ENXIO on socketpair stdin.
|
|
191
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
137
192
|
} catch { return; }
|
|
138
193
|
|
|
139
194
|
const filePath = (input.tool_input && input.tool_input.file_path) || '';
|
|
140
195
|
if (!isSourceFile(filePath)) return;
|
|
141
196
|
|
|
142
|
-
// Normalize to a
|
|
143
|
-
//
|
|
197
|
+
// Normalize to a root-relative path. Read sends absolute paths; files
|
|
198
|
+
// outside the project (other repos, ~/.claude/) stay silent.
|
|
144
199
|
let rel;
|
|
145
200
|
try {
|
|
146
|
-
rel = path.relative(
|
|
201
|
+
rel = path.relative(root, filePath);
|
|
147
202
|
} catch { return; }
|
|
148
|
-
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return;
|
|
149
|
-
|
|
150
|
-
const dir = path.dirname(rel);
|
|
151
|
-
if (!dir || dir === '.' || dir === '') return; // top-level file: not fanout
|
|
152
203
|
|
|
153
|
-
|
|
154
|
-
const state = loadState(cwd, now);
|
|
155
|
-
recordRead(state, dir, now);
|
|
156
|
-
let fired = false;
|
|
157
|
-
if (shouldHint(state, dir, now)) {
|
|
158
|
-
markHint(state, dir, now);
|
|
159
|
-
fired = true;
|
|
160
|
-
}
|
|
161
|
-
saveState(cwd, state);
|
|
162
|
-
if (fired) {
|
|
163
|
-
recordRecommendation(cwd, { hook: 'read', action: 'hint' });
|
|
164
|
-
process.stdout.write(buildHint(dir) + '\n');
|
|
165
|
-
}
|
|
204
|
+
trackReadAndMaybeHint(root, rel);
|
|
166
205
|
}
|
|
167
206
|
|
|
168
207
|
if (require.main === module) {
|
|
@@ -172,6 +211,7 @@ if (require.main === module) {
|
|
|
172
211
|
module.exports = {
|
|
173
212
|
isSourceFile, dirOf, cwdHash, statePath,
|
|
174
213
|
loadState, saveState, recordRead, shouldHint, markHint,
|
|
175
|
-
buildHint, isSilenced,
|
|
214
|
+
buildHint, buildHintWithAnswer, isSilenced, isAnswerDisabled,
|
|
215
|
+
trackReadAndMaybeHint, // v0.49 — shared with pre-grep-guide's sed-range path
|
|
176
216
|
FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS, SRC_EXT,
|
|
177
217
|
};
|
|
@@ -8,7 +8,8 @@ const crypto = require('crypto');
|
|
|
8
8
|
|
|
9
9
|
const {
|
|
10
10
|
isSourceFile, dirOf, recordRead, shouldHint, markHint,
|
|
11
|
-
buildHint, isSilenced,
|
|
11
|
+
buildHint, buildHintWithAnswer, isSilenced, isAnswerDisabled,
|
|
12
|
+
trackReadAndMaybeHint,
|
|
12
13
|
FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS,
|
|
13
14
|
loadState, saveState, statePath,
|
|
14
15
|
} = require('./pre-read-guide');
|
|
@@ -243,3 +244,60 @@ test('flow: 5 reads to same dir → hint, 6th read same dir → no hint (cooldow
|
|
|
243
244
|
recordRead(s, 'src/foo', 1005);
|
|
244
245
|
assert.equal(shouldHint(s, 'src/foo', 1005), false);
|
|
245
246
|
});
|
|
247
|
+
|
|
248
|
+
// ── v0.49: answer-in-hint + shared tracking core ─────────────────────
|
|
249
|
+
|
|
250
|
+
test('buildHintWithAnswer: embeds overview text and truncation pointer', () => {
|
|
251
|
+
const out = buildHintWithAnswer('src/storage', { text: 'Module src/storage\n conn (57 callers)', truncated: true });
|
|
252
|
+
assert.match(out, /^\[code-graph\] 5\+ Reads into src\/storage\//);
|
|
253
|
+
assert.match(out, /conn \(57 callers\)/);
|
|
254
|
+
assert.match(out, /truncated — `code-graph-mcp overview src\/storage\/`/);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('isAnswerDisabled: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → advice-only hints', () => {
|
|
258
|
+
assert.equal(isAnswerDisabled({ CODE_GRAPH_NO_ANSWER_IN_DENY: '1' }), true);
|
|
259
|
+
assert.equal(isAnswerDisabled({}), false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('trackReadAndMaybeHint: fires on 5th read with stubbed overview answer', () => {
|
|
263
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'readfan-track-'));
|
|
264
|
+
// Stub CLI: prints a fake overview (hook resolves binary via _CG_ANSWER_BINARY).
|
|
265
|
+
const stub = path.join(root, 'stub.js');
|
|
266
|
+
fs.writeFileSync(stub, '#!/usr/bin/env node\nprocess.stdout.write("Module overview stub: 3 symbols\\n");');
|
|
267
|
+
fs.chmodSync(stub, 0o755);
|
|
268
|
+
const oldEnv = process.env._CG_ANSWER_BINARY;
|
|
269
|
+
process.env._CG_ANSWER_BINARY = stub;
|
|
270
|
+
// .code-graph present so recordRecommendation appends.
|
|
271
|
+
fs.mkdirSync(path.join(root, '.code-graph'), { recursive: true });
|
|
272
|
+
|
|
273
|
+
const written = [];
|
|
274
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
275
|
+
process.stdout.write = (chunk) => { written.push(String(chunk)); return true; };
|
|
276
|
+
try {
|
|
277
|
+
let fired = false;
|
|
278
|
+
for (let i = 0; i < 5; i++) {
|
|
279
|
+
fired = trackReadAndMaybeHint(root, 'src/storage/file' + i + '.rs');
|
|
280
|
+
}
|
|
281
|
+
assert.equal(fired, true, '5th same-dir read must fire');
|
|
282
|
+
assert.match(written.join(''), /Module overview stub/, 'hint must EMBED the overview answer');
|
|
283
|
+
const recs = fs.readFileSync(path.join(root, '.code-graph', 'recommendations.jsonl'), 'utf8');
|
|
284
|
+
assert.match(recs, /"hook":"read"/);
|
|
285
|
+
assert.match(recs, /"answered":true/);
|
|
286
|
+
} finally {
|
|
287
|
+
process.stdout.write = origWrite;
|
|
288
|
+
if (oldEnv === undefined) delete process.env._CG_ANSWER_BINARY;
|
|
289
|
+
else process.env._CG_ANSWER_BINARY = oldEnv;
|
|
290
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('trackReadAndMaybeHint: top-level and outside-root paths never fire', () => {
|
|
295
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'readfan-skip-'));
|
|
296
|
+
try {
|
|
297
|
+
assert.equal(trackReadAndMaybeHint(root, 'main.rs'), false);
|
|
298
|
+
assert.equal(trackReadAndMaybeHint(root, '../other/file.rs'), false);
|
|
299
|
+
assert.equal(trackReadAndMaybeHint(root, '/abs/file.rs'), false);
|
|
300
|
+
} finally {
|
|
301
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
302
|
+
}
|
|
303
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Shared project-root resolution for the PreToolUse hooks (v0.49 — extracted
|
|
3
|
+
// from pre-grep-guide so pre-read-guide gets the same subdir-cwd fix without a
|
|
4
|
+
// circular require).
|
|
5
|
+
//
|
|
6
|
+
// The hook's process.cwd() follows the PERSISTENT shell, not the project root:
|
|
7
|
+
// after the model runs `cd backend/`, every per-cwd gate (index.db existence,
|
|
8
|
+
// relative-path matching) fails silently for the rest of the session (daagu
|
|
9
|
+
// 2026-06-11: 38/40 head-greps dark; the read hook never recorded AT ALL).
|
|
10
|
+
// Walk up to the nearest ancestor holding `.code-graph/index.db`; stop at
|
|
11
|
+
// $HOME (checked, not crossed) and fs root.
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
function resolveProjectRoot(startDir, opts = {}) {
|
|
18
|
+
const home = opts.home !== undefined ? opts.home : os.homedir();
|
|
19
|
+
const exists = opts.exists || fs.existsSync;
|
|
20
|
+
let dir = path.resolve(startDir || '.');
|
|
21
|
+
for (;;) {
|
|
22
|
+
if (exists(path.join(dir, '.code-graph', 'index.db'))) return dir;
|
|
23
|
+
if (dir === home) return null;
|
|
24
|
+
const parent = path.dirname(dir);
|
|
25
|
+
if (parent === dir) return null;
|
|
26
|
+
dir = parent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { resolveProjectRoot };
|
|
@@ -38,6 +38,11 @@ type: reference
|
|
|
38
38
|
|
|
39
39
|
## 何时调用 MCP/CLI(替代多步 Grep/Read)
|
|
40
40
|
|
|
41
|
+
> **v0.49 起 CLI 优先**:Claude Code 里 MCP 工具是 deferred(首次调用前要
|
|
42
|
+
> ToolSearch 加载),而 Bash 永远在线——真实编程夜(2026-06-12)观测到的全部
|
|
43
|
+
> 转化都是 CLI 调用。结构化查询的最快路径是 Bash 直呼
|
|
44
|
+
> `code-graph-mcp callgraph X / show X / overview <dir> / grep "pat" / impact X`。
|
|
45
|
+
>
|
|
41
46
|
> v0.10.0 起:tools/list 默认只暴露 7 个核心工具;下表"进阶 5"中的工具
|
|
42
47
|
> 已从 tools/list 隐藏以节省 session 启动 tokens。**Claude Code 里请走 CLI
|
|
43
48
|
> 子命令**(MCP schema 不在 list,Claude Code 的 ToolSearch 不会加载,直接
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.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.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.49.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.49.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.49.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.49.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.49.0"
|
|
43
43
|
}
|
|
44
44
|
}
|