@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.
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/cg-answer.js +107 -0
- package/claude-plugin/scripts/cg-answer.test.js +135 -0
- package/claude-plugin/scripts/pre-grep-guide.js +111 -7
- package/claude-plugin/scripts/pre-grep-guide.test.js +313 -0
- package/package.json +6 -6
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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(
|
|
266
|
+
if (isOnCooldown(rawCmd)) return;
|
|
189
267
|
|
|
190
|
-
markCooldown(
|
|
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, {
|
|
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:
|
|
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.
|
|
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.
|
|
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.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
|
}
|