@sdsrs/code-graph 0.47.1 → 0.48.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/cg-answer.js +23 -2
- package/claude-plugin/scripts/cg-answer.test.js +27 -0
- package/claude-plugin/scripts/pre-grep-guide.js +118 -21
- package/claude-plugin/scripts/pre-grep-guide.test.js +207 -4
- package/package.json +6 -6
|
@@ -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,8 +99,11 @@ 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,
|
|
@@ -104,4 +125,4 @@ function runGrepAnswer(opts = {}) {
|
|
|
104
125
|
}
|
|
105
126
|
}
|
|
106
127
|
|
|
107
|
-
module.exports = { runGrepAnswer, truncateAtLine };
|
|
128
|
+
module.exports = { runGrepAnswer, truncateAtLine, sanitizeSearchPath };
|
|
@@ -133,3 +133,30 @@ test('truncateAtLine: single oversized line → hard cut', () => {
|
|
|
133
133
|
assert.equal(truncated, true);
|
|
134
134
|
assert.equal(Buffer.byteLength(text, 'utf8'), 10);
|
|
135
135
|
});
|
|
136
|
+
|
|
137
|
+
// ── v0.48 sanitizeSearchPath: glob args reach rg literally (no shell) ──
|
|
138
|
+
|
|
139
|
+
test('sanitizeSearchPath: truncates at first glob segment (daagu denied command)', () => {
|
|
140
|
+
const { sanitizeSearchPath } = require('./cg-answer');
|
|
141
|
+
assert.equal(
|
|
142
|
+
sanitizeSearchPath('backend/app/services/llm_engine/*.py'),
|
|
143
|
+
'backend/app/services/llm_engine');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('sanitizeSearchPath: clean path unchanged; leading glob drops scope; falsy → undefined', () => {
|
|
147
|
+
const { sanitizeSearchPath } = require('./cg-answer');
|
|
148
|
+
assert.equal(sanitizeSearchPath('src/storage/'), 'src/storage/');
|
|
149
|
+
assert.equal(sanitizeSearchPath('*.py'), undefined);
|
|
150
|
+
assert.equal(sanitizeSearchPath('src/**/x.rs'), 'src');
|
|
151
|
+
assert.equal(sanitizeSearchPath('src/file[1].rs'), 'src');
|
|
152
|
+
assert.equal(sanitizeSearchPath(''), undefined);
|
|
153
|
+
assert.equal(sanitizeSearchPath(undefined), undefined);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('runGrepAnswer: glob searchPath is truncated before spawn (defensive layer)', () => {
|
|
157
|
+
const r = runGrepAnswer({
|
|
158
|
+
cwd: stubDir, pattern: 'fts5_search', searchPath: 'src/storage/*.rs', binary: stubBinary(),
|
|
159
|
+
});
|
|
160
|
+
assert.equal(r.status, 'hits');
|
|
161
|
+
assert.match(r.text, /args=\["grep","fts5_search","src\/storage"\]/);
|
|
162
|
+
});
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
// 2. Args include an indexed source-tree path (src/ tests/ lib/ scripts/ ...)
|
|
13
13
|
// 3. Not searching only a config/lockfile (Cargo.toml/.gitignore/*.md/*.json)
|
|
14
14
|
// 4. Command doesn't already invoke code-graph-mcp (no double-suggest)
|
|
15
|
-
// 5. .code-graph/index.db exists in CWD
|
|
15
|
+
// 5. .code-graph/index.db exists in CWD or a parent up to $HOME (v0.48: the
|
|
16
|
+
// hook's cwd follows the persistent shell — after `cd backend/` every
|
|
17
|
+
// gate used to fail silently for the rest of the session; daagu
|
|
18
|
+
// 2026-06-11 replay: 38/40 head-greps dark to this)
|
|
16
19
|
// 6. Same command-hash not hinted within last 60s (per-command cooldown)
|
|
17
20
|
//
|
|
18
21
|
// BLOCK fires when shouldHint AND (shouldBlock):
|
|
@@ -22,19 +25,28 @@
|
|
|
22
25
|
// 9. Pattern is not a bare marker word (TODO/FIXME/XXX/HACK/WARN/ERROR/NOTE)
|
|
23
26
|
// 10. CODE_GRAPH_NO_BLOCK_GREP != "1" (block escape, independent of QUIET_HOOKS)
|
|
24
27
|
//
|
|
28
|
+
// A `CODE_GRAPH_NO_BLOCK_GREP=1`-prefixed grep that would otherwise hint is
|
|
29
|
+
// recorded as `action:'bypass'` and allowed silently (v0.48) — previously the
|
|
30
|
+
// bare KEY=VALUE prefix failed GREP_HEAD and the escape was invisible to the
|
|
31
|
+
// conversion funnel (daagu 2026-06-11: 14 bypassed greps, 0 recorded).
|
|
32
|
+
//
|
|
25
33
|
// Exits silently otherwise — zero noise for build greps, log filters, config
|
|
26
34
|
// lookups, or the rare legitimate use of raw grep on indexed source.
|
|
27
35
|
|
|
28
36
|
const fs = require('fs');
|
|
37
|
+
const os = require('os');
|
|
29
38
|
const path = require('path');
|
|
30
39
|
const crypto = require('crypto');
|
|
31
40
|
const { cgTmpDir } = require('./tmp-dir');
|
|
32
41
|
const { recordRecommendation } = require('./recommendation-log');
|
|
33
|
-
const { runGrepAnswer } = require('./cg-answer');
|
|
42
|
+
const { runGrepAnswer, sanitizeSearchPath } = require('./cg-answer');
|
|
34
43
|
|
|
35
44
|
// --- Pure logic (testable) ---
|
|
36
45
|
|
|
37
|
-
|
|
46
|
+
// v0.48: also match bare `KEY=VALUE grep` prefixes (no `env` verb) — the shape
|
|
47
|
+
// the deny message itself teaches (`CODE_GRAPH_NO_BLOCK_GREP=1 grep …`). With
|
|
48
|
+
// the old `env`-only form those commands failed gate 1 and were invisible.
|
|
49
|
+
const GREP_HEAD = /^\s*(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(grep|rg|ag)\b/;
|
|
38
50
|
// Source-tree prefix list. Expanded v0.27+ Phase C: original `src/tests/lib/...`
|
|
39
51
|
// missed real-world backend conventions where the prefix list term is preceded
|
|
40
52
|
// by something else (`backend/app/...` — `app/` doesn't match because `/` isn't
|
|
@@ -99,8 +111,8 @@ const MARKER_ONLY =
|
|
|
99
111
|
// symbol-shaped target".
|
|
100
112
|
function extractPatterns(cmd) {
|
|
101
113
|
if (!cmd || typeof cmd !== 'string') return [];
|
|
102
|
-
// Strip leading verb + env prefix
|
|
103
|
-
const stripped = cmd.replace(/^\s*(?:env\s
|
|
114
|
+
// Strip leading verb + env/assignment prefix (kept in sync with GREP_HEAD)
|
|
115
|
+
const stripped = cmd.replace(/^\s*(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:grep|rg|ag)\s+/, '');
|
|
104
116
|
// Collect every quoted argument — first one is the pattern in standard grep
|
|
105
117
|
// usage; subsequent ones (e.g. `-e "second"`) are also patterns or filter
|
|
106
118
|
// expressions and worth screening too.
|
|
@@ -131,6 +143,69 @@ function normalizeCommandPaths(cmd, cwd) {
|
|
|
131
143
|
return cmd.split(cwd.endsWith('/') ? cwd : cwd + '/').join('');
|
|
132
144
|
}
|
|
133
145
|
|
|
146
|
+
// v0.48 — the hook's process.cwd() follows the PERSISTENT shell, not the
|
|
147
|
+
// project root: after the model runs `cd backend/`, every later bare grep used
|
|
148
|
+
// to fail the index.db gate silently for the rest of the session (daagu
|
|
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
|
+
}
|
|
163
|
+
|
|
164
|
+
// v0.48 — companion to resolveProjectRoot: when the shell sits in a subdir,
|
|
165
|
+
// bare relative path args (`app --include=*.py` from backend/) are
|
|
166
|
+
// subdir-relative and never match the root-relative SRC_PATH prefixes. Rebase
|
|
167
|
+
// each candidate token onto the project root; a token only counts as a path
|
|
168
|
+
// when the rebased form EXISTS under the root — quoted patterns, flags,
|
|
169
|
+
// operators, absolute and traversal tokens are never touched. Existence is the
|
|
170
|
+
// workhorse gate: it keeps unquoted pattern words from masquerading as paths
|
|
171
|
+
// (the exact shape that would re-create the answered:false glob failure).
|
|
172
|
+
function rebaseRelativePaths(cmd, relPrefix, rootDir, exists = fs.existsSync) {
|
|
173
|
+
if (!cmd || typeof cmd !== 'string' || !relPrefix || !rootDir) return cmd;
|
|
174
|
+
const prefix = relPrefix.split(path.sep).join('/');
|
|
175
|
+
// Shell sits outside any known source dir (docs/, target/, …) — don't guess.
|
|
176
|
+
if (!SRC_PATH_TOKEN.test(prefix + '/')) return cmd;
|
|
177
|
+
let verbSeen = false;
|
|
178
|
+
return cmd.split(/(\s+)/).map((tok) => {
|
|
179
|
+
if (!tok || /^\s+$/.test(tok)) return tok;
|
|
180
|
+
if (!verbSeen) {
|
|
181
|
+
if (/^(?:env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/.test(tok)) return tok;
|
|
182
|
+
verbSeen = true; // the verb itself (grep/rg/ag) — never a path
|
|
183
|
+
return tok;
|
|
184
|
+
}
|
|
185
|
+
if (/^["']/.test(tok)) return tok; // quoted → pattern
|
|
186
|
+
if (tok.startsWith('-')) return tok; // flag
|
|
187
|
+
if (tok.startsWith('/')) return tok; // absolute (foreign — root strip already ran)
|
|
188
|
+
if (tok.includes('..')) return tok; // traversal
|
|
189
|
+
if (/[|;&<>=\\$`'"]/.test(tok)) return tok; // operators / redirects / assignments / escapes
|
|
190
|
+
const candidate = prefix + '/' + tok;
|
|
191
|
+
// Probe existence on the glob-truncated form: `app/…/llm_engine/*.py`
|
|
192
|
+
// must still rebase (its dir exists) or the deny-answer would run a
|
|
193
|
+
// subdir-relative path from the root and fail (answered:false again).
|
|
194
|
+
const probe = sanitizeSearchPath(candidate);
|
|
195
|
+
try {
|
|
196
|
+
if (!probe || !exists(path.join(rootDir, probe))) return tok;
|
|
197
|
+
} catch { return tok; }
|
|
198
|
+
return candidate;
|
|
199
|
+
}).join('');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// v0.48 — bypass detection on the RAW command. The deny message documents
|
|
203
|
+
// `CODE_GRAPH_NO_BLOCK_GREP=1` as a per-command escape; record its use so the
|
|
204
|
+
// conversion funnel can see escape adoption instead of going dark.
|
|
205
|
+
function commandHasBypass(cmd) {
|
|
206
|
+
return typeof cmd === 'string' && /(?:^|\s)CODE_GRAPH_NO_BLOCK_GREP=1(?:\s|$)/.test(cmd);
|
|
207
|
+
}
|
|
208
|
+
|
|
134
209
|
// v0.47.0 — pull the first source-tree path token out of the denied command so
|
|
135
210
|
// the inline answer can scope its search the same way the raw grep would have.
|
|
136
211
|
function extractSearchPath(cmd) {
|
|
@@ -188,14 +263,18 @@ function buildBlockReason() {
|
|
|
188
263
|
' code-graph-mcp grep "<pattern>" <path> # FTS + AST context per hit',
|
|
189
264
|
' code-graph-mcp ast-search "<pattern>" --type fn # filter by node type',
|
|
190
265
|
' code-graph-mcp callgraph SYMBOL # callers + callees',
|
|
191
|
-
'
|
|
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.',
|
|
192
268
|
].join('\n');
|
|
193
269
|
}
|
|
194
270
|
|
|
195
271
|
// v0.47.0 — deny WITH the answer inline. Hint-only had ~0% transfer and a bare
|
|
196
272
|
// deny still asks the model to initiate a new tool call; embedding the actual
|
|
197
|
-
// results removes that choice entirely.
|
|
198
|
-
//
|
|
273
|
+
// results removes that choice entirely.
|
|
274
|
+
// v0.48 — NO escape-hatch line here: the model already has the results, and
|
|
275
|
+
// advertising the bypass taught it a permanent prefix within 5 seconds (daagu
|
|
276
|
+
// 2026-06-11: one deny → 14 bypassed greps). The static deny keeps a scoped
|
|
277
|
+
// escape because there we give no answer.
|
|
199
278
|
function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
|
|
200
279
|
const cmdShown = `code-graph-mcp grep "${pattern}"${searchPath ? ` ${searchPath}` : ''}`;
|
|
201
280
|
const lines = [
|
|
@@ -208,7 +287,6 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
|
|
|
208
287
|
}
|
|
209
288
|
lines.push(
|
|
210
289
|
'Each hit shows its containing fn/module — use these results directly instead of re-running the search.',
|
|
211
|
-
'For raw-text regex (alternation, log/comment scans), re-run with `CODE_GRAPH_NO_BLOCK_GREP=1` prepended.',
|
|
212
290
|
);
|
|
213
291
|
return lines.join('\n');
|
|
214
292
|
}
|
|
@@ -245,9 +323,11 @@ function isAnswerDisabled(env = process.env) {
|
|
|
245
323
|
|
|
246
324
|
function runMain() {
|
|
247
325
|
if (isSilenced()) return;
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
326
|
+
// v0.48 — process.cwd() follows the persistent shell; resolve the project
|
|
327
|
+
// root by walking up so `cd backend/` no longer darkens the whole session.
|
|
328
|
+
const shellCwd = process.cwd();
|
|
329
|
+
const root = resolveProjectRoot(shellCwd);
|
|
330
|
+
if (root === null) return; // no index anywhere up to $HOME — no hint
|
|
251
331
|
|
|
252
332
|
let input;
|
|
253
333
|
try {
|
|
@@ -258,11 +338,22 @@ function runMain() {
|
|
|
258
338
|
} catch { return; }
|
|
259
339
|
|
|
260
340
|
const rawCmd = (input.tool_input && input.tool_input.command) || '';
|
|
261
|
-
// v0.47.1 — match against the
|
|
262
|
-
// project root behave exactly like their relative spelling.
|
|
263
|
-
//
|
|
264
|
-
|
|
341
|
+
// v0.47.1 — match against the root-stripped form so absolute paths under the
|
|
342
|
+
// project root behave exactly like their relative spelling. v0.48 — then
|
|
343
|
+
// rebase bare subdir-relative tokens onto the root. Cooldown stays keyed on
|
|
344
|
+
// the raw command (what Claude actually sent).
|
|
345
|
+
let cmd = normalizeCommandPaths(rawCmd, root);
|
|
346
|
+
const relPrefix = path.relative(root, shellCwd);
|
|
347
|
+
if (relPrefix) cmd = rebaseRelativePaths(cmd, relPrefix, root);
|
|
265
348
|
if (!shouldHint(cmd)) return;
|
|
349
|
+
|
|
350
|
+
// v0.48 — deliberate escape: record it (funnel visibility) and stay silent.
|
|
351
|
+
// Before GREP_HEAD accepted bare KEY=VALUE prefixes these were invisible.
|
|
352
|
+
if (commandHasBypass(rawCmd)) {
|
|
353
|
+
recordRecommendation(root, { hook: 'grep', action: 'bypass' });
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
266
357
|
if (isOnCooldown(rawCmd)) return;
|
|
267
358
|
|
|
268
359
|
markCooldown(rawCmd);
|
|
@@ -274,12 +365,15 @@ function runMain() {
|
|
|
274
365
|
// (regex-dialect differences mean 0 hits ≠ proof of absence).
|
|
275
366
|
let answer = { status: 'unavailable' };
|
|
276
367
|
const pattern = pickBlockPattern(cmd);
|
|
368
|
+
// v0.48 — glob-truncated once, shared by the run and the deny message
|
|
369
|
+
// (a literal `dir/*.py` argv made rg exit 1 → answered:false static deny).
|
|
370
|
+
const searchPath = sanitizeSearchPath(extractSearchPath(cmd));
|
|
277
371
|
if (!isAnswerDisabled() && pattern) {
|
|
278
|
-
answer = runGrepAnswer({ cwd, pattern, searchPath
|
|
372
|
+
answer = runGrepAnswer({ cwd: root, pattern, searchPath });
|
|
279
373
|
}
|
|
280
374
|
|
|
281
375
|
if (answer.status === 'no-hits') {
|
|
282
|
-
recordRecommendation(
|
|
376
|
+
recordRecommendation(root, { hook: 'grep', action: 'hint', fallthrough: 'no-hits' });
|
|
283
377
|
process.stdout.write(buildNoHitsFyi(pattern) + '\n');
|
|
284
378
|
return;
|
|
285
379
|
}
|
|
@@ -289,7 +383,7 @@ function runMain() {
|
|
|
289
383
|
// ignored by Claude Code — the grep ran anyway. The hookSpecificOutput form
|
|
290
384
|
// is the documented modern path. Exit 0 — this is a routing decision, not
|
|
291
385
|
// a hook failure (exit 2 would mark the tool call as "hook errored").
|
|
292
|
-
recordRecommendation(
|
|
386
|
+
recordRecommendation(root, {
|
|
293
387
|
hook: 'grep', action: 'deny', answered: answer.status === 'hits',
|
|
294
388
|
});
|
|
295
389
|
process.stdout.write(JSON.stringify({
|
|
@@ -297,14 +391,14 @@ function runMain() {
|
|
|
297
391
|
hookEventName: 'PreToolUse',
|
|
298
392
|
permissionDecision: 'deny',
|
|
299
393
|
permissionDecisionReason: answer.status === 'hits'
|
|
300
|
-
? buildBlockReasonWithAnswer(pattern,
|
|
394
|
+
? buildBlockReasonWithAnswer(pattern, searchPath, answer)
|
|
301
395
|
: buildBlockReason(),
|
|
302
396
|
},
|
|
303
397
|
}) + '\n');
|
|
304
398
|
return;
|
|
305
399
|
}
|
|
306
400
|
|
|
307
|
-
recordRecommendation(
|
|
401
|
+
recordRecommendation(root, { hook: 'grep', action: 'hint' });
|
|
308
402
|
process.stdout.write(buildHint() + '\n');
|
|
309
403
|
}
|
|
310
404
|
|
|
@@ -318,6 +412,9 @@ module.exports = {
|
|
|
318
412
|
extractPatterns, // v0.32.1 — exposed for tests
|
|
319
413
|
extractSearchPath, // v0.47.0 — deny-with-answer
|
|
320
414
|
normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
|
|
415
|
+
resolveProjectRoot, // v0.48 — subdir-cwd dark fix
|
|
416
|
+
rebaseRelativePaths, // v0.48 — subdir-cwd dark fix
|
|
417
|
+
commandHasBypass, // v0.48 — bypass funnel visibility
|
|
321
418
|
pickBlockPattern,
|
|
322
419
|
buildHint,
|
|
323
420
|
buildBlockReason,
|
|
@@ -7,6 +7,9 @@ const {
|
|
|
7
7
|
extractPatterns,
|
|
8
8
|
extractSearchPath,
|
|
9
9
|
normalizeCommandPaths,
|
|
10
|
+
resolveProjectRoot,
|
|
11
|
+
rebaseRelativePaths,
|
|
12
|
+
commandHasBypass,
|
|
10
13
|
pickBlockPattern,
|
|
11
14
|
buildHint,
|
|
12
15
|
buildBlockReason,
|
|
@@ -651,17 +654,30 @@ test('pickBlockPattern: no identifier-like pattern → undefined', () => {
|
|
|
651
654
|
|
|
652
655
|
// ── v0.47.0 deny-with-answer: message builders + env gate ───────────
|
|
653
656
|
|
|
654
|
-
test('buildBlockReasonWithAnswer: embeds results
|
|
657
|
+
test('buildBlockReasonWithAnswer: embeds results and command', () => {
|
|
655
658
|
const reason = buildBlockReasonWithAnswer('fts5_search', 'src/storage/', {
|
|
656
659
|
status: 'hits', text: 'src/storage/db.rs:42 fn fts5_search()', truncated: false,
|
|
657
660
|
});
|
|
658
661
|
assert.match(reason, /already ran/);
|
|
659
662
|
assert.match(reason, /code-graph-mcp grep "fts5_search" src\/storage\//);
|
|
660
663
|
assert.match(reason, /src\/storage\/db\.rs:42/);
|
|
661
|
-
assert.match(reason, /CODE_GRAPH_NO_BLOCK_GREP=1/);
|
|
662
664
|
assert.doesNotMatch(reason, /truncated/);
|
|
663
665
|
});
|
|
664
666
|
|
|
667
|
+
test('buildBlockReasonWithAnswer: NEVER advertises the bypass (v0.48 — one deny taught a 14-grep permanent prefix)', () => {
|
|
668
|
+
const reason = buildBlockReasonWithAnswer('fts5_search', 'src/storage/', {
|
|
669
|
+
status: 'hits', text: 'hit', truncated: false,
|
|
670
|
+
});
|
|
671
|
+
assert.doesNotMatch(reason, /CODE_GRAPH_NO_BLOCK_GREP/);
|
|
672
|
+
});
|
|
673
|
+
|
|
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
|
+
|
|
665
681
|
test('buildBlockReasonWithAnswer: no searchPath → command has no path arg', () => {
|
|
666
682
|
const reason = buildBlockReasonWithAnswer('fts5_search', undefined, {
|
|
667
683
|
status: 'hits', text: 'hit', truncated: false,
|
|
@@ -706,9 +722,9 @@ function e2eFixture(stubBody) {
|
|
|
706
722
|
return { dir, stub };
|
|
707
723
|
}
|
|
708
724
|
|
|
709
|
-
function runHook(cmd, fixture) {
|
|
725
|
+
function runHook(cmd, fixture, cwdOverride) {
|
|
710
726
|
const res = spawnHook(process.execPath, [pathE2e.join(__dirname, 'pre-grep-guide.js')], {
|
|
711
|
-
cwd: fixture.dir,
|
|
727
|
+
cwd: cwdOverride || fixture.dir,
|
|
712
728
|
input: JSON.stringify({ tool_input: { command: cmd } }),
|
|
713
729
|
encoding: 'utf8',
|
|
714
730
|
env: {
|
|
@@ -837,3 +853,190 @@ test('e2e: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → static deny even when stub would h
|
|
|
837
853
|
cleanupFixture(fixture, cmd);
|
|
838
854
|
}
|
|
839
855
|
});
|
|
856
|
+
|
|
857
|
+
// ── v0.48 subdir-cwd dark fix: resolveProjectRoot / rebaseRelativePaths ──
|
|
858
|
+
// daagu 2026-06-11: the persistent shell `cd backend/` darkened 38/40
|
|
859
|
+
// head-greps for the rest of the night — gate 5 checked process.cwd() only.
|
|
860
|
+
|
|
861
|
+
const { sanitizeSearchPath } = require('./cg-answer');
|
|
862
|
+
|
|
863
|
+
test('resolveProjectRoot: index at start dir', () => {
|
|
864
|
+
const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
|
|
865
|
+
try {
|
|
866
|
+
fsE2e.mkdirSync(pathE2e.join(base, 'proj', '.code-graph'), { recursive: true });
|
|
867
|
+
fsE2e.writeFileSync(pathE2e.join(base, 'proj', '.code-graph', 'index.db'), '');
|
|
868
|
+
assert.equal(
|
|
869
|
+
resolveProjectRoot(pathE2e.join(base, 'proj'), { home: base }),
|
|
870
|
+
pathE2e.join(base, 'proj'));
|
|
871
|
+
} finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
test('resolveProjectRoot: walks up from nested subdir to the indexed root', () => {
|
|
875
|
+
const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
|
|
876
|
+
try {
|
|
877
|
+
const proj = pathE2e.join(base, 'proj');
|
|
878
|
+
fsE2e.mkdirSync(pathE2e.join(proj, '.code-graph'), { recursive: true });
|
|
879
|
+
fsE2e.writeFileSync(pathE2e.join(proj, '.code-graph', 'index.db'), '');
|
|
880
|
+
const deep = pathE2e.join(proj, 'backend', 'app', 'services');
|
|
881
|
+
fsE2e.mkdirSync(deep, { recursive: true });
|
|
882
|
+
assert.equal(resolveProjectRoot(deep, { home: base }), proj);
|
|
883
|
+
} finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test('resolveProjectRoot: no index up to $HOME → null (home itself still checked)', () => {
|
|
887
|
+
const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
|
|
888
|
+
try {
|
|
889
|
+
const deep = pathE2e.join(base, 'somewhere', 'deep');
|
|
890
|
+
fsE2e.mkdirSync(deep, { recursive: true });
|
|
891
|
+
assert.equal(resolveProjectRoot(deep, { home: base }), null);
|
|
892
|
+
// home itself holding an index is honored
|
|
893
|
+
fsE2e.mkdirSync(pathE2e.join(base, '.code-graph'), { recursive: true });
|
|
894
|
+
fsE2e.writeFileSync(pathE2e.join(base, '.code-graph', 'index.db'), '');
|
|
895
|
+
assert.equal(resolveProjectRoot(deep, { home: base }), base);
|
|
896
|
+
} finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test('rebaseRelativePaths: daagu shape — bare `app` from backend/ cwd', () => {
|
|
900
|
+
const exists = (p) => p.endsWith(pathE2e.join('backend', 'app'));
|
|
901
|
+
const cmd = 'grep -rn "rr_source\\|max_retries" app --include=*.py';
|
|
902
|
+
const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
|
|
903
|
+
assert.equal(rebased, 'grep -rn "rr_source\\|max_retries" backend/app --include=*.py');
|
|
904
|
+
assert.equal(shouldHint(rebased), true);
|
|
905
|
+
assert.equal(extractSearchPath(rebased), 'backend/app');
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test('rebaseRelativePaths: deep relPrefix, multiple file args', () => {
|
|
909
|
+
const exists = (p) => p.endsWith('.py');
|
|
910
|
+
const rel = 'backend/app/services/scheduler/tasks';
|
|
911
|
+
const cmd = 'grep -n "except Exception" asr_preload.py xuanlun_pro_scan.py';
|
|
912
|
+
const rebased = rebaseRelativePaths(cmd, rel, '/proj', exists);
|
|
913
|
+
assert.match(rebased, /backend\/app\/services\/scheduler\/tasks\/asr_preload\.py/);
|
|
914
|
+
assert.match(rebased, /backend\/app\/services\/scheduler\/tasks\/xuanlun_pro_scan\.py/);
|
|
915
|
+
assert.equal(shouldHint(rebased), true);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
test('rebaseRelativePaths: quoted patterns are never rebased even if a same-named path exists', () => {
|
|
919
|
+
const exists = () => true; // adversarial: everything "exists"
|
|
920
|
+
const cmd = 'grep -rn "retry" app';
|
|
921
|
+
const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
|
|
922
|
+
assert.equal(rebased, 'grep -rn "retry" backend/app');
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
test('rebaseRelativePaths: flags, absolute, traversal, operators untouched', () => {
|
|
926
|
+
const exists = () => true;
|
|
927
|
+
const cmd = 'grep -rn "X" /etc/hosts ../up --include=*.py 2>/dev/null';
|
|
928
|
+
assert.equal(rebaseRelativePaths(cmd, 'backend', '/proj', exists), cmd);
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
test('rebaseRelativePaths: non-source relPrefix (docs/) → unchanged', () => {
|
|
932
|
+
const exists = () => true;
|
|
933
|
+
const cmd = 'grep -rn "X" app';
|
|
934
|
+
assert.equal(rebaseRelativePaths(cmd, 'docs', '/proj', exists), cmd);
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
test('rebaseRelativePaths: unquoted pattern word does not exist → untouched', () => {
|
|
938
|
+
const exists = (p) => p.endsWith('/backend/app');
|
|
939
|
+
const cmd = 'grep -rn retry_count app';
|
|
940
|
+
const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
|
|
941
|
+
assert.equal(rebased, 'grep -rn retry_count backend/app');
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
// ── v0.48 bypass visibility: GREP_HEAD bare-prefix + commandHasBypass ──
|
|
945
|
+
|
|
946
|
+
test('shouldHint: bare KEY=VALUE prefixed grep now matches GREP_HEAD', () => {
|
|
947
|
+
assert.equal(shouldHint('CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "fts5_search" src/'), true);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
test('extractPatterns: bare KEY=VALUE prefix stripped with the verb', () => {
|
|
951
|
+
assert.deepEqual(
|
|
952
|
+
extractPatterns('CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "split_identifier" src/'),
|
|
953
|
+
['split_identifier']);
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test('commandHasBypass: =1 prefix detected, other values / absence are not', () => {
|
|
957
|
+
assert.equal(commandHasBypass('CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "X" src/'), true);
|
|
958
|
+
assert.equal(commandHasBypass('FOO=1 CODE_GRAPH_NO_BLOCK_GREP=1 grep "X" src/'), true);
|
|
959
|
+
assert.equal(commandHasBypass('CODE_GRAPH_NO_BLOCK_GREP=0 grep -rn "X" src/'), false);
|
|
960
|
+
assert.equal(commandHasBypass('grep -rn "CODE_GRAPH_NO_BLOCK_GREP=1" src/'), false);
|
|
961
|
+
assert.equal(commandHasBypass('grep -rn "X" src/'), false);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
// ── v0.48 replay: the exact command behind the night's only deny ──
|
|
965
|
+
// (answered:false — glob path reached rg literally and exited 1)
|
|
966
|
+
|
|
967
|
+
test('replay: daagu denied glob command → block + sanitized search path', () => {
|
|
968
|
+
const cmd = 'grep -rn "async def chat\\|def chat\\|retry\\|rate.limit\\|rate-limit\\|RateLimit\\|429\\|max_retries\\|backoff\\|fallback_model\\|temporarily" backend/app/services/llm_engine/*.py | head -40';
|
|
969
|
+
assert.equal(shouldHint(cmd), true);
|
|
970
|
+
assert.equal(shouldBlock(cmd), true);
|
|
971
|
+
const raw = extractSearchPath(cmd);
|
|
972
|
+
assert.equal(raw, 'backend/app/services/llm_engine/*.py');
|
|
973
|
+
assert.equal(sanitizeSearchPath(raw), 'backend/app/services/llm_engine');
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
// ── v0.48 e2e: hook process spawned exactly as CC does ──
|
|
977
|
+
|
|
978
|
+
test('e2e: subdir cwd — hook resolves root, rebases path, records at root', () => {
|
|
979
|
+
const uniq = `sub_dir_fix_${Date.now()}`;
|
|
980
|
+
const fixture = e2eFixture(
|
|
981
|
+
`process.stdout.write('backend/app/x.py:1 fn hit()\\n');`);
|
|
982
|
+
const cmd = `grep -rn "${uniq}\\|max_retries" app`;
|
|
983
|
+
try {
|
|
984
|
+
fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'backend', 'app'), { recursive: true });
|
|
985
|
+
const res = runHook(cmd, fixture, pathE2e.join(fixture.dir, 'backend'));
|
|
986
|
+
const out = JSON.parse(res.stdout);
|
|
987
|
+
assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
|
|
988
|
+
const recs = fsE2e.readFileSync(
|
|
989
|
+
pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
|
|
990
|
+
assert.match(recs, /"action":"deny"/);
|
|
991
|
+
// never creates .code-graph in the subdir
|
|
992
|
+
assert.equal(fsE2e.existsSync(pathE2e.join(fixture.dir, 'backend', '.code-graph')), false);
|
|
993
|
+
} finally {
|
|
994
|
+
cleanupFixture(fixture, cmd);
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
test('e2e: bypassed grep is silent but recorded as action:bypass', () => {
|
|
999
|
+
const uniq = `bypass_vis_${Date.now()}`;
|
|
1000
|
+
const fixture = e2eFixture(`process.stdout.write('never called\\n');`);
|
|
1001
|
+
const cmd = `CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "${uniq}\\|fts5_search" src/`;
|
|
1002
|
+
try {
|
|
1003
|
+
fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src'), { recursive: true });
|
|
1004
|
+
const res = runHook(cmd, fixture);
|
|
1005
|
+
assert.equal(res.stdout, '');
|
|
1006
|
+
const recs = fsE2e.readFileSync(
|
|
1007
|
+
pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
|
|
1008
|
+
assert.match(recs, /"action":"bypass"/);
|
|
1009
|
+
} finally {
|
|
1010
|
+
cleanupFixture(fixture, cmd);
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
test('e2e: glob path arg → answer runs against the glob-truncated dir', () => {
|
|
1015
|
+
const uniq = `GlobTrunc${Date.now()}`;
|
|
1016
|
+
const fixture = e2eFixture(
|
|
1017
|
+
`process.stdout.write('args=' + JSON.stringify(process.argv.slice(2)) + '\\n');`);
|
|
1018
|
+
const cmd = `grep -rn "${uniq}" src/storage/*.rs`;
|
|
1019
|
+
try {
|
|
1020
|
+
fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src', 'storage'), { recursive: true });
|
|
1021
|
+
const res = runHook(cmd, fixture);
|
|
1022
|
+
const out = JSON.parse(res.stdout);
|
|
1023
|
+
assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
|
|
1024
|
+
assert.match(out.hookSpecificOutput.permissionDecisionReason,
|
|
1025
|
+
new RegExp(`args=\\["grep","${uniq}","src/storage"\\]`));
|
|
1026
|
+
} finally {
|
|
1027
|
+
cleanupFixture(fixture, cmd);
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
test('rebaseRelativePaths: glob token rebases when its glob-truncated dir exists', () => {
|
|
1032
|
+
// daagu shape: shell in backend/, command scopes a glob under it. Without
|
|
1033
|
+
// the truncated probe the token stayed subdir-relative while the answer ran
|
|
1034
|
+
// from the root → rg ENOENT → answered:false (the original night bug).
|
|
1035
|
+
const exists = (p) => p.endsWith(pathE2e.join('backend', 'app', 'services', 'llm_engine'));
|
|
1036
|
+
const cmd = 'grep -rn "def chat\\|max_retries" app/services/llm_engine/*.py';
|
|
1037
|
+
const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
|
|
1038
|
+
assert.equal(
|
|
1039
|
+
extractSearchPath(rebased), 'backend/app/services/llm_engine/*.py');
|
|
1040
|
+
assert.equal(
|
|
1041
|
+
sanitizeSearchPath(extractSearchPath(rebased)), 'backend/app/services/llm_engine');
|
|
1042
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.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.48.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.48.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.48.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.48.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.48.0"
|
|
43
43
|
}
|
|
44
44
|
}
|