@sdsrs/code-graph 0.46.0 → 0.47.1

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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.46.0",
7
+ "version": "0.47.1",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Synchronous "answer in the deny" runner (v0.47.0).
4
+ //
5
+ // When pre-grep-guide denies a symbol-shaped raw grep, the measured
6
+ // recommend→use transfer rate of a bare suggestion is ~0% — the model rarely
7
+ // initiates a NEW tool call just because a deny message told it to. This module
8
+ // closes that gap by running the AST-aware equivalent (`code-graph-mcp grep
9
+ // "<pattern>" [path]`) inside the hook and handing the deny path the actual
10
+ // results, so the model never has to choose.
11
+ //
12
+ // Posture mirrors recommendation-log.js: bounded and best-effort. Any failure
13
+ // (no binary, nonzero exit, timeout, oversized pattern) degrades to
14
+ // `unavailable` and the caller falls back to the static deny — answering is an
15
+ // enhancement, never a new failure mode for the tool call.
16
+ //
17
+ // Verified non-polluting: the CLI `grep` subcommand does not write
18
+ // usage.jsonl (only the MCP server's SessionMetrics does), so hook-initiated
19
+ // runs cannot inflate the deny→use conversion funnel.
20
+
21
+ const { spawnSync } = require('child_process');
22
+
23
+ const DEFAULT_TIMEOUT_MS = 2000;
24
+ // ~1000 tokens. A deny reason carrying more than this stops being an answer
25
+ // and starts being a context tax.
26
+ const DEFAULT_MAX_BYTES = 4000;
27
+ const MAX_PATTERN_LEN = 200;
28
+ // CLI empty-result contract (text mode): stable prefix owned by this repo.
29
+ const NO_MATCH_PREFIX = '[code-graph] No matches';
30
+
31
+ /**
32
+ * Truncate text to maxBytes, cutting at the last complete line that fits.
33
+ * Falls back to a hard byte cut when even the first line is oversized.
34
+ * @returns {{text: string, truncated: boolean}}
35
+ */
36
+ function truncateAtLine(text, maxBytes) {
37
+ if (Buffer.byteLength(text, 'utf8') <= maxBytes) {
38
+ return { text, truncated: false };
39
+ }
40
+ const buf = Buffer.from(text, 'utf8');
41
+ const head = buf.subarray(0, maxBytes).toString('utf8');
42
+ // Drop a possibly half-cut trailing line (and any UTF-8 replacement char
43
+ // from a mid-codepoint cut rides along with it).
44
+ const lastNl = head.lastIndexOf('\n');
45
+ if (lastNl > 0) {
46
+ return { text: head.slice(0, lastNl), truncated: true };
47
+ }
48
+ return { text: buf.subarray(0, maxBytes).toString('latin1'), truncated: true };
49
+ }
50
+
51
+ /**
52
+ * Run `code-graph-mcp grep <pattern> [searchPath]` synchronously.
53
+ *
54
+ * @param {object} opts
55
+ * @param {string} opts.cwd project root (hook process.cwd())
56
+ * @param {string} opts.pattern the symbol-shaped pattern that triggered the deny
57
+ * @param {string} [opts.searchPath] optional path scope extracted from the denied command
58
+ * @param {string|null} [opts.binary] binary path; tests inject a stub. Defaults to
59
+ * `_CG_ANSWER_BINARY` env override, then findBinary().
60
+ * @param {number} [opts.timeoutMs]
61
+ * @param {number} [opts.maxBytes]
62
+ * @returns {{status: 'hits', text: string, truncated: boolean}
63
+ * | {status: 'no-hits'}
64
+ * | {status: 'unavailable'}}
65
+ */
66
+ function runGrepAnswer(opts = {}) {
67
+ const {
68
+ cwd,
69
+ pattern,
70
+ searchPath,
71
+ timeoutMs = DEFAULT_TIMEOUT_MS,
72
+ maxBytes = DEFAULT_MAX_BYTES,
73
+ } = opts;
74
+ try {
75
+ if (!pattern || typeof pattern !== 'string' || pattern.length > MAX_PATTERN_LEN) {
76
+ return { status: 'unavailable' };
77
+ }
78
+ let binary = opts.binary;
79
+ if (binary === undefined) {
80
+ binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
81
+ }
82
+ if (!binary) return { status: 'unavailable' };
83
+
84
+ const args = ['grep', pattern];
85
+ if (searchPath) args.push(searchPath);
86
+ const res = spawnSync(binary, args, {
87
+ cwd,
88
+ timeout: timeoutMs,
89
+ encoding: 'utf8',
90
+ maxBuffer: 4 * 1024 * 1024,
91
+ stdio: ['ignore', 'pipe', 'ignore'],
92
+ });
93
+ if (res.error || res.signal || res.status !== 0) {
94
+ return { status: 'unavailable' };
95
+ }
96
+ const out = (res.stdout || '').trim();
97
+ if (!out || out.startsWith(NO_MATCH_PREFIX)) {
98
+ return { status: 'no-hits' };
99
+ }
100
+ const { text, truncated } = truncateAtLine(out, maxBytes);
101
+ return { status: 'hits', text, truncated };
102
+ } catch {
103
+ return { status: 'unavailable' };
104
+ }
105
+ }
106
+
107
+ module.exports = { runGrepAnswer, truncateAtLine };
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { runGrepAnswer, truncateAtLine } = require('./cg-answer');
8
+
9
+ // Stub "binary": a node script that reacts to its first real arg so one stub
10
+ // covers hits / no-hits / error / timeout cases.
11
+ let stubDir;
12
+ let stubPath;
13
+
14
+ test.before(() => {
15
+ stubDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-answer-test-'));
16
+ stubPath = path.join(stubDir, 'cg-stub.js');
17
+ fs.writeFileSync(stubPath, `#!/usr/bin/env node
18
+ 'use strict';
19
+ const pattern = process.argv[3] || '';
20
+ if (pattern === 'HangForever') { setTimeout(() => {}, 60000); }
21
+ else if (pattern === 'ExplodePlease') { process.exit(3); }
22
+ else if (pattern === 'NothingHere') {
23
+ process.stdout.write('[code-graph] No matches for: NothingHere\\n');
24
+ } else {
25
+ process.stdout.write(
26
+ 'src/storage/db.rs:42 fn ' + pattern + '() {\\n' +
27
+ ' -> fn ' + pattern + ' (lines 42-60)\\n' +
28
+ 'args=' + JSON.stringify(process.argv.slice(2)) + '\\n');
29
+ }
30
+ `);
31
+ });
32
+
33
+ test.after(() => {
34
+ fs.rmSync(stubDir, { recursive: true, force: true });
35
+ });
36
+
37
+ // Wrap the stub so spawnSync can exec it directly: binary = node, leading arg
38
+ // trick is not possible (runGrepAnswer controls args), so expose via a shim
39
+ // shell-free approach: point binary at node and prepend the script through
40
+ // _CG_ANSWER_BINARY handling is binary-only. Instead make the stub itself
41
+ // executable with a node shebang and rely on exec.
42
+ function stubBinary() {
43
+ fs.chmodSync(stubPath, 0o755);
44
+ return stubPath;
45
+ }
46
+
47
+ test('runGrepAnswer: hits → status hits with stdout text', () => {
48
+ const r = runGrepAnswer({ cwd: stubDir, pattern: 'fts5_search', binary: stubBinary() });
49
+ assert.equal(r.status, 'hits');
50
+ assert.match(r.text, /fn fts5_search/);
51
+ });
52
+
53
+ test('runGrepAnswer: passes grep subcommand, pattern and path as argv', () => {
54
+ const r = runGrepAnswer({
55
+ cwd: stubDir, pattern: 'fts5_search', searchPath: 'src/storage/', binary: stubBinary(),
56
+ });
57
+ assert.equal(r.status, 'hits');
58
+ assert.match(r.text, /args=\["grep","fts5_search","src\/storage\/"\]/);
59
+ });
60
+
61
+ test('runGrepAnswer: omits path argv when no searchPath', () => {
62
+ const r = runGrepAnswer({ cwd: stubDir, pattern: 'fts5_search', binary: stubBinary() });
63
+ assert.match(r.text, /args=\["grep","fts5_search"\]/);
64
+ });
65
+
66
+ test('runGrepAnswer: CLI "[code-graph] No matches" → status no-hits', () => {
67
+ const r = runGrepAnswer({ cwd: stubDir, pattern: 'NothingHere', binary: stubBinary() });
68
+ assert.equal(r.status, 'no-hits');
69
+ });
70
+
71
+ test('runGrepAnswer: nonzero exit → unavailable', () => {
72
+ const r = runGrepAnswer({ cwd: stubDir, pattern: 'ExplodePlease', binary: stubBinary() });
73
+ assert.equal(r.status, 'unavailable');
74
+ });
75
+
76
+ test('runGrepAnswer: missing binary → unavailable', () => {
77
+ const r = runGrepAnswer({ cwd: stubDir, pattern: 'fts5_search', binary: null });
78
+ assert.equal(r.status, 'unavailable');
79
+ });
80
+
81
+ test('runGrepAnswer: nonexistent binary path → unavailable', () => {
82
+ const r = runGrepAnswer({
83
+ cwd: stubDir, pattern: 'fts5_search', binary: path.join(stubDir, 'nope-bin'),
84
+ });
85
+ assert.equal(r.status, 'unavailable');
86
+ });
87
+
88
+ test('runGrepAnswer: timeout → unavailable', () => {
89
+ const r = runGrepAnswer({
90
+ cwd: stubDir, pattern: 'HangForever', binary: stubBinary(), timeoutMs: 300,
91
+ });
92
+ assert.equal(r.status, 'unavailable');
93
+ });
94
+
95
+ test('runGrepAnswer: empty pattern → unavailable (never spawns)', () => {
96
+ const r = runGrepAnswer({ cwd: stubDir, pattern: '', binary: stubBinary() });
97
+ assert.equal(r.status, 'unavailable');
98
+ });
99
+
100
+ test('runGrepAnswer: oversized pattern (>200ch) → unavailable (never spawns)', () => {
101
+ const r = runGrepAnswer({ cwd: stubDir, pattern: 'A'.repeat(201), binary: stubBinary() });
102
+ assert.equal(r.status, 'unavailable');
103
+ });
104
+
105
+ test('runGrepAnswer: long output is truncated with marker', () => {
106
+ // Stub echoes args= line; force truncation via tiny maxBytes
107
+ const r = runGrepAnswer({
108
+ cwd: stubDir, pattern: 'fts5_search', binary: stubBinary(), maxBytes: 30,
109
+ });
110
+ assert.equal(r.status, 'hits');
111
+ assert.equal(r.truncated, true);
112
+ assert.ok(Buffer.byteLength(r.text, 'utf8') <= 30);
113
+ });
114
+
115
+ // ── truncateAtLine (pure) ───────────────────────────────────────────
116
+
117
+ test('truncateAtLine: under limit → unchanged, not truncated', () => {
118
+ const { text, truncated } = truncateAtLine('a\nb\nc', 100);
119
+ assert.equal(text, 'a\nb\nc');
120
+ assert.equal(truncated, false);
121
+ });
122
+
123
+ test('truncateAtLine: cuts at a line boundary', () => {
124
+ const input = 'line-one\nline-two\nline-three\n';
125
+ const { text, truncated } = truncateAtLine(input, 20);
126
+ assert.equal(truncated, true);
127
+ // 20-byte budget fits 'line-one\nline-two' (17B); the half-cut 'li' is dropped
128
+ assert.equal(text, 'line-one\nline-two');
129
+ });
130
+
131
+ test('truncateAtLine: single oversized line → hard cut', () => {
132
+ const { text, truncated } = truncateAtLine('x'.repeat(50), 10);
133
+ assert.equal(truncated, true);
134
+ assert.equal(Buffer.byteLength(text, 'utf8'), 10);
135
+ });
@@ -30,6 +30,7 @@ const path = require('path');
30
30
  const crypto = require('crypto');
