@sdsrs/code-graph 0.71.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.71.0",
7
+ "version": "0.73.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+ // Pure formatter for edit-time covering-test targeting.
3
+ //
4
+ // pre-edit-guide.js feeds this the `test_callers` from `impact --json` (the tests
5
+ // that exercise the symbol being edited) plus the edited file's path. It returns a
6
+ // summary fragment that turns the bare "(N tests)" count into an actionable,
7
+ // language-aware run command — so the fix-test-iterate loop runs the TARGETED
8
+ // covering tests, not the whole suite or a guessed test name.
9
+ //
10
+ // Pure + side-effect-free so it's unit-testable by require() — unlike the hook
11
+ // itself, which top-level-exits on require (reads stdin / checks the index).
12
+
13
+ // Display cap: list at most this many "name (file)" entries before collapsing to a
14
+ // bare count. Above the cap a long per-name list is noise in the injected context.
15
+ const LIST_CAP = 6;
16
+
17
+ // Detect the project's test runner from the edited file's extension. v1 emits a
18
+ // REAL targeted command only for Rust (`cargo test` accepts bare substring filters,
19
+ // so the test fn names work directly). Other languages degrade to listing the
20
+ // covering tests with no command — a wrong/guessed command is worse than none
21
+ // (jest vs vitest vs mocha / pytest node ids / `go test -run` all differ).
22
+ function detectRunner(filePath) {
23
+ if (typeof filePath === 'string' && /\.rs$/.test(filePath)) return 'rust';
24
+ return null;
25
+ }
26
+
27
+ function targetedCommand(runner, names) {
28
+ if (runner === 'rust') return `cargo test ${names.join(' ')}`;
29
+ return null;
30
+ }
31
+
32
+ function suiteCommand(runner) {
33
+ if (runner === 'rust') return 'cargo test';
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * Build the covering-tests summary fragment.
39
+ * @param {Array<{name:string,file:string}>} testCallers from `impact --json` test_callers
40
+ * @param {string} editedFile the file being edited (drives runner detection)
41
+ * @returns {string} a `\n`-terminated fragment, or '' when there's nothing to add
42
+ */
43
+ function formatCoveringTests(testCallers, editedFile) {
44
+ const tests = Array.isArray(testCallers) ? testCallers.filter((t) => t && t.name) : [];
45
+ const n = tests.length;
46
+ if (n === 0) return '';
47
+
48
+ const runner = detectRunner(editedFile);
49
+
50
+ if (n <= LIST_CAP) {
51
+ const list = tests.map((t) => `${t.name} (${t.file})`).join(', ');
52
+ let out = ` Covering tests (${n}): ${list}\n`;
53
+ const cmd = targetedCommand(runner, tests.map((t) => t.name));
54
+ if (cmd) out += ` → Run after editing: ${cmd}\n`;
55
+ return out;
56
+ }
57
+
58
+ // High fan-out: editing a widely-tested symbol. A long targeted command is noise
59
+ // — point at the suite instead (mirrors the blast-size scaling in
60
+ // session-init.js formatRecentImpact).
61
+ let out = ` Covering tests: ${n} — editing a widely-tested symbol\n`;
62
+ const suite = suiteCommand(runner);
63
+ if (suite) out += ` → Run the suite after editing: ${suite}\n`;
64
+ return out;
65
+ }
66
+
67
+ module.exports = { formatCoveringTests, LIST_CAP };
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const { formatCoveringTests, LIST_CAP } = require('./covering-tests');
5
+
6
+ // ── empty / robust ──────────────────────────────────────
7
+
8
+ test('covering: empty list → no output', () => {
9
+ assert.equal(formatCoveringTests([], 'src/a.rs'), '');
10
+ });
11
+
12
+ test('covering: missing/undefined → no output (never throws)', () => {
13
+ assert.equal(formatCoveringTests(undefined, 'src/a.rs'), '');
14
+ assert.equal(formatCoveringTests(null, 'src/a.rs'), '');
15
+ });
16
+
17
+ test('covering: entries without a name are dropped', () => {
18
+ const out = formatCoveringTests(
19
+ [{ name: 'test_real', file: 'tests/a.rs' }, { file: 'tests/nameless.rs' }],
20
+ 'src/a.rs'
21
+ );
22
+ assert.match(out, /Covering tests \(1\)/); // only the named one counts
23
+ assert.match(out, /test_real/);
24
+ });
25
+
26
+ // ── Rust: a real targeted command ───────────────────────
27
+
28
+ test('covering: Rust ≤cap lists names + a targeted `cargo test` command', () => {
29
+ const out = formatCoveringTests(
30
+ [
31
+ { name: 'test_alpha', file: 'tests/a.rs' },
32
+ { name: 'test_beta', file: 'src/b.rs' },
33
+ ],
34
+ 'src/foo.rs'
35
+ );
36
+ assert.match(out, /Covering tests \(2\)/);
37
+ assert.match(out, /test_alpha \(tests\/a\.rs\)/);
38
+ assert.match(out, /test_beta \(src\/b\.rs\)/);
39
+ // The actionable part: a command that runs exactly the covering tests.
40
+ assert.match(out, /Run after editing: cargo test test_alpha test_beta/);
41
+ });
42
+
43
+ // ── non-Rust: list only, never a fabricated command ─────
44
+
45
+ test('covering: non-Rust ≤cap lists names but emits NO command (no wrong command)', () => {
46
+ const out = formatCoveringTests(
47
+ [{ name: 'testValidate', file: 'src/auth.test.ts' }],
48
+ 'src/auth.ts'
49
+ );
50
+ assert.match(out, /Covering tests \(1\): testValidate \(src\/auth\.test\.ts\)/);
51
+ assert.doesNotMatch(out, /cargo test/);
52
+ assert.doesNotMatch(out, /Run after editing/);
53
+ });
54
+
55
+ // ── high fan-out: collapse, point at the suite ──────────
56
+
57
+ test('covering: Rust high fan-out (>cap) collapses to a count + suite command, no name list', () => {
58
+ const many = Array.from({ length: LIST_CAP + 1 }, (_, i) => ({
59
+ name: `test_${i}`,
60
+ file: 'tests/wide.rs',
61
+ }));
62
+ const out = formatCoveringTests(many, 'src/hot.rs');
63
+ assert.match(out, new RegExp(`Covering tests: ${LIST_CAP + 1}`));
64
+ assert.match(out, /widely-tested/);
65
+ assert.match(out, /Run the suite after editing: cargo test/);
66
+ // The long per-name list must NOT be inlined when fan-out is high.
67
+ assert.doesNotMatch(out, /test_0 \(/);
68
+ });
69
+
70
+ test('covering: non-Rust high fan-out collapses to a count with no command', () => {
71
+ const many = Array.from({ length: LIST_CAP + 3 }, (_, i) => ({
72
+ name: `test_${i}`,
73
+ file: 'a.test.ts',
74
+ }));
75
+ const out = formatCoveringTests(many, 'src/hot.ts');
76
+ assert.match(out, new RegExp(`Covering tests: ${LIST_CAP + 3}`));
77
+ assert.doesNotMatch(out, /cargo test/);
78
+ });
@@ -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 + 1 PostToolUse + 1 UserPromptSubmit = 5 settings.json hooks
21
- assert.ok(results.length >= 5, `expected >=5 hook probes, got ${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 + 1 PostToolUse + 1 UserPromptSubmit + 1 SessionStart = 6
186
- assert.ok(commands.length >= 6, `expected >=6 registered hook commands, got ${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 re-index trigger surface must be deliberate');
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
- return { tool_name: 'Bash', tool_input: { command: 'grep -rn someUniqueSymbol src/' } };
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
+ });
@@ -13,6 +13,8 @@ const { findBinary } = require('./find-binary');
13
13
  const { cgTmpDir } = require('./tmp-dir');
14
14
  const { resolveProjectRoot } = require('./project-root');
15
15
  const { recordRecommendation } = require('./recommendation-log');
16
+ const { formatCoveringTests } = require('./covering-tests');
17
+ const { emitPreToolAllowContext } = require('./hook-emit');
16
18
 
17
19
  // v0.49 — walk up from the shell cwd (subdir-cwd fix). The per-cwd index.db
18
20
  // gate kept this hook dark for entire sessions after `cd backend/` — daagu
@@ -170,7 +172,13 @@ try { fs.writeFileSync(cooldownFile, ''); } catch { /* ok */ }
170
172
  // Funnel visibility (v0.49): an injected impact summary is a delivered answer.
171
173
  // v0.63 — ack:true marks that this injection carries a salience-forcing directive
172
174
  // (the per-caller verdict line below), so a later A/B can segment ack vs non-ack.
173
- recordRecommendation(cwd, { hook: 'edit', action: 'hint', answered: true, ack: true });
175
+ // test_targets: how many covering tests this injection offered — the forward
176
+ // signal for whether covering-test targeting reduces test-name guessing (read on
177
+ // consumer projects; this dogfood repo's metrics are dark).
178
+ recordRecommendation(cwd, {
179
+ hook: 'edit', action: 'hint', answered: true, ack: true,
180
+ test_targets: (jsonResult.test_callers || []).length,
181
+ });
174
182
 
175
183
  // --- Inject compact impact summary ---
176
184
  const routeCount = jsonResult.affected_routes || 0;
@@ -188,6 +196,12 @@ if (callers.length > 0) {
188
196
  summary += ' Callers: ' + callers.map(c => `${c.name} (${c.file})`).join(', ') + '\n';
189
197
  }
190
198
 
199
+ // Covering tests — turn the bare "(N tests)" count above into an actionable,
200
+ // targeted run command so the fix-test-iterate loop runs exactly the tests that
201
+ // exercise the edited symbol (not the whole suite or a guessed name). Empty/absent
202
+ // test_callers → appends nothing.
203
+ summary += formatCoveringTests(jsonResult.test_callers, editedFile);
204
+
191
205
  // Salience forcing (v0.63) — an injected impact summary that the model merely
192
206
  // reads is wasted context. mem's PreToolUse edit hook lifts cite-recall to ~94%
193
207
  // by making the model ACT on the injection ("apply each lesson or rule it out")
@@ -198,4 +212,10 @@ if (callers.length > 0) {
198
212
  // fire with the count alone — the verdict must stay coherent either way.
199
213
  summary += ` → Before this edit: confirm each caller of ${symbol}() still holds with your change, or note why it is unaffected.\n`;
200
214
 
201
- process.stdout.write(summary);
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');
@@ -176,3 +176,43 @@ test('pattern-sync: fnPatterns count matches source', () => {
176
176
  assert.ok(fnPatterns.length === 8, `Expected 8 patterns, got ${fnPatterns.length}`);
177
177
  assert.ok(sourcePatternCount >= 7, `Source should have >= 7 language comments, found ${sourcePatternCount}`);
178
178
  });
179
+
180
+ // ── Covering-test targeting (edit-time PUSH) ────────────
181
+ // The pure formatter lives in covering-tests.js (unit-tested in covering-tests.test.js);
182
+ // these guard that the hook actually wires it in and records the forward signal.
183
+ // Source-grep, same convention as the salience/pattern-sync guards (hook exits on require).
184
+
185
+ test('covering-tests: hook requires and invokes the covering-tests formatter on test_callers', () => {
186
+ const fs = require('node:fs');
187
+ const path = require('node:path');
188
+ const source = fs.readFileSync(path.join(__dirname, 'pre-edit-guide.js'), 'utf8');
189
+ assert.match(source, /require\(['"]\.\/covering-tests['"]\)/);
190
+ assert.match(source, /formatCoveringTests\(/);
191
+ assert.match(source, /test_callers/);
192
+ });
193
+
194
+ test('covering-tests: edit injection records test_targets for the forward funnel', () => {
195
+ const fs = require('node:fs');
196
+ const path = require('node:path');
197
+ const source = fs.readFileSync(path.join(__dirname, 'pre-edit-guide.js'), 'utf8');
198
+ assert.match(source, /test_targets:/);
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
- recordRecommendation(root, { hook: 'grep', action: 'hint' });
668
- process.stdout.write(buildHint() + '\n');
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
- process.stdout.write((answered ? buildHintWithAnswer(dir, answer) : buildHint(dir)) + '\n');
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
- assert.match(written.join(''), /Module overview stub/, 'hint must EMBED the overview answer');
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.71.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.71.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.71.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.71.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.71.0",
42
- "@sdsrs/code-graph-win32-x64": "0.71.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
  }