@sdsrs/code-graph 0.45.4 → 0.47.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.45.4",
7
+ "version": "0.47.0",
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
+ });
@@ -206,19 +206,28 @@ function runDiagnostics() {
206
206
  try {
207
207
  const settings = readJson(settingsPath()) || {};
208
208
  const cov = surveyHookCoverage(settings);
209
- if (cov.missing.length === 0) {
209
+ if (cov.missing.length === 0 && cov.stale.length === 0) {
210
210
  results.push({
211
211
  name: 'Hook coverage',
212
212
  status: 'ok',
213
213
  detail: `settings.json has all ${cov.expected.length} expected entries`,
214
214
  });
215
- } else {
215
+ } else if (cov.missing.length > 0) {
216
216
  results.push({
217
217
  name: 'Hook coverage',
218
218
  status: 'warn',
219
219
  detail: `missing ${cov.missing.length}/${cov.expected.length} settings.json entries: ${cov.missing.join(', ')}`,
220
220
  fixId: 'missing-hooks-in-settings',
221
221
  });
222
+ } else {
223
+ // Present but stale path(s) — re-register rewrites them to the current
224
+ // version. A stale PreToolUse hook can keep the conversion metric dark.
225
+ results.push({
226
+ name: 'Hook coverage',
227
+ status: 'warn',
228
+ detail: `${cov.stale.length}/${cov.expected.length} settings.json entries point at a stale path (re-register to current version): ${cov.stale.join(', ')}`,
229
+ fixId: 'missing-hooks-in-settings',
230
+ });
222
231
  }
223
232
  } catch { /* probe failed — skip */ }
224
233
 
@@ -230,26 +239,42 @@ function runDiagnostics() {
230
239
  function surveyHookCoverage(settings) {
231
240
  const desired = buildSettingsHookEntries();
232
241
  const expected = [];
242
+ const desiredCmd = {}; // key -> command string we would write now
233
243
  for (const [event, entries] of Object.entries(desired)) {
234
244
  for (const e of entries) {
235
- expected.push(`${event}:${e.matcher || '*'}`);
245
+ const key = `${event}:${e.matcher || '*'}`;
246
+ expected.push(key);
247
+ desiredCmd[key] = e.hooks && e.hooks[0] && e.hooks[0].command;
236
248
  }
237
249
  }
238
250
 
239
251
  const present = new Set();
252
+ const presentCmd = {}; // key -> command currently registered
240
253
  if (settings && settings.hooks) {
241
254
  for (const [event, entries] of Object.entries(settings.hooks)) {
242
255
  if (!Array.isArray(entries)) continue;
243
256
  for (const entry of entries) {
244
257
  if (isOurHookEntry(entry)) {
245
- present.add(`${event}:${entry.matcher || '*'}`);
258
+ const key = `${event}:${entry.matcher || '*'}`;
259
+ present.add(key);
260
+ if (entry.hooks && entry.hooks[0] && entry.hooks[0].command) {
261
+ presentCmd[key] = entry.hooks[0].command;
262
+ }
246
263
  }
247
264
  }
248
265
  }
249
266
  }
250
267
 
251
268
  const missing = expected.filter(k => !present.has(k));
252
- return { expected, present: [...present], missing };
269
+ // Stale = present but the registered command no longer matches what we'd write
270
+ // now (points at an old plugin-cache version dir / moved path). A stale path can
271
+ // run pre-recordRecommendation hook code, so the hook fires but the conversion
272
+ // metric stays dark — invisible to a present/absent check. This is the
273
+ // 0.45.1-registered-while-0.45.4-active case the RCA surfaced.
274
+ const stale = expected.filter(k =>
275
+ present.has(k) && desiredCmd[k] && presentCmd[k] && presentCmd[k] !== desiredCmd[k]
276
+ );
277
+ return { expected, present: [...present], missing, stale };
253
278
  }
254
279
 
255
280
  // ── Report Formatting ─────────────────────────────────────
@@ -449,7 +474,7 @@ function runDoctor(opts = {}) {
449
474
  return { results, issueCount: issues.length };
450
475
  }
451
476
 
452
- module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor };
477
+ module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, surveyHookCoverage };
453
478
 
454
479
  if (require.main === module) {
455
480
  const args = process.argv.slice(2);
@@ -2,7 +2,18 @@
2
2
  const test = require('node:test');
3
3
  const assert = require('node:assert/strict');
4
4
 
5
- const { runDiagnostics, formatReport } = require('./doctor');
5
+ const { runDiagnostics, formatReport, surveyHookCoverage } = require('./doctor');
6
+ const { buildSettingsHookEntries } = require('./lifecycle');
7
+
8
+ // Build a settings.json whose hooks exactly mirror what we'd register now.
9
+ function settingsWithCurrentHooks() {
10
+ const desired = buildSettingsHookEntries();
11
+ const hooks = {};
12
+ for (const [event, entries] of Object.entries(desired)) {
13
+ hooks[event] = entries.map(e => JSON.parse(JSON.stringify(e)));
14
+ }
15
+ return { hooks };
16
+ }
6
17
 
7
18
  test('runDiagnostics returns an array of check results', () => {
8
19
  const results = runDiagnostics();
@@ -45,3 +56,27 @@ test('formatReport shows all-clear when no problems', () => {
45
56
  const output = formatReport(results);
46
57
  assert.ok(output.includes('All checks passed') || output.includes('0 issues'));
47
58
  });
59
+
60
+ test('surveyHookCoverage reports clean when all entries are current', () => {
61
+ const cov = surveyHookCoverage(settingsWithCurrentHooks());
62
+ assert.equal(cov.missing.length, 0, 'no missing entries');
63
+ assert.equal(cov.stale.length, 0, 'no stale entries');
64
+ });
65
+
66
+ test('surveyHookCoverage flags a present-but-stale hook path', () => {
67
+ const settings = settingsWithCurrentHooks();
68
+ // Repoint one PreToolUse entry at an old plugin-cache version dir — present,
69
+ // recognized as ours (description unchanged), but command no longer current.
70
+ const bash = settings.hooks.PreToolUse.find(e => e.matcher === 'Bash');
71
+ bash.hooks[0].command = bash.hooks[0].command.replace('/scripts/', '/0.0.1-old/scripts/');
72
+ const cov = surveyHookCoverage(settings);
73
+ assert.equal(cov.missing.length, 0, 'entry is present, not missing');
74
+ assert.ok(cov.stale.includes('PreToolUse:Bash'),
75
+ `stale Bash path should be flagged; got stale=${JSON.stringify(cov.stale)}`);
76
+ });
77
+
78
+ test('surveyHookCoverage flags missing entries when settings empty', () => {
79
+ const cov = surveyHookCoverage({});
80
+ assert.ok(cov.missing.length === cov.expected.length, 'all expected entries missing');
81
+ assert.equal(cov.stale.length, 0, 'nothing present to be stale');
82
+ });
@@ -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,24 @@ function shouldBlock(cmd) {
112
117
  return patterns.some(p => IDENTIFIER_LIKE.test(p));
113
118
  }
114
119
 
120
+ // v0.47.0 — pull the first source-tree path token out of the denied command so
121
+ // the inline answer can scope its search the same way the raw grep would have.
122
+ function extractSearchPath(cmd) {
123
+ if (!cmd || typeof cmd !== 'string') return undefined;
124
+ for (const raw of cmd.split(/\s+/)) {
125
+ const token = raw.replace(/^["']|["']$/g, '');
126
+ if (!token || token.startsWith('-')) continue;
127
+ if (token.includes('..')) return undefined; // traversal — don't scope, don't guess
128
+ if (SRC_PATH_TOKEN.test(token)) return token;
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ // v0.47.0 — the pattern that justified the block: first identifier-like one.
134
+ function pickBlockPattern(cmd) {
135
+ return extractPatterns(cmd).find(p => IDENTIFIER_LIKE.test(p));
136
+ }
137
+
115
138
  function commandHash(cmd) {
116
139
  return crypto.createHash('sha1').update(cmd).digest('hex').slice(0, 12);
117
140
  }
@@ -155,6 +178,34 @@ function buildBlockReason() {
155
178
  ].join('\n');
156
179
  }
157
180
 
181
+ // v0.47.0 — deny WITH the answer inline. Hint-only had ~0% transfer and a bare
182
+ // deny still asks the model to initiate a new tool call; embedding the actual
183
+ // results removes that choice entirely. Keep the escape hatch line — raw-text
184
+ // regex (BRE alternation, log scans) remains a legitimate need.
185
+ function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
186
+ const cmdShown = `code-graph-mcp grep "${pattern}"${searchPath ? ` ${searchPath}` : ''}`;
187
+ const lines = [
188
+ '[code-graph] Raw `grep` on indexed source — denied; the AST-aware equivalent already ran for you:',
189
+ `$ ${cmdShown}`,
190
+ answer.text,
191
+ ];
192
+ if (answer.truncated) {
193
+ lines.push(`(truncated — run \`${cmdShown}\` yourself for the full list)`);
194
+ }
195
+ lines.push(
196
+ '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
+ );
199
+ return lines.join('\n');
200
+ }
201
+
202
+ // v0.47.0 — cg grep found nothing. Regex-dialect differences (BRE `\|` vs
203
+ // ripgrep) mean 0 hits is NOT proof of absence, so denying here could mislead.
204
+ // Let the raw grep through with an honest one-liner.
205
+ function buildNoHitsFyi(pattern) {
206
+ return `[code-graph] FYI: \`code-graph-mcp grep "${pattern}"\` found no matches — raw grep proceeding.`;
207
+ }
208
+
158
209
  // --- Main execution (only when run directly) ---
159
210
 
160
211
  // Kill switch: matches user-prompt-context.js convention. =1 forces silence
@@ -172,6 +223,12 @@ function isBlockDisabled(env = process.env) {
172
223
  return env.CODE_GRAPH_NO_BLOCK_GREP === '1';
173
224
  }
174
225
 
226
+ // v0.47.0 — opt-out for the inline-answer tier only: =1 restores the v0.46
227
+ // static deny (no CLI run inside the hook). Independent of NO_BLOCK_GREP.
228
+ function isAnswerDisabled(env = process.env) {
229
+ return env.CODE_GRAPH_NO_ANSWER_IN_DENY === '1';
230
+ }
231
+
175
232
  function runMain() {
176
233
  if (isSilenced()) return;
177
234
  const cwd = process.cwd();
@@ -180,7 +237,10 @@ function runMain() {
180
237
 
181
238
  let input;
182
239
  try {
183
- input = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
240
+ // fd 0, not '/dev/stdin': the path form open(2)s the symlink target, which
241
+ // fails with ENXIO when stdin is a socketpair (e.g. spawnSync {input}).
242
+ // Reading the fd directly works for pipes, sockets, and files alike.
243
+ input = JSON.parse(fs.readFileSync(0, 'utf8'));
184
244
  } catch { return; }
185
245
 
186
246
  const cmd = (input.tool_input && input.tool_input.command) || '';
@@ -190,17 +250,37 @@ function runMain() {
190
250
  markCooldown(cmd);
191
251
 
192
252
  if (!isBlockDisabled() && shouldBlock(cmd)) {
253
+ // v0.47.0 — run the AST-aware equivalent inside the hook and embed the
254
+ // results in the deny reason ("answer in the deny"). Degrades to the
255
+ // v0.46 static deny on any failure; downgrades to allow+FYI on 0 hits
256
+ // (regex-dialect differences mean 0 hits ≠ proof of absence).
257
+ let answer = { status: 'unavailable' };
258
+ const pattern = pickBlockPattern(cmd);
259
+ if (!isAnswerDisabled() && pattern) {
260
+ answer = runGrepAnswer({ cwd, pattern, searchPath: extractSearchPath(cmd) });
261
+ }
262
+
263
+ if (answer.status === 'no-hits') {
264
+ recordRecommendation(cwd, { hook: 'grep', action: 'hint', fallthrough: 'no-hits' });
265
+ process.stdout.write(buildNoHitsFyi(pattern) + '\n');
266
+ return;
267
+ }
268
+
193
269
  // PreToolUse block via current CC schema (`hookSpecificOutput.permissionDecision`).
194
270
  // Verified empirically 2026-05-24: legacy `{decision:"block",reason}` was
195
271
  // ignored by Claude Code — the grep ran anyway. The hookSpecificOutput form
196
272
  // is the documented modern path. Exit 0 — this is a routing decision, not
197
273
  // a hook failure (exit 2 would mark the tool call as "hook errored").
198
- recordRecommendation(cwd, { hook: 'grep', action: 'deny' });
274
+ recordRecommendation(cwd, {
275
+ hook: 'grep', action: 'deny', answered: answer.status === 'hits',
276
+ });
199
277
  process.stdout.write(JSON.stringify({
200
278
  hookSpecificOutput: {
201
279
  hookEventName: 'PreToolUse',
202
280
  permissionDecision: 'deny',
203
- permissionDecisionReason: buildBlockReason(),
281
+ permissionDecisionReason: answer.status === 'hits'
282
+ ? buildBlockReasonWithAnswer(pattern, extractSearchPath(cmd), answer)
283
+ : buildBlockReason(),
204
284
  },
205
285
  }) + '\n');
206
286
  return;
@@ -218,11 +298,16 @@ module.exports = {
218
298
  shouldHint,
219
299
  shouldBlock,
220
300
  extractPatterns, // v0.32.1 — exposed for tests
301
+ extractSearchPath, // v0.47.0 — deny-with-answer
302
+ pickBlockPattern,
221
303
  buildHint,
222
304
  buildBlockReason,
305
+ buildBlockReasonWithAnswer,
306
+ buildNoHitsFyi,
223
307
  commandHash,
224
308
  isOnCooldown,
225
309
  markCooldown,
226
310
  isSilenced,
227
311
  isBlockDisabled,
312
+ isAnswerDisabled,
228
313
  };
@@ -5,11 +5,16 @@ const {
5
5
  shouldHint,
6
6
  shouldBlock,
7
7
  extractPatterns,
8
+ extractSearchPath,
9
+ pickBlockPattern,
8
10
  buildHint,
9
11
  buildBlockReason,
12
+ buildBlockReasonWithAnswer,
13
+ buildNoHitsFyi,
10
14
  commandHash,
11
15
  isSilenced,
12
16
  isBlockDisabled,
17
+ isAnswerDisabled,
13
18
  } = require('./pre-grep-guide');
14
19
 
15
20
  // ── Should fire: bare grep/rg/ag on indexed source tree ─────────────
@@ -524,3 +529,222 @@ test('I4: grep -rn "def calc_total" src/ → BLOCK (def at start + snake_case)',
524
529
  test('I4: grep -rn "fn render" src/ → BLOCK (decl anchor at start)', () => {
525
530
  assert.equal(shouldBlock('grep -rn "fn render" src/'), true);
526
531
  });
532
+
533
+ // ── v0.47.0 deny-with-answer: extractSearchPath / pickBlockPattern ──
534
+
535
+ test('extractSearchPath: dir path after pattern', () => {
536
+ assert.equal(extractSearchPath('grep -rn "fts5_search" src/storage/'), 'src/storage/');
537
+ });
538
+
539
+ test('extractSearchPath: single file in src/', () => {
540
+ assert.equal(
541
+ extractSearchPath('grep -n "split_identifier" src/search/tokenizer.rs'),
542
+ 'src/search/tokenizer.rs');
543
+ });
544
+
545
+ test('extractSearchPath: first of multiple paths wins', () => {
546
+ assert.equal(extractSearchPath('grep -rn "set_hook" src/main.rs src/lib.rs'), 'src/main.rs');
547
+ });
548
+
549
+ test('extractSearchPath: quoted path is unwrapped', () => {
550
+ assert.equal(extractSearchPath('grep -rn "Foo" "claude-plugin/scripts/"'), 'claude-plugin/scripts/');
551
+ });
552
+
553
+ test('extractSearchPath: flags and redirects are skipped', () => {
554
+ assert.equal(extractSearchPath('grep -rn "Foo" src/ 2>&1'), 'src/');
555
+ });
556
+
557
+ test('extractSearchPath: ./-prefixed path is accepted', () => {
558
+ assert.equal(extractSearchPath('grep -rn "Foo" ./src/parser/'), './src/parser/');
559
+ });
560
+
561
+ test('extractSearchPath: path traversal is rejected', () => {
562
+ assert.equal(extractSearchPath('grep -rn "Foo" src/../../etc/'), undefined);
563
+ });
564
+
565
+ test('extractSearchPath: no source path → undefined', () => {
566
+ assert.equal(extractSearchPath('grep -rn "Foo"'), undefined);
567
+ });
568
+
569
+ test('pickBlockPattern: returns the identifier-like pattern', () => {
570
+ assert.equal(pickBlockPattern('grep -rn "EmbeddingModel" src/'), 'EmbeddingModel');
571
+ });
572
+
573
+ test('pickBlockPattern: skips non-identifier, picks identifier from -e args', () => {
574
+ assert.equal(
575
+ pickBlockPattern('grep -rn -e "some words" -e "fts5_search" src/'),
576
+ 'fts5_search');
577
+ });
578
+
579
+ test('pickBlockPattern: no identifier-like pattern → undefined', () => {
580
+ assert.equal(pickBlockPattern('grep -rn "no ident here" src/'), undefined);
581
+ });
582
+
583
+ // ── v0.47.0 deny-with-answer: message builders + env gate ───────────
584
+
585
+ test('buildBlockReasonWithAnswer: embeds results, command, and escape hatch', () => {
586
+ const reason = buildBlockReasonWithAnswer('fts5_search', 'src/storage/', {
587
+ status: 'hits', text: 'src/storage/db.rs:42 fn fts5_search()', truncated: false,
588
+ });
589
+ assert.match(reason, /already ran/);
590
+ assert.match(reason, /code-graph-mcp grep "fts5_search" src\/storage\//);
591
+ assert.match(reason, /src\/storage\/db\.rs:42/);
592
+ assert.match(reason, /CODE_GRAPH_NO_BLOCK_GREP=1/);
593
+ assert.doesNotMatch(reason, /truncated/);
594
+ });
595
+
596
+ test('buildBlockReasonWithAnswer: no searchPath → command has no path arg', () => {
597
+ const reason = buildBlockReasonWithAnswer('fts5_search', undefined, {
598
+ status: 'hits', text: 'hit', truncated: false,
599
+ });
600
+ assert.match(reason, /code-graph-mcp grep "fts5_search"\n/);
601
+ });
602
+
603
+ test('buildBlockReasonWithAnswer: truncated flag adds marker', () => {
604
+ const reason = buildBlockReasonWithAnswer('fts5_search', 'src/', {
605
+ status: 'hits', text: 'hit', truncated: true,
606
+ });
607
+ assert.match(reason, /truncated/);
608
+ });
609
+
610
+ test('buildNoHitsFyi: names the pattern and says raw grep proceeds', () => {
611
+ const fyi = buildNoHitsFyi('GhostSymbol');
612
+ assert.match(fyi, /GhostSymbol/);
613
+ assert.match(fyi, /[Nn]o matches/);
614
+ });
615
+
616
+ test('isAnswerDisabled: only env=1 disables', () => {
617
+ assert.equal(isAnswerDisabled({ CODE_GRAPH_NO_ANSWER_IN_DENY: '1' }), true);
618
+ assert.equal(isAnswerDisabled({ CODE_GRAPH_NO_ANSWER_IN_DENY: '0' }), false);
619
+ assert.equal(isAnswerDisabled({}), false);
620
+ });
621
+
622
+ // ── v0.47.0 deny-with-answer: stdin-spawn e2e with stub binary ──────
623
+
624
+ const { spawnSync: spawnHook } = require('child_process');
625
+ const fsE2e = require('fs');
626
+ const osE2e = require('os');
627
+ const pathE2e = require('path');
628
+ const { cgTmpDir } = require('./tmp-dir');
629
+
630
+ function e2eFixture(stubBody) {
631
+ const dir = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'pre-grep-e2e-'));
632
+ fsE2e.mkdirSync(pathE2e.join(dir, '.code-graph'), { recursive: true });
633
+ fsE2e.writeFileSync(pathE2e.join(dir, '.code-graph', 'index.db'), '');
634
+ const stub = pathE2e.join(dir, 'cg-stub.js');
635
+ fsE2e.writeFileSync(stub, '#!/usr/bin/env node\n' + stubBody);
636
+ fsE2e.chmodSync(stub, 0o755);
637
+ return { dir, stub };
638
+ }
639
+
640
+ function runHook(cmd, fixture) {
641
+ const res = spawnHook(process.execPath, [pathE2e.join(__dirname, 'pre-grep-guide.js')], {
642
+ cwd: fixture.dir,
643
+ input: JSON.stringify({ tool_input: { command: cmd } }),
644
+ encoding: 'utf8',
645
+ env: {
646
+ ...process.env,
647
+ _CG_ANSWER_BINARY: fixture.stub,
648
+ CODE_GRAPH_QUIET_HOOKS: '0',
649
+ CODE_GRAPH_NO_BLOCK_GREP: '0',
650
+ CODE_GRAPH_NO_ANSWER_IN_DENY: '0',
651
+ },
652
+ });
653
+ return res;
654
+ }
655
+
656
+ function cleanupFixture(fixture, cmd) {
657
+ fsE2e.rmSync(fixture.dir, { recursive: true, force: true });
658
+ // cooldown flag for this command lives in cgTmpDir — remove so reruns stay deterministic
659
+ try {
660
+ fsE2e.unlinkSync(pathE2e.join(cgTmpDir(), `.code-graph-bash-${commandHash(cmd)}`));
661
+ } catch { /* ok */ }
662
+ }
663
+
664
+ test('e2e: denied grep with stub hits → deny JSON embeds the answer + records answered:true', () => {
665
+ const uniq = `StubHit${Date.now()}`;
666
+ const fixture = e2eFixture(
667
+ `process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
668
+ const cmd = `grep -rn "${uniq}" src/`;
669
+ try {
670
+ const res = runHook(cmd, fixture);
671
+ assert.equal(res.status, 0);
672
+ const out = JSON.parse(res.stdout);
673
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
674
+ assert.match(out.hookSpecificOutput.permissionDecisionReason, /src\/foo\.rs:7/);
675
+ assert.match(out.hookSpecificOutput.permissionDecisionReason, new RegExp(uniq));
676
+ const recs = fsE2e.readFileSync(
677
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
678
+ const rec = JSON.parse(recs.trim().split('\n').pop());
679
+ assert.equal(rec.action, 'deny');
680
+ assert.equal(rec.answered, true);
681
+ } finally {
682
+ cleanupFixture(fixture, cmd);
683
+ }
684
+ });
685
+
686
+ test('e2e: stub reports no matches → grep allowed with FYI + records fallthrough', () => {
687
+ const uniq = `StubMiss${Date.now()}`;
688
+ const fixture = e2eFixture(
689
+ `process.stdout.write('[code-graph] No matches for: ' + process.argv[3] + '\\n');`);
690
+ const cmd = `grep -rn "${uniq}" src/`;
691
+ try {
692
+ const res = runHook(cmd, fixture);
693
+ assert.equal(res.status, 0);
694
+ // No deny JSON — plain FYI text means the grep proceeds
695
+ assert.throws(() => JSON.parse(res.stdout));
696
+ assert.match(res.stdout, /[Nn]o matches/);
697
+ const recs = fsE2e.readFileSync(
698
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
699
+ const rec = JSON.parse(recs.trim().split('\n').pop());
700
+ assert.equal(rec.action, 'hint');
701
+ assert.equal(rec.fallthrough, 'no-hits');
702
+ } finally {
703
+ cleanupFixture(fixture, cmd);
704
+ }
705
+ });
706
+
707
+ test('e2e: stub fails → static deny (v0.46 fallback) + records answered:false', () => {
708
+ const uniq = `StubBoom${Date.now()}`;
709
+ const fixture = e2eFixture(`process.exit(3);`);
710
+ const cmd = `grep -rn "${uniq}" src/`;
711
+ try {
712
+ const res = runHook(cmd, fixture);
713
+ assert.equal(res.status, 0);
714
+ const out = JSON.parse(res.stdout);
715
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
716
+ // static reason, no embedded results
717
+ assert.match(out.hookSpecificOutput.permissionDecisionReason, /denied by code-graph hook/);
718
+ const rec = JSON.parse(fsE2e.readFileSync(
719
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim());
720
+ assert.equal(rec.action, 'deny');
721
+ assert.equal(rec.answered, false);
722
+ } finally {
723
+ cleanupFixture(fixture, cmd);
724
+ }
725
+ });
726
+
727
+ test('e2e: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → static deny even when stub would hit', () => {
728
+ const uniq = `StubOptout${Date.now()}`;
729
+ const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 hit\\n');`);
730
+ const cmd = `grep -rn "${uniq}" src/`;
731
+ try {
732
+ const res = spawnHook(process.execPath, [pathE2e.join(__dirname, 'pre-grep-guide.js')], {
733
+ cwd: fixture.dir,
734
+ input: JSON.stringify({ tool_input: { command: cmd } }),
735
+ encoding: 'utf8',
736
+ env: {
737
+ ...process.env,
738
+ _CG_ANSWER_BINARY: fixture.stub,
739
+ CODE_GRAPH_QUIET_HOOKS: '0',
740
+ CODE_GRAPH_NO_BLOCK_GREP: '0',
741
+ CODE_GRAPH_NO_ANSWER_IN_DENY: '1',
742
+ },
743
+ });
744
+ const out = JSON.parse(res.stdout);
745
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
746
+ assert.doesNotMatch(out.hookSpecificOutput.permissionDecisionReason, /src\/foo\.rs:7/);
747
+ } finally {
748
+ cleanupFixture(fixture, cmd);
749
+ }
750
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.45.4",
3
+ "version": "0.47.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.45.4",
39
- "@sdsrs/code-graph-linux-arm64": "0.45.4",
40
- "@sdsrs/code-graph-darwin-x64": "0.45.4",
41
- "@sdsrs/code-graph-darwin-arm64": "0.45.4",
42
- "@sdsrs/code-graph-win32-x64": "0.45.4"
38
+ "@sdsrs/code-graph-linux-x64": "0.47.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.47.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.47.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.47.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.47.0"
43
43
  }
44
44
  }