31
31
  const { cgTmpDir } = require('./tmp-dir');
32
32
  const { recordRecommendation } = require('./recommendation-log');
33
+ const { runGrepAnswer } = require('./cg-answer');
33
34
 
34
35
  // --- Pure logic (testable) ---
35
36
 
@@ -43,7 +44,11 @@ const GREP_HEAD = /^\s*(?:env\s+\S+=\S+\s+)*(grep|rg|ag)\b/;
43
44
  // entities/migrations/tasks/jobs/workers/features/modules/api/web. Generic
44
45
  // terms like `core`/`utils`/`shared`/`common`/`types` deliberately omitted —
45
46
  // they appear in too many non-code contexts to be precise enough.
46
- const SRC_PATH = /(?:^|\s|["'])(src|tests|lib|libs|scripts|claude-plugin|tools|pkg|cmd|internal|app|apps|components?|server|client|crates|packages|backend|frontend|services|models|domain|controllers|views|handlers|middleware|routes|repositories|entities|migrations|tasks|jobs|workers|features|modules|api|web)\//;
47
+ const SRC_PREFIXES =
48
+ 'src|tests|lib|libs|scripts|claude-plugin|tools|pkg|cmd|internal|app|apps|components?|server|client|crates|packages|backend|frontend|services|models|domain|controllers|views|handlers|middleware|routes|repositories|entities|migrations|tasks|jobs|workers|features|modules|api|web';
49
+ const SRC_PATH = new RegExp(`(?:^|\\s|["'])(${SRC_PREFIXES})/`);
50
+ // Anchored variant for whole-token matching in extractSearchPath.
51
+ const SRC_PATH_TOKEN = new RegExp(`^(?:\\./)?(${SRC_PREFIXES})/`);
47
52
  const PIPE_INTO_GREP = /\|\s*(?:grep|rg|ag)\b/;
48
53
  const CG_INVOKED = /\bcode-graph-mcp\b/;
49
54
  // A file argument that ends in a config/lockfile extension AND no source-tree
@@ -112,6 +117,38 @@ function shouldBlock(cmd) {
112
117
  return patterns.some(p => IDENTIFIER_LIKE.test(p));
113
118
  }
114
119
 
120
+ // v0.47.1 — CC harness steers Bash toward ABSOLUTE paths (cd in compound
121
+ // commands triggers permission prompts), so `grep -rn "X" /abs/root/backend/…`
122
+ // is the dominant real shape — and SRC_PATH's lookbehind (^|\s|quote) never
123
+ // matched it (daagu 2026-06-11 replay: 42/42 head-greps absolute → 1 hint /
124
+ // 0 block as-is vs 30 / 16 after this strip). Strip `<cwd>/` everywhere before
125
+ // matching: the hook's cwd IS the project root, so this is exact — paths
126
+ // outside the project stay absolute and keep not firing (conservative edge).
127
+ // split/join, not regex: cwd may contain regex metacharacters.
128
+ function normalizeCommandPaths(cmd, cwd) {
129
+ if (!cmd || typeof cmd !== 'string') return cmd;
130
+ if (!cwd || typeof cwd !== 'string' || cwd === '/') return cmd;
131
+ return cmd.split(cwd.endsWith('/') ? cwd : cwd + '/').join('');
132
+ }
133
+
134
+ // v0.47.0 — pull the first source-tree path token out of the denied command so
135
+ // the inline answer can scope its search the same way the raw grep would have.
136
+ function extractSearchPath(cmd) {
137
+ if (!cmd || typeof cmd !== 'string') return undefined;
138
+ for (const raw of cmd.split(/\s+/)) {
139
+ const token = raw.replace(/^["']|["']$/g, '');
140
+ if (!token || token.startsWith('-')) continue;
141
+ if (token.includes('..')) return undefined; // traversal — don't scope, don't guess
142
+ if (SRC_PATH_TOKEN.test(token)) return token;
143
+ }
144
+ return undefined;
145
+ }
146
+
147
+ // v0.47.0 — the pattern that justified the block: first identifier-like one.
148
+ function pickBlockPattern(cmd) {
149
+ return extractPatterns(cmd).find(p => IDENTIFIER_LIKE.test(p));
150
+ }
151
+
115
152
  function commandHash(cmd) {
116
153
  return crypto.createHash('sha1').update(cmd).digest('hex').slice(0, 12);
117
154
  }
@@ -155,6 +192,34 @@ function buildBlockReason() {
155
192
  ].join('\n');
156
193
  }
157
194
 
195
+ // v0.47.0 — deny WITH the answer inline. Hint-only had ~0% transfer and a bare
196
+ // deny still asks the model to initiate a new tool call; embedding the actual
197
+ // results removes that choice entirely. Keep the escape hatch line — raw-text
198
+ // regex (BRE alternation, log scans) remains a legitimate need.
199
+ function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
200
+ const cmdShown = `code-graph-mcp grep "${pattern}"${searchPath ? ` ${searchPath}` : ''}`;
201
+ const lines = [
202
+ '[code-graph] Raw `grep` on indexed source — denied; the AST-aware equivalent already ran for you:',
203
+ `$ ${cmdShown}`,
204
+ answer.text,
205
+ ];
206
+ if (answer.truncated) {
207
+ lines.push(`(truncated — run \`${cmdShown}\` yourself for the full list)`);
208
+ }
209
+ lines.push(
210
+ '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
+ );
213
+ return lines.join('\n');
214
+ }
215
+
216
+ // v0.47.0 — cg grep found nothing. Regex-dialect differences (BRE `\|` vs
217
+ // ripgrep) mean 0 hits is NOT proof of absence, so denying here could mislead.
218
+ // Let the raw grep through with an honest one-liner.
219
+ function buildNoHitsFyi(pattern) {
220
+ return `[code-graph] FYI: \`code-graph-mcp grep "${pattern}"\` found no matches — raw grep proceeding.`;
221
+ }
222
+
158
223
  // --- Main execution (only when run directly) ---
159
224
 
160
225
  // Kill switch: matches user-prompt-context.js convention. =1 forces silence
@@ -172,6 +237,12 @@ function isBlockDisabled(env = process.env) {
172
237
  return env.CODE_GRAPH_NO_BLOCK_GREP === '1';
173
238
  }
174
239
 
240
+ // v0.47.0 — opt-out for the inline-answer tier only: =1 restores the v0.46
241
+ // static deny (no CLI run inside the hook). Independent of NO_BLOCK_GREP.
242
+ function isAnswerDisabled(env = process.env) {
243
+ return env.CODE_GRAPH_NO_ANSWER_IN_DENY === '1';
244
+ }
245
+
175
246
  function runMain() {
176
247
  if (isSilenced()) return;
177
248
  const cwd = process.cwd();
@@ -180,27 +251,54 @@ function runMain() {
180
251
 
181
252
  let input;
182
253
  try {
183
- input = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
254
+ // fd 0, not '/dev/stdin': the path form open(2)s the symlink target, which
255
+ // fails with ENXIO when stdin is a socketpair (e.g. spawnSync {input}).
256
+ // Reading the fd directly works for pipes, sockets, and files alike.
257
+ input = JSON.parse(fs.readFileSync(0, 'utf8'));
184
258
  } catch { return; }
185
259
 
186
- const cmd = (input.tool_input && input.tool_input.command) || '';
260
+ const rawCmd = (input.tool_input && input.tool_input.command) || '';
261
+ // v0.47.1 — match against the cwd-stripped form so absolute paths under the
262
+ // project root behave exactly like their relative spelling. Cooldown stays
263
+ // keyed on the raw command (what Claude actually sent).
264
+ const cmd = normalizeCommandPaths(rawCmd, cwd);
187
265
  if (!shouldHint(cmd)) return;
188
- if (isOnCooldown(cmd)) return;
266
+ if (isOnCooldown(rawCmd)) return;
189
267
 
190
- markCooldown(cmd);
268
+ markCooldown(rawCmd);
191
269
 
192
270
  if (!isBlockDisabled() && shouldBlock(cmd)) {
271
+ // v0.47.0 — run the AST-aware equivalent inside the hook and embed the
272
+ // results in the deny reason ("answer in the deny"). Degrades to the
273
+ // v0.46 static deny on any failure; downgrades to allow+FYI on 0 hits
274
+ // (regex-dialect differences mean 0 hits ≠ proof of absence).
275
+ let answer = { status: 'unavailable' };
276
+ const pattern = pickBlockPattern(cmd);
277
+ if (!isAnswerDisabled() && pattern) {
278
+ answer = runGrepAnswer({ cwd, pattern, searchPath: extractSearchPath(cmd) });
279
+ }
280
+
281
+ if (answer.status === 'no-hits') {
282
+ recordRecommendation(cwd, { hook: 'grep', action: 'hint', fallthrough: 'no-hits' });
283
+ process.stdout.write(buildNoHitsFyi(pattern) + '\n');
284
+ return;
285
+ }
286
+
193
287
  // PreToolUse block via current CC schema (`hookSpecificOutput.permissionDecision`).
194
288
  // Verified empirically 2026-05-24: legacy `{decision:"block",reason}` was
195
289
  // ignored by Claude Code — the grep ran anyway. The hookSpecificOutput form
196
290
  // is the documented modern path. Exit 0 — this is a routing decision, not
197
291
  // a hook failure (exit 2 would mark the tool call as "hook errored").
198
- recordRecommendation(cwd, { hook: 'grep', action: 'deny' });
292
+ recordRecommendation(cwd, {
293
+ hook: 'grep', action: 'deny', answered: answer.status === 'hits',
294
+ });
199
295
  process.stdout.write(JSON.stringify({
200
296
  hookSpecificOutput: {
201
297
  hookEventName: 'PreToolUse',
202
298
  permissionDecision: 'deny',
203
- permissionDecisionReason: buildBlockReason(),
299
+ permissionDecisionReason: answer.status === 'hits'
300
+ ? buildBlockReasonWithAnswer(pattern, extractSearchPath(cmd), answer)
301
+ : buildBlockReason(),
204
302
  },
205
303
  }) + '\n');
206
304
  return;
@@ -218,11 +316,17 @@ module.exports = {
218
316
  shouldHint,
219
317
  shouldBlock,
220
318
  extractPatterns, // v0.32.1 — exposed for tests
319
+ extractSearchPath, // v0.47.0 — deny-with-answer
320
+ normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
321
+ pickBlockPattern,
221
322
  buildHint,
222
323
  buildBlockReason,
324
+ buildBlockReasonWithAnswer,
325
+ buildNoHitsFyi,
223
326
  commandHash,
224
327
  isOnCooldown,
225
328
  markCooldown,
226
329
  isSilenced,
227
330
  isBlockDisabled,
331
+ isAnswerDisabled,
228
332
  };
@@ -5,11 +5,17 @@ const {
5
5
  shouldHint,
6
6
  shouldBlock,
7
7
  extractPatterns,
8
+ extractSearchPath,
9
+ normalizeCommandPaths,
10
+ pickBlockPattern,
8
11
  buildHint,
9
12
  buildBlockReason,
13
+ buildBlockReasonWithAnswer,
14
+ buildNoHitsFyi,
10
15
  commandHash,
11
16
  isSilenced,
12
17
  isBlockDisabled,
18
+ isAnswerDisabled,
13
19
  } = require('./pre-grep-guide');
14
20
 
15
21
  // ── Should fire: bare grep/rg/ag on indexed source tree ─────────────
@@ -524,3 +530,310 @@ test('I4: grep -rn "def calc_total" src/ → BLOCK (def at start + snake_case)',
524
530
  test('I4: grep -rn "fn render" src/ → BLOCK (decl anchor at start)', () => {
525
531
  assert.equal(shouldBlock('grep -rn "fn render" src/'), true);
526
532
  });
533
+
534
+ // ── v0.47.1 abs-path matcher fix: normalizeCommandPaths ─────────────
535
+ // CC harness steers Bash toward ABSOLUTE paths (cd in compound commands
536
+ // triggers permission prompts), so `grep -rn "X" /abs/root/backend/...` is
537
+ // the dominant real-world shape. SRC_PATH's lookbehind (^|\s|quote) never
538
+ // matched it: daagu 2026-06-11 replay — 42/42 head-greps absolute, 1 hint /
539
+ // 0 block as-is vs 30 hint / 16 block after cwd-strip.
540
+
541
+ test('normalizeCommandPaths: strips cwd prefix from path args', () => {
542
+ assert.equal(
543
+ normalizeCommandPaths('grep -rn "X" /proj/root/src/storage/', '/proj/root'),
544
+ 'grep -rn "X" src/storage/');
545
+ });
546
+
547
+ test('normalizeCommandPaths: strips every occurrence', () => {
548
+ assert.equal(
549
+ normalizeCommandPaths('grep -rn "X" /proj/root/src/a.rs /proj/root/tests/', '/proj/root'),
550
+ 'grep -rn "X" src/a.rs tests/');
551
+ });
552
+
553
+ test('normalizeCommandPaths: strips inside quotes', () => {
554
+ assert.equal(
555
+ normalizeCommandPaths('grep -rn "X" "/proj/root/backend/app/"', '/proj/root'),
556
+ 'grep -rn "X" "backend/app/"');
557
+ });
558
+
559
+ test('normalizeCommandPaths: leaves foreign absolute paths alone', () => {
560
+ assert.equal(
561
+ normalizeCommandPaths('grep -rn "X" /other/place/src/', '/proj/root'),
562
+ 'grep -rn "X" /other/place/src/');
563
+ });
564
+
565
+ test('normalizeCommandPaths: no-op when cwd absent / falsy inputs', () => {
566
+ assert.equal(normalizeCommandPaths('grep -rn "X" src/', '/proj/root'), 'grep -rn "X" src/');
567
+ assert.equal(normalizeCommandPaths('', '/proj/root'), '');
568
+ assert.equal(normalizeCommandPaths('grep "X" src/', ''), 'grep "X" src/');
569
+ });
570
+
571
+ // Real daagu transcript commands (2026-06-11 session 23f149f0…), the exact
572
+ // shape that was invisible to v0.47.0. Replay must fire post-normalization.
573
+ const DAAGU = '/mnt/data_ssd/dev/projects/daagu';
574
+
575
+ test('replay: real abs-path symbol grep → BLOCK after normalization', () => {
576
+ const cmd = `grep -n "_parse_finish_reason\\|_last_finish_reason\\|class OpenRouterProvider" ${DAAGU}/backend/app/services/llm_engine/openrouter.py`;
577
+ assert.equal(shouldHint(cmd), false); // documents the v0.47.0 blindspot
578
+ const norm = normalizeCommandPaths(cmd, DAAGU);
579
+ assert.equal(shouldHint(norm), true);
580
+ assert.equal(shouldBlock(norm), true);
581
+ });
582
+
583
+ test('replay: real abs-path -rln grep → HINT only after normalization (precision flag)', () => {
584
+ const cmd = `grep -rln "load_active_config_standalone" ${DAAGU}/backend/tests/ | head -5`;
585
+ const norm = normalizeCommandPaths(cmd, DAAGU);
586
+ assert.equal(shouldHint(norm), true);
587
+ assert.equal(shouldBlock(norm), false); // -l cluster disqualifies block
588
+ });
589
+
590
+ test('replay: abs-path config-only grep stays silent after normalization', () => {
591
+ const cmd = `grep -n '"typecheck"\\|"type-check"\\|vue-tsc' ${DAAGU}/frontend/package.json`;
592
+ assert.equal(shouldHint(normalizeCommandPaths(cmd, DAAGU)), false);
593
+ });
594
+
595
+ test('replay: extractSearchPath gets relative path from normalized abs command', () => {
596
+ const cmd = `grep -rn "config_version" ${DAAGU}/backend/app/services/stock_picker/data_providers.py 2>/dev/null | head -5`;
597
+ assert.equal(
598
+ extractSearchPath(normalizeCommandPaths(cmd, DAAGU)),
599
+ 'backend/app/services/stock_picker/data_providers.py');
600
+ });
601
+
602
+ // ── v0.47.0 deny-with-answer: extractSearchPath / pickBlockPattern ──
603
+
604
+ test('extractSearchPath: dir path after pattern', () => {
605
+ assert.equal(extractSearchPath('grep -rn "fts5_search" src/storage/'), 'src/storage/');
606
+ });
607
+
608
+ test('extractSearchPath: single file in src/', () => {
609
+ assert.equal(
610
+ extractSearchPath('grep -n "split_identifier" src/search/tokenizer.rs'),
611
+ 'src/search/tokenizer.rs');
612
+ });
613
+
614
+ test('extractSearchPath: first of multiple paths wins', () => {
615
+ assert.equal(extractSearchPath('grep -rn "set_hook" src/main.rs src/lib.rs'), 'src/main.rs');
616
+ });
617
+
618
+ test('extractSearchPath: quoted path is unwrapped', () => {
619
+ assert.equal(extractSearchPath('grep -rn "Foo" "claude-plugin/scripts/"'), 'claude-plugin/scripts/');
620
+ });
621
+
622
+ test('extractSearchPath: flags and redirects are skipped', () => {
623
+ assert.equal(extractSearchPath('grep -rn "Foo" src/ 2>&1'), 'src/');
624
+ });
625
+
626
+ test('extractSearchPath: ./-prefixed path is accepted', () => {
627
+ assert.equal(extractSearchPath('grep -rn "Foo" ./src/parser/'), './src/parser/');
628
+ });
629
+
630
+ test('extractSearchPath: path traversal is rejected', () => {
631
+ assert.equal(extractSearchPath('grep -rn "Foo" src/../../etc/'), undefined);
632
+ });
633
+
634
+ test('extractSearchPath: no source path → undefined', () => {
635
+ assert.equal(extractSearchPath('grep -rn "Foo"'), undefined);
636
+ });
637
+
638
+ test('pickBlockPattern: returns the identifier-like pattern', () => {
639
+ assert.equal(pickBlockPattern('grep -rn "EmbeddingModel" src/'), 'EmbeddingModel');
640
+ });
641
+
642
+ test('pickBlockPattern: skips non-identifier, picks identifier from -e args', () => {
643
+ assert.equal(
644
+ pickBlockPattern('grep -rn -e "some words" -e "fts5_search" src/'),
645
+ 'fts5_search');
646
+ });
647
+
648
+ test('pickBlockPattern: no identifier-like pattern → undefined', () => {
649
+ assert.equal(pickBlockPattern('grep -rn "no ident here" src/'), undefined);
650
+ });
651
+
652
+ // ── v0.47.0 deny-with-answer: message builders + env gate ───────────
653
+
654
+ test('buildBlockReasonWithAnswer: embeds results, command, and escape hatch', () => {
655
+ const reason = buildBlockReasonWithAnswer('fts5_search', 'src/storage/', {
656
+ status: 'hits', text: 'src/storage/db.rs:42 fn fts5_search()', truncated: false,
657
+ });
658
+ assert.match(reason, /already ran/);
659
+ assert.match(reason, /code-graph-mcp grep "fts5_search" src\/storage\//);
660
+ assert.match(reason, /src\/storage\/db\.rs:42/);
661
+ assert.match(reason, /CODE_GRAPH_NO_BLOCK_GREP=1/);
662
+ assert.doesNotMatch(reason, /truncated/);
663
+ });
664
+
665
+ test('buildBlockReasonWithAnswer: no searchPath → command has no path arg', () => {
666
+ const reason = buildBlockReasonWithAnswer('fts5_search', undefined, {
667
+ status: 'hits', text: 'hit', truncated: false,
668
+ });
669
+ assert.match(reason, /code-graph-mcp grep "fts5_search"\n/);
670
+ });
671
+
672
+ test('buildBlockReasonWithAnswer: truncated flag adds marker', () => {
673
+ const reason = buildBlockReasonWithAnswer('fts5_search', 'src/', {
674
+ status: 'hits', text: 'hit', truncated: true,
675
+ });
676
+ assert.match(reason, /truncated/);
677
+ });
678
+
679
+ test('buildNoHitsFyi: names the pattern and says raw grep proceeds', () => {
680
+ const fyi = buildNoHitsFyi('GhostSymbol');
681
+ assert.match(fyi, /GhostSymbol/);
682
+ assert.match(fyi, /[Nn]o matches/);
683
+ });
684
+
685
+ test('isAnswerDisabled: only env=1 disables', () => {
686
+ assert.equal(isAnswerDisabled({ CODE_GRAPH_NO_ANSWER_IN_DENY: '1' }), true);
687
+ assert.equal(isAnswerDisabled({ CODE_GRAPH_NO_ANSWER_IN_DENY: '0' }), false);
688
+ assert.equal(isAnswerDisabled({}), false);
689
+ });
690
+
691
+ // ── v0.47.0 deny-with-answer: stdin-spawn e2e with stub binary ──────
692
+
693
+ const { spawnSync: spawnHook } = require('child_process');
694
+ const fsE2e = require('fs');
695
+ const osE2e = require('os');
696
+ const pathE2e = require('path');
697
+ const { cgTmpDir } = require('./tmp-dir');
698
+
699
+ function e2eFixture(stubBody) {
700
+ const dir = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'pre-grep-e2e-'));
701
+ fsE2e.mkdirSync(pathE2e.join(dir, '.code-graph'), { recursive: true });
702
+ fsE2e.writeFileSync(pathE2e.join(dir, '.code-graph', 'index.db'), '');
703
+ const stub = pathE2e.join(dir, 'cg-stub.js');
704
+ fsE2e.writeFileSync(stub, '#!/usr/bin/env node\n' + stubBody);
705
+ fsE2e.chmodSync(stub, 0o755);
706
+ return { dir, stub };
707
+ }
708
+
709
+ function runHook(cmd, fixture) {
710
+ const res = spawnHook(process.execPath, [pathE2e.join(__dirname, 'pre-grep-guide.js')], {
711
+ cwd: fixture.dir,
712
+ input: JSON.stringify({ tool_input: { command: cmd } }),
713
+ encoding: 'utf8',
714
+ env: {
715
+ ...process.env,
716
+ _CG_ANSWER_BINARY: fixture.stub,
717
+ CODE_GRAPH_QUIET_HOOKS: '0',
718
+ CODE_GRAPH_NO_BLOCK_GREP: '0',
719
+ CODE_GRAPH_NO_ANSWER_IN_DENY: '0',
720
+ },
721
+ });
722
+ return res;
723
+ }
724
+
725
+ function cleanupFixture(fixture, cmd) {
726
+ fsE2e.rmSync(fixture.dir, { recursive: true, force: true });
727
+ // cooldown flag for this command lives in cgTmpDir — remove so reruns stay deterministic
728
+ try {
729
+ fsE2e.unlinkSync(pathE2e.join(cgTmpDir(), `.code-graph-bash-${commandHash(cmd)}`));
730
+ } catch { /* ok */ }
731
+ }
732
+
733
+ test('e2e: denied grep with stub hits → deny JSON embeds the answer + records answered:true', () => {
734
+ const uniq = `StubHit${Date.now()}`;
735
+ const fixture = e2eFixture(
736
+ `process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
737
+ const cmd = `grep -rn "${uniq}" src/`;
738
+ try {
739
+ const res = runHook(cmd, fixture);
740
+ assert.equal(res.status, 0);
741
+ const out = JSON.parse(res.stdout);
742
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
743
+ assert.match(out.hookSpecificOutput.permissionDecisionReason, /src\/foo\.rs:7/);
744
+ assert.match(out.hookSpecificOutput.permissionDecisionReason, new RegExp(uniq));
745
+ const recs = fsE2e.readFileSync(
746
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
747
+ const rec = JSON.parse(recs.trim().split('\n').pop());
748
+ assert.equal(rec.action, 'deny');
749
+ assert.equal(rec.answered, true);
750
+ } finally {
751
+ cleanupFixture(fixture, cmd);
752
+ }
753
+ });
754
+
755
+ test('e2e: stub reports no matches → grep allowed with FYI + records fallthrough', () => {
756
+ const uniq = `StubMiss${Date.now()}`;
757
+ const fixture = e2eFixture(
758
+ `process.stdout.write('[code-graph] No matches for: ' + process.argv[3] + '\\n');`);
759
+ const cmd = `grep -rn "${uniq}" src/`;
760
+ try {
761
+ const res = runHook(cmd, fixture);
762
+ assert.equal(res.status, 0);
763
+ // No deny JSON — plain FYI text means the grep proceeds
764
+ assert.throws(() => JSON.parse(res.stdout));
765
+ assert.match(res.stdout, /[Nn]o matches/);
766
+ const recs = fsE2e.readFileSync(
767
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
768
+ const rec = JSON.parse(recs.trim().split('\n').pop());
769
+ assert.equal(rec.action, 'hint');
770
+ assert.equal(rec.fallthrough, 'no-hits');
771
+ } finally {
772
+ cleanupFixture(fixture, cmd);
773
+ }
774
+ });
775
+
776
+ test('e2e: stub fails → static deny (v0.46 fallback) + records answered:false', () => {
777
+ const uniq = `StubBoom${Date.now()}`;
778
+ const fixture = e2eFixture(`process.exit(3);`);
779
+ const cmd = `grep -rn "${uniq}" src/`;
780
+ try {
781
+ const res = runHook(cmd, fixture);
782
+ assert.equal(res.status, 0);
783
+ const out = JSON.parse(res.stdout);
784
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
785
+ // static reason, no embedded results
786
+ assert.match(out.hookSpecificOutput.permissionDecisionReason, /denied by code-graph hook/);
787
+ const rec = JSON.parse(fsE2e.readFileSync(
788
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim());
789
+ assert.equal(rec.action, 'deny');
790
+ assert.equal(rec.answered, false);
791
+ } finally {
792
+ cleanupFixture(fixture, cmd);
793
+ }
794
+ });
795
+
796
+ test('e2e: ABS-path grep under fixture root → deny fires, CLI argv gets relative path', () => {
797
+ const uniq = `StubAbs${Date.now()}`;
798
+ const fixture = e2eFixture(
799
+ `process.stdout.write('args=' + JSON.stringify(process.argv.slice(2)) + '\\n');`);
800
+ // fs.realpathSync: on macOS/Linux tmpdir may be a symlink; the hook sees the
801
+ // resolved cwd, so build the command from the same resolved form.
802
+ const realDir = fsE2e.realpathSync(fixture.dir);
803
+ const cmd = `grep -rn "${uniq}" ${realDir}/src/storage/`;
804
+ try {
805
+ const res = runHook(cmd, fixture);
806
+ assert.equal(res.status, 0);
807
+ const out = JSON.parse(res.stdout);
808
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
809
+ assert.match(out.hookSpecificOutput.permissionDecisionReason,
810
+ /args=\["grep","StubAbs\d+","src\/storage\/"\]/);
811
+ } finally {
812
+ cleanupFixture(fixture, cmd);
813
+ }
814
+ });
815
+
816
+ test('e2e: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → static deny even when stub would hit', () => {
817
+ const uniq = `StubOptout${Date.now()}`;
818
+ const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 hit\\n');`);
819
+ const cmd = `grep -rn "${uniq}" src/`;
820
+ try {
821
+ const res = spawnHook(process.execPath, [pathE2e.join(__dirname, 'pre-grep-guide.js')], {
822
+ cwd: fixture.dir,
823
+ input: JSON.stringify({ tool_input: { command: cmd } }),
824
+ encoding: 'utf8',
825
+ env: {
826
+ ...process.env,
827
+ _CG_ANSWER_BINARY: fixture.stub,
828
+ CODE_GRAPH_QUIET_HOOKS: '0',
829
+ CODE_GRAPH_NO_BLOCK_GREP: '0',
830
+ CODE_GRAPH_NO_ANSWER_IN_DENY: '1',
831
+ },
832
+ });
833
+ const out = JSON.parse(res.stdout);
834
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
835
+ assert.doesNotMatch(out.hookSpecificOutput.permissionDecisionReason, /src\/foo\.rs:7/);
836
+ } finally {
837
+ cleanupFixture(fixture, cmd);
838
+ }
839
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.46.0",
3
+ "version": "0.47.1",
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.46.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.46.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.46.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.46.0",
42
- "@sdsrs/code-graph-win32-x64": "0.46.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.47.1",
39
+ "@sdsrs/code-graph-linux-arm64": "0.47.1",
40
+ "@sdsrs/code-graph-darwin-x64": "0.47.1",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.47.1",
42
+ "@sdsrs/code-graph-win32-x64": "0.47.1"
43
43
  }
44
44
  }