@sdsrs/code-graph 0.72.0 → 0.73.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/hook-emit.js +53 -0
- package/claude-plugin/scripts/hook-fire.test.js +2 -2
- package/claude-plugin/scripts/hooks.test.js +4 -4
- package/claude-plugin/scripts/lifecycle.js +9 -1
- package/claude-plugin/scripts/post-grep-inject.js +184 -0
- package/claude-plugin/scripts/post-grep-inject.test.js +260 -0
- package/claude-plugin/scripts/pre-edit-guide.js +8 -1
- package/claude-plugin/scripts/pre-edit-guide.test.js +19 -0
- package/claude-plugin/scripts/pre-grep-guide.js +77 -2
- package/claude-plugin/scripts/pre-grep-guide.test.js +91 -0
- package/claude-plugin/scripts/pre-read-guide.js +8 -1
- package/claude-plugin/scripts/pre-read-guide.test.js +9 -1
- package/package.json +6 -6
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// Shared hook emit envelopes. One place defines the CC hookSpecificOutput JSON
|
|
4
|
+
// schema so the three sibling delivery hooks (pre-read-guide / pre-edit-guide /
|
|
5
|
+
// post-grep-inject) cannot drift apart (feedback_hook_class_bug_sweep — no
|
|
6
|
+
// inline copies of shared logic). DRY mirror of the project-root.js precedent.
|
|
7
|
+
//
|
|
8
|
+
// Why these two shapes:
|
|
9
|
+
// - PreToolUse plain stdout on exit 0 goes to the DEBUG LOG ONLY — it never
|
|
10
|
+
// reaches the model (CC docs, code.claude.com/docs/en/hooks.md, v2026-06).
|
|
11
|
+
// `additionalContext` DOES reach the model, but only alongside a
|
|
12
|
+
// permissionDecision. For the safe-tool pre hooks (Read fanout hint, Edit
|
|
13
|
+
// impact summary) the elevation to `allow` is negligible and is what makes
|
|
14
|
+
// the carried context visible.
|
|
15
|
+
// - PostToolUse honors `additionalContext` permission-neutrally (no
|
|
16
|
+
// permissionDecision), so the Bash-side grep answer can be injected without
|
|
17
|
+
// skipping CC's default permission prompt for the underlying tool call.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* PreToolUse allow + additionalContext envelope (string, no trailing newline).
|
|
21
|
+
* Used by the safe-tool delivery hooks (pre-read-guide / pre-edit-guide) so
|
|
22
|
+
* their carried hint/impact text reaches the model instead of dying in the
|
|
23
|
+
* debug log.
|
|
24
|
+
* @param {string} text
|
|
25
|
+
* @returns {string} JSON line
|
|
26
|
+
*/
|
|
27
|
+
function emitPreToolAllowContext(text) {
|
|
28
|
+
return JSON.stringify({
|
|
29
|
+
hookSpecificOutput: {
|
|
30
|
+
hookEventName: 'PreToolUse',
|
|
31
|
+
permissionDecision: 'allow',
|
|
32
|
+
additionalContext: text,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* PostToolUse additionalContext envelope (string, no trailing newline).
|
|
39
|
+
* Permission-neutral: NO permissionDecision, so the underlying Bash tool call's
|
|
40
|
+
* permission flow is untouched while the answer still reaches the model.
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @returns {string} JSON line
|
|
43
|
+
*/
|
|
44
|
+
function emitPostToolContext(text) {
|
|
45
|
+
return JSON.stringify({
|
|
46
|
+
hookSpecificOutput: {
|
|
47
|
+
hookEventName: 'PostToolUse',
|
|
48
|
+
additionalContext: text,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { emitPreToolAllowContext, emitPostToolContext };
|
|
@@ -17,8 +17,8 @@ const { hookFireWarning, analyzeHookDark } = require('./session-init');
|
|
|
17
17
|
|
|
18
18
|
test('verifyHooksFire: all real registered hooks run cleanly (exit 0)', () => {
|
|
19
19
|
const { ok, results } = verifyHooksFire();
|
|
20
|
-
// 3 PreToolUse +
|
|
21
|
-
assert.ok(results.length >=
|
|
20
|
+
// 3 PreToolUse + 2 PostToolUse (incremental-index + compound-grep inject) + 1 UserPromptSubmit = 6 settings.json hooks
|
|
21
|
+
assert.ok(results.length >= 6, `expected >=6 hook probes, got ${results.length}`);
|
|
22
22
|
for (const r of results) {
|
|
23
23
|
assert.ok(r.ok, `hook ${r.label} (${r.script}) did not fire cleanly: code=${r.code} err=${r.error}`);
|
|
24
24
|
}
|
|
@@ -182,8 +182,8 @@ function allRegisteredHookCommands() {
|
|
|
182
182
|
|
|
183
183
|
test('every registered hook script exists on disk', () => {
|
|
184
184
|
const commands = allRegisteredHookCommands();
|
|
185
|
-
// 3 PreToolUse +
|
|
186
|
-
assert.ok(commands.length >=
|
|
185
|
+
// 3 PreToolUse + 2 PostToolUse (incremental-index + compound-grep inject) + 1 UserPromptSubmit + 1 SessionStart = 7
|
|
186
|
+
assert.ok(commands.length >= 7, `expected >=7 registered hook commands, got ${commands.length}`);
|
|
187
187
|
for (const cmd of commands) {
|
|
188
188
|
const p = resolveHookScript(cmd);
|
|
189
189
|
assert.ok(p, `could not extract a .js path from hook command: ${JSON.stringify(cmd)}`);
|
|
@@ -221,8 +221,8 @@ test('buildSettingsHookEntries: matcher surface is exactly the intended set', ()
|
|
|
221
221
|
const setOf = (event) => (desired[event] || []).map(e => e.matcher).sort();
|
|
222
222
|
assert.deepEqual(setOf('PreToolUse'), ['Bash', 'Edit', 'Read'],
|
|
223
223
|
'PreToolUse matcher set changed — update this gate intentionally (does the new tool need a guide hook?)');
|
|
224
|
-
assert.deepEqual(setOf('PostToolUse'), ['Write|Edit'],
|
|
225
|
-
'PostToolUse matcher set changed — incremental-index
|
|
224
|
+
assert.deepEqual(setOf('PostToolUse'), ['Bash', 'Write|Edit'],
|
|
225
|
+
'PostToolUse matcher set changed — incremental-index (Write|Edit) + compound-grep inject (Bash) trigger surface must be deliberate');
|
|
226
226
|
assert.deepEqual(setOf('UserPromptSubmit'), [''],
|
|
227
227
|
'UserPromptSubmit matcher set changed unexpectedly');
|
|
228
228
|
assert.deepEqual(Object.keys(desired).sort(), ['PostToolUse', 'PreToolUse', 'UserPromptSubmit'],
|
|
@@ -310,6 +310,7 @@ const OUR_HOOK_SCRIPTS = [
|
|
|
310
310
|
'pre-edit-guide.js',
|
|
311
311
|
'pre-grep-guide.js', // v0.32.0 — was in plugin-cache only, never fired
|
|
312
312
|
'pre-read-guide.js', // v0.32.0 — was in plugin-cache only, never fired
|
|
313
|
+
'post-grep-inject.js', // compound-grep — PostToolUse(Bash) permission-neutral answer inject
|
|
313
314
|
];
|
|
314
315
|
|
|
315
316
|
// Description markers — primary cleanup discriminator (immune to env/path
|
|
@@ -318,6 +319,7 @@ const OUR_HOOK_SCRIPTS = [
|
|
|
318
319
|
const SETTINGS_HOOK_DESC = {
|
|
319
320
|
preToolUse: '[code-graph-mcp v0.32+] PreToolUse re-routed via settings.json (cache hooks.json silently ignored for this event by current CC)',
|
|
320
321
|
postToolUseEdit: '[code-graph-mcp v0.32+] PostToolUse Write|Edit incremental-index update',
|
|
322
|
+
postToolUseInject:'[code-graph-mcp v0.32+] PostToolUse Bash compound-grep answer inject (permission-neutral additionalContext)',
|
|
321
323
|
userPromptSubmit: '[code-graph-mcp v0.32+] UserPromptSubmit context push',
|
|
322
324
|
};
|
|
323
325
|
|
|
@@ -330,6 +332,7 @@ const OUR_DESCRIPTIONS = [
|
|
|
330
332
|
// v0.32.0 — new re-route markers
|
|
331
333
|
SETTINGS_HOOK_DESC.preToolUse,
|
|
332
334
|
SETTINGS_HOOK_DESC.postToolUseEdit,
|
|
335
|
+
SETTINGS_HOOK_DESC.postToolUseInject,
|
|
333
336
|
SETTINGS_HOOK_DESC.userPromptSubmit,
|
|
334
337
|
];
|
|
335
338
|
|
|
@@ -385,6 +388,7 @@ function buildSettingsHookEntries() {
|
|
|
385
388
|
],
|
|
386
389
|
PostToolUse: [
|
|
387
390
|
{ description: SETTINGS_HOOK_DESC.postToolUseEdit, matcher: 'Write|Edit', hooks: [scriptCmd('incremental-index.js', 10)] },
|
|
391
|
+
{ description: SETTINGS_HOOK_DESC.postToolUseInject, matcher: 'Bash', hooks: [scriptCmd('post-grep-inject.js', 5)] },
|
|
388
392
|
],
|
|
389
393
|
UserPromptSubmit: [
|
|
390
394
|
{ description: SETTINGS_HOOK_DESC.userPromptSubmit, matcher: '', hooks: [scriptCmd('user-prompt-context.js', 5)] },
|
|
@@ -481,7 +485,11 @@ function surveyHookCoverage(settings) {
|
|
|
481
485
|
function hookFirePayload(matcher) {
|
|
482
486
|
switch (matcher) {
|
|
483
487
|
case 'Bash':
|
|
484
|
-
|
|
488
|
+
// A QUOTED, identifier-like pattern → classifyBlock-positive → the
|
|
489
|
+
// PreToolUse deny tier emits (the hint-tier dark stdout fallthrough was
|
|
490
|
+
// removed in the compound-grep change, so a non-foldable pattern would now
|
|
491
|
+
// produce no output and falsely read as "didn't fire").
|
|
492
|
+
return { tool_name: 'Bash', tool_input: { command: 'grep -rn "SomeUniqueSymbol" src/' } };
|
|
485
493
|
case 'Read':
|
|
486
494
|
return { tool_name: 'Read', tool_input: { file_path: 'src/example.rs' } };
|
|
487
495
|
case 'Edit':
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// PostToolUse(Bash) hook: deliver cg's AST-aware answer for a FOLDABLE grep that
|
|
4
|
+
// rode inside a COMPOUND command and therefore flew past the PreToolUse deny gate
|
|
5
|
+
// (`echo "..." && grep Sym tests/`, `git diff && grep ...`, `for s in …; do grep`).
|
|
6
|
+
//
|
|
7
|
+
// Why PostToolUse, not PreToolUse: the leading-grep deny path (pre-grep-guide)
|
|
8
|
+
// only fires when the command HEAD is grep — a grep buried after a side-effecting
|
|
9
|
+
// sibling has head=echo/git/for and is intentionally left alone there (denying the
|
|
10
|
+
// whole compound command would also block the sibling). Those greps RUN, so the
|
|
11
|
+
// only permission-neutral way to hand the model cg's structural view is a
|
|
12
|
+
// PostToolUse `additionalContext` injection (CC docs v2026-06: PostToolUse honors
|
|
13
|
+
// additionalContext; a PreToolUse `allow` would skip the default permission prompt
|
|
14
|
+
// for the underlying Bash call, which we must not do).
|
|
15
|
+
//
|
|
16
|
+
// Reuses the PreToolUse pure predicates wholesale (feedback_hook_class_bug_sweep —
|
|
17
|
+
// no inline copies of the grep gate): splitTopLevelSegments + classifyBlock pick
|
|
18
|
+
// the foldable segment; pickBlockPattern / translateBreToRg / extractSearchPath +
|
|
19
|
+
// sanitizeSearchPath + runGrepAnswer / runShowAnswer run the exact same answer the
|
|
20
|
+
// deny path would have. Best-effort: any miss (no hits / unavailable / no binary)
|
|
21
|
+
// exits silently with NO injection — an enhancement, never a new failure mode.
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
const { cgTmpDir } = require('./tmp-dir');
|
|
27
|
+
const { recordRecommendation } = require('./recommendation-log');
|
|
28
|
+
const { runGrepAnswer, runShowAnswer, sanitizeSearchPath } = require('./cg-answer');
|
|
29
|
+
const { emitPostToolContext } = require('./hook-emit');
|
|
30
|
+
const {
|
|
31
|
+
splitTopLevelSegments,
|
|
32
|
+
classifyBlock,
|
|
33
|
+
pickBlockPattern,
|
|
34
|
+
translateBreToRg,
|
|
35
|
+
extractSearchPath,
|
|
36
|
+
normalizeCommandPaths,
|
|
37
|
+
rebaseRelativePaths,
|
|
38
|
+
resolveProjectRoot,
|
|
39
|
+
} = require('./pre-grep-guide');
|
|
40
|
+
|
|
41
|
+
// The command HEAD is grep/rg/ag (or git grep, or a KEY=VALUE/env prefix). Kept
|
|
42
|
+
// loosely aligned with pre-grep-guide's GREP_VERB; this only gates "is this
|
|
43
|
+
// segment a search command" so the segment splitter doesn't have to.
|
|
44
|
+
const GREP_HEAD = /^\s*(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:git\s+grep|grep|rg|ag)\b/;
|
|
45
|
+
|
|
46
|
+
// --- Pure logic (testable) ---
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Find the FIRST foldable grep segment in a (possibly compound) command.
|
|
50
|
+
* Splits via splitTopLevelSegments (NOT on a single `|` — that is an output
|
|
51
|
+
* filter), then returns the first segment whose head is grep AND whose
|
|
52
|
+
* classifyBlock is non-null (the answerable symbol/show tier).
|
|
53
|
+
* @returns {{segment: string, block: {mode: string, symbols?: string[]}} | null}
|
|
54
|
+
*/
|
|
55
|
+
function findFoldableGrepSegment(cmd) {
|
|
56
|
+
if (!cmd || typeof cmd !== 'string') return null;
|
|
57
|
+
for (const segment of splitTopLevelSegments(cmd)) {
|
|
58
|
+
if (!GREP_HEAD.test(segment)) continue;
|
|
59
|
+
const block = classifyBlock(segment);
|
|
60
|
+
if (block) return { segment, block };
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Short header so the model recognizes this as cg's parallel structural view of
|
|
66
|
+
// the grep it just ran (the grep already executed; this is additive context).
|
|
67
|
+
const INJECT_HEADER = '[code-graph] AST-aware view of your grep (ran alongside):';
|
|
68
|
+
|
|
69
|
+
function buildInjectText(answer, mode) {
|
|
70
|
+
const lines = [INJECT_HEADER, answer.text];
|
|
71
|
+
if (answer.truncated) {
|
|
72
|
+
lines.push(mode === 'show'
|
|
73
|
+
? '(truncated — re-run the `code-graph-mcp show <symbol>` command above for full source)'
|
|
74
|
+
: '(truncated — run `code-graph-mcp grep "<pattern>"` yourself for the full list)');
|
|
75
|
+
}
|
|
76
|
+
lines.push('Each hit shows its containing fn/module — use these results directly.');
|
|
77
|
+
return lines.join('\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Kill switch — matches the sibling-hook convention (pre-grep-guide.isSilenced).
|
|
81
|
+
function isSilenced(env = process.env) {
|
|
82
|
+
return env.CODE_GRAPH_QUIET_HOOKS === '1';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// New per-this-hook opt-out (released-artifact requirement): =1 disables the
|
|
86
|
+
// PostToolUse compound-grep injection entirely, independent of QUIET_HOOKS.
|
|
87
|
+
function isInjectDisabled(env = process.env) {
|
|
88
|
+
return env.CODE_GRAPH_NO_INJECT === '1';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Per-command cooldown, mirror of pre-grep-guide's flag pattern but with a
|
|
92
|
+
// DISTINCT prefix so the two hooks never share a flag (a PreToolUse deny and a
|
|
93
|
+
// PostToolUse inject for different commands must not suppress each other).
|
|
94
|
+
function commandHash(cmd) {
|
|
95
|
+
return crypto.createHash('sha1').update(String(cmd)).digest('hex').slice(0, 12);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function flagPath(cmd) {
|
|
99
|
+
return path.join(cgTmpDir(), `.code-graph-postinject-${commandHash(cmd)}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isOnCooldown(cmd, now = Date.now(), windowMs = 60000) {
|
|
103
|
+
try {
|
|
104
|
+
return now - fs.statSync(flagPath(cmd)).mtimeMs < windowMs;
|
|
105
|
+
} catch { return false; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function markCooldown(cmd) {
|
|
109
|
+
try { fs.writeFileSync(flagPath(cmd), ''); } catch { /* ok */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- Main execution ---
|
|
113
|
+
|
|
114
|
+
function runMain() {
|
|
115
|
+
if (isSilenced() || isInjectDisabled()) return;
|
|
116
|
+
// Walk up from the persistent shell cwd (subdir-cwd fix — shared resolver).
|
|
117
|
+
const shellCwd = process.cwd();
|
|
118
|
+
const root = resolveProjectRoot(shellCwd);
|
|
119
|
+
if (root === null) return; // no index anywhere up to $HOME
|
|
120
|
+
|
|
121
|
+
let input;
|
|
122
|
+
try {
|
|
123
|
+
// fd 0, not '/dev/stdin': the path form fails ENXIO on socketpair stdin.
|
|
124
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
125
|
+
} catch { return; }
|
|
126
|
+
|
|
127
|
+
const rawCmd = (input.tool_input && input.tool_input.command) || '';
|
|
128
|
+
if (!rawCmd) return;
|
|
129
|
+
|
|
130
|
+
// Normalize abs paths under the root + rebase subdir-relative tokens, exactly
|
|
131
|
+
// like the PreToolUse path, so the segment classifier and the answer scope see
|
|
132
|
+
// root-relative paths. Cooldown stays keyed on the raw command.
|
|
133
|
+
let cmd = normalizeCommandPaths(rawCmd, root);
|
|
134
|
+
const relPrefix = path.relative(root, shellCwd);
|
|
135
|
+
if (relPrefix) cmd = rebaseRelativePaths(cmd, relPrefix, root);
|
|
136
|
+
|
|
137
|
+
const found = findFoldableGrepSegment(cmd);
|
|
138
|
+
if (!found) return;
|
|
139
|
+
|
|
140
|
+
if (isOnCooldown(rawCmd)) return;
|
|
141
|
+
markCooldown(rawCmd);
|
|
142
|
+
|
|
143
|
+
const { segment, block } = found;
|
|
144
|
+
// Run the answer exactly like the deny path.
|
|
145
|
+
const pattern = translateBreToRg(segment, pickBlockPattern(segment));
|
|
146
|
+
const searchPath = sanitizeSearchPath(extractSearchPath(segment));
|
|
147
|
+
let answer = { status: 'unavailable' };
|
|
148
|
+
let answeredMode = block.mode;
|
|
149
|
+
if (block.mode === 'show') {
|
|
150
|
+
answer = runShowAnswer({ cwd: root, symbols: block.symbols });
|
|
151
|
+
if (answer.status !== 'hits' && pattern) {
|
|
152
|
+
answeredMode = 'grep';
|
|
153
|
+
answer = runGrepAnswer({ cwd: root, pattern, searchPath });
|
|
154
|
+
}
|
|
155
|
+
} else if (pattern) {
|
|
156
|
+
answer = runGrepAnswer({ cwd: root, pattern, searchPath });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Only inject on hits — no-hits / unavailable / no-binary stay silent (the grep
|
|
160
|
+
// already ran and produced its own output; a failed cg answer adds no value and
|
|
161
|
+
// 0 hits ≠ proof of absence given regex-dialect differences).
|
|
162
|
+
if (answer.status !== 'hits') return;
|
|
163
|
+
|
|
164
|
+
recordRecommendation(root, {
|
|
165
|
+
hook: 'grep', action: 'inject', answered: true,
|
|
166
|
+
...(pattern ? { pattern } : {}),
|
|
167
|
+
mode: answeredMode,
|
|
168
|
+
});
|
|
169
|
+
process.stdout.write(emitPostToolContext(buildInjectText(answer, answeredMode)) + '\n');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (require.main === module) {
|
|
173
|
+
runMain();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = {
|
|
177
|
+
findFoldableGrepSegment,
|
|
178
|
+
buildInjectText,
|
|
179
|
+
isSilenced,
|
|
180
|
+
isInjectDisabled,
|
|
181
|
+
commandHash,
|
|
182
|
+
isOnCooldown,
|
|
183
|
+
markCooldown,
|
|
184
|
+
};
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { cgTmpDir } = require('./tmp-dir');
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
findFoldableGrepSegment,
|
|
12
|
+
isSilenced,
|
|
13
|
+
isInjectDisabled,
|
|
14
|
+
buildInjectText,
|
|
15
|
+
commandHash,
|
|
16
|
+
} = require('./post-grep-inject');
|
|
17
|
+
|
|
18
|
+
// ── Pure logic: findFoldableGrepSegment ─────────────────────────────
|
|
19
|
+
// Reuses splitTopLevelSegments + classifyBlock from pre-grep-guide. The FIRST
|
|
20
|
+
// segment whose head is grep AND whose classifyBlock is non-null is the foldable
|
|
21
|
+
// grep to answer. Leading-grep foldable commands were DENIED in PreToolUse and
|
|
22
|
+
// never ran → never reach PostToolUse, so no dedup is needed here.
|
|
23
|
+
|
|
24
|
+
test('findFoldableGrepSegment: compound `echo && grep "Sym" tests/` → the grep segment', () => {
|
|
25
|
+
// classifyBlock requires a QUOTED, identifier-like pattern (the deny gate's
|
|
26
|
+
// contract); `EmbeddingModel` stands for the spec's illustrative `Sym`.
|
|
27
|
+
const seg = findFoldableGrepSegment('echo "x" && grep "EmbeddingModel" tests/');
|
|
28
|
+
assert.ok(seg, 'expected a foldable grep segment');
|
|
29
|
+
assert.equal(seg.segment, 'grep "EmbeddingModel" tests/');
|
|
30
|
+
assert.equal(seg.block.mode, 'grep');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('findFoldableGrepSegment: `git diff && grep "Sym" src/` → the grep segment', () => {
|
|
34
|
+
const seg = findFoldableGrepSegment('git diff && grep "EmbeddingModel" src/');
|
|
35
|
+
assert.ok(seg);
|
|
36
|
+
assert.equal(seg.segment, 'grep "EmbeddingModel" src/');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('findFoldableGrepSegment: `cargo test | grep FAIL` is an output filter → null', () => {
|
|
40
|
+
// single pipe is NOT a split → head stays `cargo`, not a foldable grep.
|
|
41
|
+
assert.equal(findFoldableGrepSegment('cargo test | grep FAIL'), null);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('findFoldableGrepSegment: a leading non-compound grep is NOT folded here (PreToolUse denies it)', () => {
|
|
45
|
+
// A bare leading foldable grep is handled by PreToolUse deny; if it somehow
|
|
46
|
+
// reaches PostToolUse it still classifies, but the typical compound case is the
|
|
47
|
+
// target. We DO answer a lone classifyBlock-positive segment when present.
|
|
48
|
+
const seg = findFoldableGrepSegment('grep "EmbeddingModel" src/');
|
|
49
|
+
assert.ok(seg, 'a classifyBlock-positive grep segment is foldable');
|
|
50
|
+
assert.equal(seg.block.mode, 'grep');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('findFoldableGrepSegment: non-foldable hint-tier grep (marker) → null', () => {
|
|
54
|
+
// bare TODO marker passes shouldHint but classifyBlock is null → not foldable.
|
|
55
|
+
assert.equal(findFoldableGrepSegment('echo hi && grep "TODO" src/'), null);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('findFoldableGrepSegment: no grep anywhere → null', () => {
|
|
59
|
+
assert.equal(findFoldableGrepSegment('cargo build && cargo test'), null);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('findFoldableGrepSegment: for-loop body grep is isolated and folded', () => {
|
|
63
|
+
const seg = findFoldableGrepSegment('for s in a b; do grep "EmbeddingModel" src/; done');
|
|
64
|
+
assert.ok(seg, 'loop-body grep must be foldable');
|
|
65
|
+
assert.match(seg.segment, /grep "EmbeddingModel" src\//);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('findFoldableGrepSegment: empty / non-string → null', () => {
|
|
69
|
+
assert.equal(findFoldableGrepSegment(''), null);
|
|
70
|
+
assert.equal(findFoldableGrepSegment(null), null);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('findFoldableGrepSegment: show-mode (decl anchor + context flag) classifies as show', () => {
|
|
74
|
+
const seg = findFoldableGrepSegment('echo go && grep "fn handle_message" -A 5 src/');
|
|
75
|
+
assert.ok(seg);
|
|
76
|
+
assert.equal(seg.block.mode, 'show');
|
|
77
|
+
assert.deepEqual(seg.block.symbols, ['handle_message']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── buildInjectText ─────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
test('buildInjectText: carries a header + the answer text', () => {
|
|
83
|
+
const out = buildInjectText({ text: 'src/foo.rs:7 fn x()', truncated: false }, 'grep');
|
|
84
|
+
assert.match(out, /AST-aware view of your grep/);
|
|
85
|
+
assert.match(out, /src\/foo\.rs:7/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('buildInjectText: truncation note appended when truncated', () => {
|
|
89
|
+
const out = buildInjectText({ text: 'hit', truncated: true }, 'grep');
|
|
90
|
+
assert.match(out, /truncated/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('buildInjectText: no truncation note when not truncated', () => {
|
|
94
|
+
const out = buildInjectText({ text: 'hit', truncated: false }, 'grep');
|
|
95
|
+
assert.doesNotMatch(out, /truncated/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ── opt-out / kill switch ───────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
test('isSilenced: CODE_GRAPH_QUIET_HOOKS=1 → silenced; default not', () => {
|
|
101
|
+
assert.equal(isSilenced({ CODE_GRAPH_QUIET_HOOKS: '1' }), true);
|
|
102
|
+
assert.equal(isSilenced({}), false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('isInjectDisabled: CODE_GRAPH_NO_INJECT=1 → disabled; default not', () => {
|
|
106
|
+
assert.equal(isInjectDisabled({ CODE_GRAPH_NO_INJECT: '1' }), true);
|
|
107
|
+
assert.equal(isInjectDisabled({ CODE_GRAPH_NO_INJECT: '0' }), false);
|
|
108
|
+
assert.equal(isInjectDisabled({}), false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── e2e: real spawn with stub binary (mirrors pre-grep-guide harness) ──
|
|
112
|
+
// PostToolUse-shaped stdin {tool_input:{command:"..."}}; assert on
|
|
113
|
+
// hookSpecificOutput.additionalContext.
|
|
114
|
+
|
|
115
|
+
function e2eFixture(stubBody) {
|
|
116
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'post-grep-e2e-'));
|
|
117
|
+
fs.mkdirSync(path.join(dir, '.code-graph'), { recursive: true });
|
|
118
|
+
fs.writeFileSync(path.join(dir, '.code-graph', 'index.db'), '');
|
|
119
|
+
const stub = path.join(dir, 'cg-stub.js');
|
|
120
|
+
fs.writeFileSync(stub, '#!/usr/bin/env node\n' + stubBody);
|
|
121
|
+
fs.chmodSync(stub, 0o755);
|
|
122
|
+
return { dir, stub };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function runHook(cmd, fixture, extraEnv = {}, cwdOverride) {
|
|
126
|
+
return spawnSync(process.execPath, [path.join(__dirname, 'post-grep-inject.js')], {
|
|
127
|
+
cwd: cwdOverride || fixture.dir,
|
|
128
|
+
input: JSON.stringify({ tool_input: { command: cmd } }),
|
|
129
|
+
encoding: 'utf8',
|
|
130
|
+
env: {
|
|
131
|
+
...process.env,
|
|
132
|
+
_CG_ANSWER_BINARY: fixture.stub,
|
|
133
|
+
CODE_GRAPH_QUIET_HOOKS: '0',
|
|
134
|
+
CODE_GRAPH_NO_INJECT: '0',
|
|
135
|
+
...extraEnv,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function cleanupFixture(fixture, cmd) {
|
|
141
|
+
fs.rmSync(fixture.dir, { recursive: true, force: true });
|
|
142
|
+
try {
|
|
143
|
+
fs.unlinkSync(path.join(cgTmpDir(), `.code-graph-postinject-${commandHash(cmd)}`));
|
|
144
|
+
} catch { /* ok */ }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
test('e2e: `echo "x" && grep Sym tests/` → injects additionalContext with the stub hits + records inject', () => {
|
|
148
|
+
const uniq = `PostHit${Date.now()}`;
|
|
149
|
+
const fixture = e2eFixture(
|
|
150
|
+
`process.stdout.write('tests/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
|
|
151
|
+
const cmd = `echo "x" && grep "${uniq}" tests/`;
|
|
152
|
+
try {
|
|
153
|
+
const res = runHook(cmd, fixture);
|
|
154
|
+
assert.equal(res.status, 0);
|
|
155
|
+
const out = JSON.parse(res.stdout);
|
|
156
|
+
assert.equal(out.hookSpecificOutput.hookEventName, 'PostToolUse');
|
|
157
|
+
assert.equal(out.hookSpecificOutput.permissionDecision, undefined,
|
|
158
|
+
'PostToolUse inject must be permission-neutral (no permissionDecision)');
|
|
159
|
+
assert.match(out.hookSpecificOutput.additionalContext, new RegExp(uniq));
|
|
160
|
+
assert.match(out.hookSpecificOutput.additionalContext, /tests\/foo\.rs:7/);
|
|
161
|
+
const recs = fs.readFileSync(
|
|
162
|
+
path.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
|
|
163
|
+
const rec = JSON.parse(recs.trim().split('\n').pop());
|
|
164
|
+
assert.equal(rec.action, 'inject');
|
|
165
|
+
assert.equal(rec.answered, true);
|
|
166
|
+
assert.equal(rec.hook, 'grep');
|
|
167
|
+
assert.equal(rec.pattern, uniq);
|
|
168
|
+
} finally {
|
|
169
|
+
cleanupFixture(fixture, cmd);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('e2e: `git diff && grep Sym src/` → inject', () => {
|
|
174
|
+
const uniq = `GitDiffHit${Date.now()}`;
|
|
175
|
+
const fixture = e2eFixture(
|
|
176
|
+
`process.stdout.write('src/foo.rs:9 fn ' + process.argv[3] + '()\\n');`);
|
|
177
|
+
const cmd = `git diff && grep "${uniq}" src/`;
|
|
178
|
+
try {
|
|
179
|
+
const res = runHook(cmd, fixture);
|
|
180
|
+
assert.equal(res.status, 0);
|
|
181
|
+
const out = JSON.parse(res.stdout);
|
|
182
|
+
assert.match(out.hookSpecificOutput.additionalContext, new RegExp(uniq));
|
|
183
|
+
} finally {
|
|
184
|
+
cleanupFixture(fixture, cmd);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('e2e: `cargo test | grep FAIL` → no inject (output filter)', () => {
|
|
189
|
+
const fixture = e2eFixture(`process.stdout.write('should not run\\n');`);
|
|
190
|
+
const cmd = `cargo test | grep FAIL`;
|
|
191
|
+
try {
|
|
192
|
+
const res = runHook(cmd, fixture);
|
|
193
|
+
assert.equal(res.status, 0);
|
|
194
|
+
assert.equal(res.stdout.trim(), '', 'an output-filter pipe must not inject');
|
|
195
|
+
} finally {
|
|
196
|
+
cleanupFixture(fixture, cmd);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('e2e: stub reports no hits → silent (no inject)', () => {
|
|
201
|
+
const uniq = `PostMiss${Date.now()}`;
|
|
202
|
+
const fixture = e2eFixture(
|
|
203
|
+
`process.stdout.write('[code-graph] No matches\\n');`);
|
|
204
|
+
const cmd = `echo go && grep "${uniq}" src/`;
|
|
205
|
+
try {
|
|
206
|
+
const res = runHook(cmd, fixture);
|
|
207
|
+
assert.equal(res.status, 0);
|
|
208
|
+
assert.equal(res.stdout.trim(), '', 'no-hits must inject nothing');
|
|
209
|
+
} finally {
|
|
210
|
+
cleanupFixture(fixture, cmd);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('e2e: CODE_GRAPH_NO_INJECT=1 silences the hook', () => {
|
|
215
|
+
const uniq = `PostOptout${Date.now()}`;
|
|
216
|
+
const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 hit\\n');`);
|
|
217
|
+
const cmd = `echo go && grep "${uniq}" src/`;
|
|
218
|
+
try {
|
|
219
|
+
const res = runHook(cmd, fixture, { CODE_GRAPH_NO_INJECT: '1' });
|
|
220
|
+
assert.equal(res.status, 0);
|
|
221
|
+
assert.equal(res.stdout.trim(), '', 'opt-out must silence the inject');
|
|
222
|
+
} finally {
|
|
223
|
+
cleanupFixture(fixture, cmd);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('e2e: per-command cooldown — verbatim re-run within window injects only once', () => {
|
|
228
|
+
const uniq = `PostCool${Date.now()}`;
|
|
229
|
+
const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 hit\\n');`);
|
|
230
|
+
const cmd = `echo go && grep "${uniq}" src/`;
|
|
231
|
+
try {
|
|
232
|
+
const r1 = runHook(cmd, fixture);
|
|
233
|
+
assert.notEqual(r1.stdout.trim(), '', 'first run injects');
|
|
234
|
+
const r2 = runHook(cmd, fixture);
|
|
235
|
+
assert.equal(r2.stdout.trim(), '', 'second run within cooldown is silent');
|
|
236
|
+
} finally {
|
|
237
|
+
cleanupFixture(fixture, cmd);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('e2e: no index up to $HOME → silent exit 0', () => {
|
|
242
|
+
// A cwd with no .code-graph anywhere up the tree resolves to null root → exit.
|
|
243
|
+
const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'post-grep-noidx-'));
|
|
244
|
+
const stub = path.join(bare, 'cg-stub.js');
|
|
245
|
+
fs.writeFileSync(stub, '#!/usr/bin/env node\nprocess.stdout.write("hit\\n");');
|
|
246
|
+
fs.chmodSync(stub, 0o755);
|
|
247
|
+
const cmd = `echo go && grep "FooBar" src/`;
|
|
248
|
+
try {
|
|
249
|
+
const res = spawnSync(process.execPath, [path.join(__dirname, 'post-grep-inject.js')], {
|
|
250
|
+
cwd: bare,
|
|
251
|
+
input: JSON.stringify({ tool_input: { command: cmd } }),
|
|
252
|
+
encoding: 'utf8',
|
|
253
|
+
env: { ...process.env, _CG_ANSWER_BINARY: stub, HOME: bare, CODE_GRAPH_QUIET_HOOKS: '0' },
|
|
254
|
+
});
|
|
255
|
+
assert.equal(res.status, 0);
|
|
256
|
+
assert.equal(res.stdout.trim(), '');
|
|
257
|
+
} finally {
|
|
258
|
+
fs.rmSync(bare, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
});
|
|
@@ -14,6 +14,7 @@ const { cgTmpDir } = require('./tmp-dir');
|
|
|
14
14
|
const { resolveProjectRoot } = require('./project-root');
|
|
15
15
|
const { recordRecommendation } = require('./recommendation-log');
|
|
16
16
|
const { formatCoveringTests } = require('./covering-tests');
|
|
17
|
+
const { emitPreToolAllowContext } = require('./hook-emit');
|
|
17
18
|
|
|
18
19
|
// v0.49 — walk up from the shell cwd (subdir-cwd fix). The per-cwd index.db
|
|
19
20
|
// gate kept this hook dark for entire sessions after `cd backend/` — daagu
|
|
@@ -211,4 +212,10 @@ summary += formatCoveringTests(jsonResult.test_callers, editedFile);
|
|
|
211
212
|
// fire with the count alone — the verdict must stay coherent either way.
|
|
212
213
|
summary += ` → Before this edit: confirm each caller of ${symbol}() still holds with your change, or note why it is unaffected.\n`;
|
|
213
214
|
|
|
214
|
-
|
|
215
|
+
// Compound-grep sibling sweep: deliver via the PreToolUse allow+additionalContext
|
|
216
|
+
// envelope (shared hook-emit.js). Bare stdout on a PreToolUse exit-0 lands in the
|
|
217
|
+
// debug log only and never reaches the model (CC docs v2026-06); additionalContext
|
|
218
|
+
// is what actually surfaces the impact summary. Impact must stay PRE-edit (so the
|
|
219
|
+
// reconciliation happens before the change), hence allow + additionalContext, not
|
|
220
|
+
// a PostToolUse inject.
|
|
221
|
+
process.stdout.write(emitPreToolAllowContext(summary) + '\n');
|
|
@@ -197,3 +197,22 @@ test('covering-tests: edit injection records test_targets for the forward funnel
|
|
|
197
197
|
const source = fs.readFileSync(path.join(__dirname, 'pre-edit-guide.js'), 'utf8');
|
|
198
198
|
assert.match(source, /test_targets:/);
|
|
199
199
|
});
|
|
200
|
+
|
|
201
|
+
// ── Compound-grep sibling sweep: impact summary → additionalContext ──
|
|
202
|
+
// Bare `process.stdout.write(summary)` on a PreToolUse exit-0 lands in the debug
|
|
203
|
+
// log only and never reaches the model (CC docs v2026-06). The impact summary
|
|
204
|
+
// must ride the shared PreToolUse allow+additionalContext envelope instead.
|
|
205
|
+
// Source-grep, same convention as the salience/pattern-sync guards (hook exits
|
|
206
|
+
// on require: reads stdin, resolves the index).
|
|
207
|
+
|
|
208
|
+
test('emit: impact summary is delivered via the PreToolUse allow+additionalContext envelope', () => {
|
|
209
|
+
const fs = require('node:fs');
|
|
210
|
+
const path = require('node:path');
|
|
211
|
+
const source = fs.readFileSync(path.join(__dirname, 'pre-edit-guide.js'), 'utf8');
|
|
212
|
+
assert.match(source, /require\(['"]\.\/hook-emit['"]\)/,
|
|
213
|
+
'pre-edit-guide must use the shared hook-emit module (no inline envelope copy)');
|
|
214
|
+
assert.match(source, /emitPreToolAllowContext\(summary\)/,
|
|
215
|
+
'the impact summary must be carried inside additionalContext, not bare stdout');
|
|
216
|
+
assert.doesNotMatch(source, /process\.stdout\.write\(summary\)\s*;/,
|
|
217
|
+
'the bare stdout summary emission (debug-log-only) must be removed');
|
|
218
|
+
});
|
|
@@ -382,6 +382,74 @@ function pickBlockPattern(cmd) {
|
|
|
382
382
|
return extractPatterns(cmd).find(p => IDENTIFIER_LIKE.test(p));
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
+
// Compound-grep PostToolUse splitter. Split a command into top-level segments on
|
|
386
|
+
// `&&`, `||`, `;`, newline, and shell `for … in` / `do` / `done` control-word
|
|
387
|
+
// boundaries — but NOT on a single `|`: a `cargo test | grep X` is an OUTPUT
|
|
388
|
+
// FILTER (its head stays `cargo`, so it is excluded from folding), exactly as
|
|
389
|
+
// PIPE_INTO_GREP treats it in the PreToolUse path. Quote-aware: separators
|
|
390
|
+
// inside single/double quotes are literal command text, never split points.
|
|
391
|
+
// Returns trimmed, non-empty segments. Shared by post-grep-inject so the
|
|
392
|
+
// PostToolUse path reuses this splitter instead of copying it.
|
|
393
|
+
function splitTopLevelSegments(cmd) {
|
|
394
|
+
if (!cmd || typeof cmd !== 'string') return [];
|
|
395
|
+
const segs = [];
|
|
396
|
+
let cur = '';
|
|
397
|
+
let quote = null;
|
|
398
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
399
|
+
const c = cmd[i];
|
|
400
|
+
if (quote) {
|
|
401
|
+
cur += c;
|
|
402
|
+
// Inside DOUBLE quotes a backslash escapes the next char, so `\"` does NOT
|
|
403
|
+
// close the quote (POSIX). Single quotes do no escaping — `\` is literal
|
|
404
|
+
// and `'` always closes — so this only applies to `"`. Without it,
|
|
405
|
+
// `echo "x\" && grep \"Y\" src/"` (one literal echo arg) mis-closes at
|
|
406
|
+
// `\"`, splits on `&&`, and yields a phantom foldable grep segment.
|
|
407
|
+
if (quote === '"' && c === '\\' && i + 1 < cmd.length) {
|
|
408
|
+
cur += cmd[i + 1];
|
|
409
|
+
i++;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (c === quote) quote = null;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (c === '"' || c === "'") { quote = c; cur += c; continue; }
|
|
416
|
+
// `&&` and `||` (a single `&`/`|` is NOT a split — `|` is an output-filter
|
|
417
|
+
// pipe, lone `&` is background and rare in tool calls).
|
|
418
|
+
if ((c === '&' && cmd[i + 1] === '&') || (c === '|' && cmd[i + 1] === '|')) {
|
|
419
|
+
segs.push(cur); cur = ''; i++; continue;
|
|
420
|
+
}
|
|
421
|
+
if (c === ';' || c === '\n') { segs.push(cur); cur = ''; continue; }
|
|
422
|
+
cur += c;
|
|
423
|
+
}
|
|
424
|
+
segs.push(cur);
|
|
425
|
+
// Split out `for … in` / `do` / `done` control words as their own boundaries
|
|
426
|
+
// so a loop body grep is isolated (the head of `for s in …; do grep …` is the
|
|
427
|
+
// `for` keyword, which would otherwise mask the grep). Quote-safety already
|
|
428
|
+
// handled above — these run per already-split segment on whitespace-delimited
|
|
429
|
+
// control words only.
|
|
430
|
+
const out = [];
|
|
431
|
+
const CTRL = /(?:^|\s)(for\s+\S+\s+in\b|do\b|done\b|then\b|fi\b)(?=\s|$)/g;
|
|
432
|
+
for (const raw of segs) {
|
|
433
|
+
let last = 0;
|
|
434
|
+
let m;
|
|
435
|
+
CTRL.lastIndex = 0;
|
|
436
|
+
let pushed = false;
|
|
437
|
+
while ((m = CTRL.exec(raw)) !== null) {
|
|
438
|
+
const before = raw.slice(last, m.index);
|
|
439
|
+
if (before.trim()) out.push(before);
|
|
440
|
+
last = CTRL.lastIndex;
|
|
441
|
+
pushed = true;
|
|
442
|
+
}
|
|
443
|
+
if (pushed) {
|
|
444
|
+
const tail = raw.slice(last);
|
|
445
|
+
if (tail.trim()) out.push(tail);
|
|
446
|
+
} else {
|
|
447
|
+
out.push(raw);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return out.map(s => s.trim()).filter(Boolean);
|
|
451
|
+
}
|
|
452
|
+
|
|
385
453
|
function commandHash(cmd) {
|
|
386
454
|
return crypto.createHash('sha1').update(cmd).digest('hex').slice(0, 12);
|
|
387
455
|
}
|
|
@@ -664,8 +732,14 @@ function runMain() {
|
|
|
664
732
|
return;
|
|
665
733
|
}
|
|
666
734
|
|
|
667
|
-
|
|
668
|
-
|
|
735
|
+
// Compound-grep change: the dark-stdout HINT fallthrough was DELETED. A grep
|
|
736
|
+
// that passes shouldHint but NOT classifyBlock used to record action:'hint'
|
|
737
|
+
// and write buildHint() to stdout — but PreToolUse exit-0 plain stdout goes to
|
|
738
|
+
// the DEBUG LOG ONLY and never reaches the model (CC docs v2026-06). It was
|
|
739
|
+
// pure noise. These hint-tier greps (unanswerable-flag / marker / multi-path)
|
|
740
|
+
// are exactly the cases cg cannot fold, so silence is correct: the model's own
|
|
741
|
+
// grep runs unimpeded. classifyBlock-positive compound greps are now picked up
|
|
742
|
+
// permission-neutrally by the PostToolUse post-grep-inject hook.
|
|
669
743
|
}
|
|
670
744
|
|
|
671
745
|
if (require.main === module) {
|
|
@@ -676,6 +750,7 @@ module.exports = {
|
|
|
676
750
|
shouldHint,
|
|
677
751
|
shouldBlock,
|
|
678
752
|
classifyBlock, // v0.49 — intent-aware block tiers
|
|
753
|
+
splitTopLevelSegments, // compound-grep — quote-aware top-level segment splitter (PostToolUse reuse)
|
|
679
754
|
extractDeclSymbols, // v0.49 — show-mode symbol extraction
|
|
680
755
|
translateBreToRg, // v0.49 — BRE→rust-regex dialect bridge
|
|
681
756
|
buildShowDenyReason, // v0.49 — show-mode deny copy
|
|
@@ -5,6 +5,7 @@ const {
|
|
|
5
5
|
shouldHint,
|
|
6
6
|
shouldBlock,
|
|
7
7
|
classifyBlock,
|
|
8
|
+
splitTopLevelSegments,
|
|
8
9
|
countNamedPaths,
|
|
9
10
|
extractDeclSymbols,
|
|
10
11
|
translateBreToRg,
|
|
@@ -1529,3 +1530,93 @@ test('extractSedReadTargets: pipeline sed after grep still extracted', () => {
|
|
|
1529
1530
|
extractSedReadTargets('grep -n "x" src/a.py | sed -n 1,5p src/b.py'),
|
|
1530
1531
|
['src/b.py']);
|
|
1531
1532
|
});
|
|
1533
|
+
|
|
1534
|
+
// ── splitTopLevelSegments (compound-grep PostToolUse §1) ─────────────
|
|
1535
|
+
// Quote-aware top-level splitter shared by post-grep-inject. Splits on &&, ||,
|
|
1536
|
+
// ;, newline, and for…in / do / done boundaries — NOT on a single `|` (so a
|
|
1537
|
+
// pipe-into-grep keeps head=cargo and is recognized as an output filter).
|
|
1538
|
+
|
|
1539
|
+
test('splitTopLevelSegments: && joins two commands → two segments', () => {
|
|
1540
|
+
assert.deepEqual(
|
|
1541
|
+
splitTopLevelSegments('echo "x" && grep Sym tests/'),
|
|
1542
|
+
['echo "x"', 'grep Sym tests/']);
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
test('splitTopLevelSegments: ; and || are top-level separators', () => {
|
|
1546
|
+
assert.deepEqual(
|
|
1547
|
+
splitTopLevelSegments('git diff; grep Sym src/ || echo none'),
|
|
1548
|
+
['git diff', 'grep Sym src/', 'echo none']);
|
|
1549
|
+
});
|
|
1550
|
+
|
|
1551
|
+
test('splitTopLevelSegments: a single pipe is NOT a separator (output filter)', () => {
|
|
1552
|
+
// cargo test | grep X must keep head=cargo so it reads as an output filter,
|
|
1553
|
+
// NOT a foldable grep segment.
|
|
1554
|
+
assert.deepEqual(
|
|
1555
|
+
splitTopLevelSegments('cargo test | grep FAIL'),
|
|
1556
|
+
['cargo test | grep FAIL']);
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
test('splitTopLevelSegments: for … in / do / done are segment boundaries', () => {
|
|
1560
|
+
const segs = splitTopLevelSegments('for s in a b; do grep "$s" src/; done');
|
|
1561
|
+
// the grep body is isolated as its own segment
|
|
1562
|
+
assert.ok(segs.some(seg => /grep "\$s" src\//.test(seg)),
|
|
1563
|
+
`grep body not isolated: ${JSON.stringify(segs)}`);
|
|
1564
|
+
// the for-header / do / done keywords are not glued onto the grep
|
|
1565
|
+
assert.ok(!segs.some(seg => /for s in/.test(seg) && /grep/.test(seg)),
|
|
1566
|
+
`for-header glued to grep: ${JSON.stringify(segs)}`);
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
test('splitTopLevelSegments: separators inside quotes are literal, not splits', () => {
|
|
1570
|
+
assert.deepEqual(
|
|
1571
|
+
splitTopLevelSegments('grep "a && b; c" src/'),
|
|
1572
|
+
['grep "a && b; c" src/']);
|
|
1573
|
+
assert.deepEqual(
|
|
1574
|
+
splitTopLevelSegments("grep 'x || y' src/"),
|
|
1575
|
+
["grep 'x || y' src/"]);
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
test('splitTopLevelSegments: backslash-escaped quote inside double quotes does NOT close (no phantom segment)', () => {
|
|
1579
|
+
// One literal echo arg — the \" must not close the quote, so && stays inside
|
|
1580
|
+
// the string and no foldable `grep` segment is split out. (review L1)
|
|
1581
|
+
assert.deepEqual(
|
|
1582
|
+
splitTopLevelSegments('echo "x\\" && grep \\"Y\\" src/ rest"'),
|
|
1583
|
+
['echo "x\\" && grep \\"Y\\" src/ rest"']);
|
|
1584
|
+
// Single quotes do NOT process backslashes (POSIX): a real separator after a
|
|
1585
|
+
// closed single-quoted string still splits.
|
|
1586
|
+
assert.deepEqual(
|
|
1587
|
+
splitTopLevelSegments("echo 'a\\' && grep Sym src/"),
|
|
1588
|
+
["echo 'a\\'", 'grep Sym src/']);
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
test('splitTopLevelSegments: newline is a separator', () => {
|
|
1592
|
+
assert.deepEqual(
|
|
1593
|
+
splitTopLevelSegments('echo hi\ngrep Sym src/'),
|
|
1594
|
+
['echo hi', 'grep Sym src/']);
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
test('splitTopLevelSegments: empty / non-string → empty array', () => {
|
|
1598
|
+
assert.deepEqual(splitTopLevelSegments(''), []);
|
|
1599
|
+
assert.deepEqual(splitTopLevelSegments(null), []);
|
|
1600
|
+
assert.deepEqual(splitTopLevelSegments(undefined), []);
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
test('splitTopLevelSegments: trims and drops empty segments', () => {
|
|
1604
|
+
assert.deepEqual(
|
|
1605
|
+
splitTopLevelSegments(' echo a ;; grep Sym src/ '),
|
|
1606
|
+
['echo a', 'grep Sym src/']);
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
// The dark-hint fallthrough (action:'hint' + stdout buildHint) was DELETED in
|
|
1610
|
+
// the compound-grep change: a grep that passes shouldHint but not classifyBlock
|
|
1611
|
+
// now exits silently from PreToolUse (PostToolUse handles only classifyBlock
|
|
1612
|
+
// non-null cases). buildHint stays exported (referenced above) but is never
|
|
1613
|
+
// emitted by the runMain hint tier.
|
|
1614
|
+
test('source-text: PreToolUse no longer emits the dark stdout hint fallthrough', () => {
|
|
1615
|
+
const fs = require('node:fs');
|
|
1616
|
+
const path = require('node:path');
|
|
1617
|
+
const src = fs.readFileSync(path.join(__dirname, 'pre-grep-guide.js'), 'utf8');
|
|
1618
|
+
assert.doesNotMatch(src, /process\.stdout\.write\(buildHint\(\)/,
|
|
1619
|
+
'the dark hint stdout emission must be removed (PreToolUse exit-0 stdout is debug-log-only)');
|
|
1620
|
+
assert.doesNotMatch(src, /action:\s*'hint'\s*\}\);\s*\n\s*process\.stdout\.write\(buildHint/,
|
|
1621
|
+
'the hint-tier recordRecommendation + buildHint pair must be removed');
|
|
1622
|
+
});
|
|
@@ -29,6 +29,7 @@ const { cgTmpDir } = require('./tmp-dir');
|
|
|
29
29
|
const { recordRecommendation } = require('./recommendation-log');
|
|
30
30
|
const { resolveProjectRoot } = require('./project-root');
|
|
31
31
|
const { runOverviewAnswer } = require('./cg-answer');
|
|
32
|
+
const { emitPreToolAllowContext } = require('./hook-emit');
|
|
32
33
|
|
|
33
34
|
// --- Configuration ---
|
|
34
35
|
|
|
@@ -185,7 +186,13 @@ function trackReadAndMaybeHint(root, rel, now = Date.now()) {
|
|
|
185
186
|
// so the read-fanout funnel can tell a dark flagship apart from no result.
|
|
186
187
|
...(answered ? {} : { reason: answer.status }),
|
|
187
188
|
});
|
|
188
|
-
|
|
189
|
+
// Compound-grep sibling sweep: emit via the PreToolUse allow+additionalContext
|
|
190
|
+
// envelope (shared hook-emit.js). Bare stdout on a PreToolUse exit-0 lands in
|
|
191
|
+
// the debug log only and never reaches the model (CC docs v2026-06); the
|
|
192
|
+
// additionalContext channel is what actually surfaces the fanout hint. Read is
|
|
193
|
+
// a safe tool, so the allow elevation is negligible.
|
|
194
|
+
const hintText = answered ? buildHintWithAnswer(dir, answer) : buildHint(dir);
|
|
195
|
+
process.stdout.write(emitPreToolAllowContext(hintText) + '\n');
|
|
189
196
|
return true;
|
|
190
197
|
}
|
|
191
198
|
|
|
@@ -279,7 +279,15 @@ test('trackReadAndMaybeHint: fires on 5th read with stubbed overview answer', ()
|
|
|
279
279
|
fired = trackReadAndMaybeHint(root, 'src/storage/file' + i + '.rs');
|
|
280
280
|
}
|
|
281
281
|
assert.equal(fired, true, '5th same-dir read must fire');
|
|
282
|
-
|
|
282
|
+
// Compound-grep sibling sweep: the fanout hint is now emitted as a
|
|
283
|
+
// PreToolUse allow+additionalContext envelope (was bare stdout, which CC
|
|
284
|
+
// routes to the debug log only and never shows the model). The overview
|
|
285
|
+
// answer must ride inside additionalContext.
|
|
286
|
+
const emitted = JSON.parse(written.join(''));
|
|
287
|
+
assert.equal(emitted.hookSpecificOutput.hookEventName, 'PreToolUse');
|
|
288
|
+
assert.equal(emitted.hookSpecificOutput.permissionDecision, 'allow');
|
|
289
|
+
assert.match(emitted.hookSpecificOutput.additionalContext, /Module overview stub/,
|
|
290
|
+
'hint must EMBED the overview answer in additionalContext');
|
|
283
291
|
const recs = fs.readFileSync(path.join(root, '.code-graph', 'recommendations.jsonl'), 'utf8');
|
|
284
292
|
assert.match(recs, /"hook":"read"/);
|
|
285
293
|
assert.match(recs, /"answered":true/);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.73.0",
|
|
4
4
|
"description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"node": ">=16"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|
|
38
|
-
"@sdsrs/code-graph-linux-x64": "0.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.73.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.73.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.73.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.73.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.73.0"
|
|
43
43
|
}
|
|
44
44
|
}
|