@sdsrs/code-graph 0.47.0 → 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 +136 -20
- package/claude-plugin/scripts/pre-grep-guide.test.js +296 -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.
|
|
@@ -117,6 +129,83 @@ function shouldBlock(cmd) {
|
|
|
117
129
|
return patterns.some(p => IDENTIFIER_LIKE.test(p));
|
|
118
130
|
}
|
|
119
131
|
|
|
132
|
+
// v0.47.1 — CC harness steers Bash toward ABSOLUTE paths (cd in compound
|
|
133
|
+
// commands triggers permission prompts), so `grep -rn "X" /abs/root/backend/…`
|
|
134
|
+
// is the dominant real shape — and SRC_PATH's lookbehind (^|\s|quote) never
|
|
135
|
+
// matched it (daagu 2026-06-11 replay: 42/42 head-greps absolute → 1 hint /
|
|
136
|
+
// 0 block as-is vs 30 / 16 after this strip). Strip `<cwd>/` everywhere before
|
|
137
|
+
// matching: the hook's cwd IS the project root, so this is exact — paths
|
|
138
|
+
// outside the project stay absolute and keep not firing (conservative edge).
|
|
139
|
+
// split/join, not regex: cwd may contain regex metacharacters.
|
|
140
|
+
function normalizeCommandPaths(cmd, cwd) {
|
|
141
|
+
if (!cmd || typeof cmd !== 'string') return cmd;
|
|
142
|
+
if (!cwd || typeof cwd !== 'string' || cwd === '/') return cmd;
|
|
143
|
+
return cmd.split(cwd.endsWith('/') ? cwd : cwd + '/').join('');
|
|
144
|
+
}
|
|
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
|
+
|
|
120
209
|
// v0.47.0 — pull the first source-tree path token out of the denied command so
|
|
121
210
|
// the inline answer can scope its search the same way the raw grep would have.
|
|
122
211
|
function extractSearchPath(cmd) {
|
|
@@ -174,14 +263,18 @@ function buildBlockReason() {
|
|
|
174
263
|
' code-graph-mcp grep "<pattern>" <path> # FTS + AST context per hit',
|
|
175
264
|
' code-graph-mcp ast-search "<pattern>" --type fn # filter by node type',
|
|
176
265
|
' code-graph-mcp callgraph SYMBOL # callers + callees',
|
|
177
|
-
'
|
|
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.',
|
|
178
268
|
].join('\n');
|
|
179
269
|
}
|
|
180
270
|
|
|
181
271
|
// v0.47.0 — deny WITH the answer inline. Hint-only had ~0% transfer and a bare
|
|
182
272
|
// deny still asks the model to initiate a new tool call; embedding the actual
|
|
183
|
-
// results removes that choice entirely.
|
|
184
|
-
//
|
|
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.
|
|
185
278
|
function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
|
|
186
279
|
const cmdShown = `code-graph-mcp grep "${pattern}"${searchPath ? ` ${searchPath}` : ''}`;
|
|
187
280
|
const lines = [
|
|
@@ -194,7 +287,6 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
|
|
|
194
287
|
}
|
|
195
288
|
lines.push(
|
|
196
289
|
'Each hit shows its containing fn/module — use these results directly instead of re-running the search.',
|
|
197
|
-
'For raw-text regex (alternation, log/comment scans), re-run with `CODE_GRAPH_NO_BLOCK_GREP=1` prepended.',
|
|
198
290
|
);
|
|
199
291
|
return lines.join('\n');
|
|
200
292
|
}
|
|
@@ -231,9 +323,11 @@ function isAnswerDisabled(env = process.env) {
|
|
|
231
323
|
|
|
232
324
|
function runMain() {
|
|
233
325
|
if (isSilenced()) return;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
237
331
|
|
|
238
332
|
let input;
|
|
239
333
|
try {
|
|
@@ -243,11 +337,26 @@ function runMain() {
|
|
|
243
337
|
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
244
338
|
} catch { return; }
|
|
245
339
|
|
|
246
|
-
const
|
|
340
|
+
const rawCmd = (input.tool_input && input.tool_input.command) || '';
|
|
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);
|
|
247
348
|
if (!shouldHint(cmd)) return;
|
|
248
|
-
if (isOnCooldown(cmd)) return;
|
|
249
349
|
|
|
250
|
-
|
|
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
|
+
|
|
357
|
+
if (isOnCooldown(rawCmd)) return;
|
|
358
|
+
|
|
359
|
+
markCooldown(rawCmd);
|
|
251
360
|
|
|
252
361
|
if (!isBlockDisabled() && shouldBlock(cmd)) {
|
|
253
362
|
// v0.47.0 — run the AST-aware equivalent inside the hook and embed the
|
|
@@ -256,12 +365,15 @@ function runMain() {
|
|
|
256
365
|
// (regex-dialect differences mean 0 hits ≠ proof of absence).
|
|
257
366
|
let answer = { status: 'unavailable' };
|
|
258
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));
|
|
259
371
|
if (!isAnswerDisabled() && pattern) {
|
|
260
|
-
answer = runGrepAnswer({ cwd, pattern, searchPath
|
|
372
|
+
answer = runGrepAnswer({ cwd: root, pattern, searchPath });
|
|
261
373
|
}
|
|
262
374
|
|
|
263
375
|
if (answer.status === 'no-hits') {
|
|
264
|
-
recordRecommendation(
|
|
376
|
+
recordRecommendation(root, { hook: 'grep', action: 'hint', fallthrough: 'no-hits' });
|
|
265
377
|
process.stdout.write(buildNoHitsFyi(pattern) + '\n');
|
|
266
378
|
return;
|
|
267
379
|
}
|
|
@@ -271,7 +383,7 @@ function runMain() {
|
|
|
271
383
|
// ignored by Claude Code — the grep ran anyway. The hookSpecificOutput form
|
|
272
384
|
// is the documented modern path. Exit 0 — this is a routing decision, not
|
|
273
385
|
// a hook failure (exit 2 would mark the tool call as "hook errored").
|
|
274
|
-
recordRecommendation(
|
|
386
|
+
recordRecommendation(root, {
|
|
275
387
|
hook: 'grep', action: 'deny', answered: answer.status === 'hits',
|
|
276
388
|
});
|
|
277
389
|
process.stdout.write(JSON.stringify({
|
|
@@ -279,14 +391,14 @@ function runMain() {
|
|
|
279
391
|
hookEventName: 'PreToolUse',
|
|
280
392
|
permissionDecision: 'deny',
|
|
281
393
|
permissionDecisionReason: answer.status === 'hits'
|
|
282
|
-
? buildBlockReasonWithAnswer(pattern,
|
|
394
|
+
? buildBlockReasonWithAnswer(pattern, searchPath, answer)
|
|
283
395
|
: buildBlockReason(),
|
|
284
396
|
},
|
|
285
397
|
}) + '\n');
|
|
286
398
|
return;
|
|
287
399
|
}
|
|
288
400
|
|
|
289
|
-
recordRecommendation(
|
|
401
|
+
recordRecommendation(root, { hook: 'grep', action: 'hint' });
|
|
290
402
|
process.stdout.write(buildHint() + '\n');
|
|
291
403
|
}
|
|
292
404
|
|
|
@@ -299,6 +411,10 @@ module.exports = {
|
|
|
299
411
|
shouldBlock,
|
|
300
412
|
extractPatterns, // v0.32.1 — exposed for tests
|
|
301
413
|
extractSearchPath, // v0.47.0 — deny-with-answer
|
|
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
|
|
302
418
|
pickBlockPattern,
|
|
303
419
|
buildHint,
|
|
304
420
|
buildBlockReason,
|
|
@@ -6,6 +6,10 @@ const {
|
|
|
6
6
|
shouldBlock,
|
|
7
7
|
extractPatterns,
|
|
8
8
|
extractSearchPath,
|
|
9
|
+
normalizeCommandPaths,
|
|
10
|
+
resolveProjectRoot,
|
|
11
|
+
rebaseRelativePaths,
|
|
12
|
+
commandHasBypass,
|
|
9
13
|
pickBlockPattern,
|
|
10
14
|
buildHint,
|
|
11
15
|
buildBlockReason,
|
|
@@ -530,6 +534,74 @@ test('I4: grep -rn "fn render" src/ → BLOCK (decl anchor at start)', () => {
|
|
|
530
534
|
assert.equal(shouldBlock('grep -rn "fn render" src/'), true);
|
|
531
535
|
});
|
|
532
536
|
|
|
537
|
+
// ── v0.47.1 abs-path matcher fix: normalizeCommandPaths ─────────────
|
|
538
|
+
// CC harness steers Bash toward ABSOLUTE paths (cd in compound commands
|
|
539
|
+
// triggers permission prompts), so `grep -rn "X" /abs/root/backend/...` is
|
|
540
|
+
// the dominant real-world shape. SRC_PATH's lookbehind (^|\s|quote) never
|
|
541
|
+
// matched it: daagu 2026-06-11 replay — 42/42 head-greps absolute, 1 hint /
|
|
542
|
+
// 0 block as-is vs 30 hint / 16 block after cwd-strip.
|
|
543
|
+
|
|
544
|
+
test('normalizeCommandPaths: strips cwd prefix from path args', () => {
|
|
545
|
+
assert.equal(
|
|
546
|
+
normalizeCommandPaths('grep -rn "X" /proj/root/src/storage/', '/proj/root'),
|
|
547
|
+
'grep -rn "X" src/storage/');
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test('normalizeCommandPaths: strips every occurrence', () => {
|
|
551
|
+
assert.equal(
|
|
552
|
+
normalizeCommandPaths('grep -rn "X" /proj/root/src/a.rs /proj/root/tests/', '/proj/root'),
|
|
553
|
+
'grep -rn "X" src/a.rs tests/');
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('normalizeCommandPaths: strips inside quotes', () => {
|
|
557
|
+
assert.equal(
|
|
558
|
+
normalizeCommandPaths('grep -rn "X" "/proj/root/backend/app/"', '/proj/root'),
|
|
559
|
+
'grep -rn "X" "backend/app/"');
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test('normalizeCommandPaths: leaves foreign absolute paths alone', () => {
|
|
563
|
+
assert.equal(
|
|
564
|
+
normalizeCommandPaths('grep -rn "X" /other/place/src/', '/proj/root'),
|
|
565
|
+
'grep -rn "X" /other/place/src/');
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test('normalizeCommandPaths: no-op when cwd absent / falsy inputs', () => {
|
|
569
|
+
assert.equal(normalizeCommandPaths('grep -rn "X" src/', '/proj/root'), 'grep -rn "X" src/');
|
|
570
|
+
assert.equal(normalizeCommandPaths('', '/proj/root'), '');
|
|
571
|
+
assert.equal(normalizeCommandPaths('grep "X" src/', ''), 'grep "X" src/');
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// Real daagu transcript commands (2026-06-11 session 23f149f0…), the exact
|
|
575
|
+
// shape that was invisible to v0.47.0. Replay must fire post-normalization.
|
|
576
|
+
const DAAGU = '/mnt/data_ssd/dev/projects/daagu';
|
|
577
|
+
|
|
578
|
+
test('replay: real abs-path symbol grep → BLOCK after normalization', () => {
|
|
579
|
+
const cmd = `grep -n "_parse_finish_reason\\|_last_finish_reason\\|class OpenRouterProvider" ${DAAGU}/backend/app/services/llm_engine/openrouter.py`;
|
|
580
|
+
assert.equal(shouldHint(cmd), false); // documents the v0.47.0 blindspot
|
|
581
|
+
const norm = normalizeCommandPaths(cmd, DAAGU);
|
|
582
|
+
assert.equal(shouldHint(norm), true);
|
|
583
|
+
assert.equal(shouldBlock(norm), true);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test('replay: real abs-path -rln grep → HINT only after normalization (precision flag)', () => {
|
|
587
|
+
const cmd = `grep -rln "load_active_config_standalone" ${DAAGU}/backend/tests/ | head -5`;
|
|
588
|
+
const norm = normalizeCommandPaths(cmd, DAAGU);
|
|
589
|
+
assert.equal(shouldHint(norm), true);
|
|
590
|
+
assert.equal(shouldBlock(norm), false); // -l cluster disqualifies block
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test('replay: abs-path config-only grep stays silent after normalization', () => {
|
|
594
|
+
const cmd = `grep -n '"typecheck"\\|"type-check"\\|vue-tsc' ${DAAGU}/frontend/package.json`;
|
|
595
|
+
assert.equal(shouldHint(normalizeCommandPaths(cmd, DAAGU)), false);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test('replay: extractSearchPath gets relative path from normalized abs command', () => {
|
|
599
|
+
const cmd = `grep -rn "config_version" ${DAAGU}/backend/app/services/stock_picker/data_providers.py 2>/dev/null | head -5`;
|
|
600
|
+
assert.equal(
|
|
601
|
+
extractSearchPath(normalizeCommandPaths(cmd, DAAGU)),
|
|
602
|
+
'backend/app/services/stock_picker/data_providers.py');
|
|
603
|
+
});
|
|
604
|
+
|
|
533
605
|
// ── v0.47.0 deny-with-answer: extractSearchPath / pickBlockPattern ──
|
|
534
606
|
|
|
535
607
|
test('extractSearchPath: dir path after pattern', () => {
|
|
@@ -582,17 +654,30 @@ test('pickBlockPattern: no identifier-like pattern → undefined', () => {
|
|
|
582
654
|
|
|
583
655
|
// ── v0.47.0 deny-with-answer: message builders + env gate ───────────
|
|
584
656
|
|
|
585
|
-
test('buildBlockReasonWithAnswer: embeds results
|
|
657
|
+
test('buildBlockReasonWithAnswer: embeds results and command', () => {
|
|
586
658
|
const reason = buildBlockReasonWithAnswer('fts5_search', 'src/storage/', {
|
|
587
659
|
status: 'hits', text: 'src/storage/db.rs:42 fn fts5_search()', truncated: false,
|
|
588
660
|
});
|
|
589
661
|
assert.match(reason, /already ran/);
|
|
590
662
|
assert.match(reason, /code-graph-mcp grep "fts5_search" src\/storage\//);
|
|
591
663
|
assert.match(reason, /src\/storage\/db\.rs:42/);
|
|
592
|
-
assert.match(reason, /CODE_GRAPH_NO_BLOCK_GREP=1/);
|
|
593
664
|
assert.doesNotMatch(reason, /truncated/);
|
|
594
665
|
});
|
|
595
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
|
+
|
|
596
681
|
test('buildBlockReasonWithAnswer: no searchPath → command has no path arg', () => {
|
|
597
682
|
const reason = buildBlockReasonWithAnswer('fts5_search', undefined, {
|
|
598
683
|
status: 'hits', text: 'hit', truncated: false,
|
|
@@ -637,9 +722,9 @@ function e2eFixture(stubBody) {
|
|
|
637
722
|
return { dir, stub };
|
|
638
723
|
}
|
|
639
724
|
|
|
640
|
-
function runHook(cmd, fixture) {
|
|
725
|
+
function runHook(cmd, fixture, cwdOverride) {
|
|
641
726
|
const res = spawnHook(process.execPath, [pathE2e.join(__dirname, 'pre-grep-guide.js')], {
|
|
642
|
-
cwd: fixture.dir,
|
|
727
|
+
cwd: cwdOverride || fixture.dir,
|
|
643
728
|
input: JSON.stringify({ tool_input: { command: cmd } }),
|
|
644
729
|
encoding: 'utf8',
|
|
645
730
|
env: {
|
|
@@ -724,6 +809,26 @@ test('e2e: stub fails → static deny (v0.46 fallback) + records answered:false'
|
|
|
724
809
|
}
|
|
725
810
|
});
|
|
726
811
|
|
|
812
|
+
test('e2e: ABS-path grep under fixture root → deny fires, CLI argv gets relative path', () => {
|
|
813
|
+
const uniq = `StubAbs${Date.now()}`;
|
|
814
|
+
const fixture = e2eFixture(
|
|
815
|
+
`process.stdout.write('args=' + JSON.stringify(process.argv.slice(2)) + '\\n');`);
|
|
816
|
+
// fs.realpathSync: on macOS/Linux tmpdir may be a symlink; the hook sees the
|
|
817
|
+
// resolved cwd, so build the command from the same resolved form.
|
|
818
|
+
const realDir = fsE2e.realpathSync(fixture.dir);
|
|
819
|
+
const cmd = `grep -rn "${uniq}" ${realDir}/src/storage/`;
|
|
820
|
+
try {
|
|
821
|
+
const res = runHook(cmd, fixture);
|
|
822
|
+
assert.equal(res.status, 0);
|
|
823
|
+
const out = JSON.parse(res.stdout);
|
|
824
|
+
assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
|
|
825
|
+
assert.match(out.hookSpecificOutput.permissionDecisionReason,
|
|
826
|
+
/args=\["grep","StubAbs\d+","src\/storage\/"\]/);
|
|
827
|
+
} finally {
|
|
828
|
+
cleanupFixture(fixture, cmd);
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
|
|
727
832
|
test('e2e: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → static deny even when stub would hit', () => {
|
|
728
833
|
const uniq = `StubOptout${Date.now()}`;
|
|
729
834
|
const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 hit\\n');`);
|
|
@@ -748,3 +853,190 @@ test('e2e: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → static deny even when stub would h
|
|
|
748
853
|
cleanupFixture(fixture, cmd);
|
|
749
854
|
}
|
|
750
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
|
}
|