@sdsrs/code-graph 0.72.0 → 0.73.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.72.0",
7
+ "version": "0.73.1",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -330,6 +330,32 @@ function relicRepairGuard({ log = console.log, relic = undefined } = {}) {
330
330
  return true;
331
331
  }
332
332
 
333
+ // A dev-mode rebuild must PRESERVE the existing binary's feature set. This repair
334
+ // used to hardcode `--no-default-features`, which silently downgraded a hybrid
335
+ // (embed-model) dev binary to FTS5-only and ping-ponged against a manual
336
+ // `cargo build --release --features embed-model`. Probe the binary's COMPILED
337
+ // feature via `health-check --json` → `model_available` (= cfg!(feature =
338
+ // "embed-model"), reported even with no index) and rebuild to match. Returns
339
+ // true/false, or null when the binary can't be probed (missing/broken) — the
340
+ // caller then defaults to FTS5 + an explicit note, never a silent downgrade.
341
+ // End users never reach this path (binary-stale → auto-update; binary-missing
342
+ // non-dev → install instructions); it is purely the source-repo dev convenience.
343
+ function detectEmbedModel(binary, run = execFileSync) {
344
+ if (!binary) return null;
345
+ try {
346
+ const out = run(binary, ['health-check', '--json'], {
347
+ timeout: 10000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
348
+ });
349
+ return JSON.parse(out).model_available === true;
350
+ } catch { return null; }
351
+ }
352
+
353
+ function devBuildCommand(embed) {
354
+ return embed
355
+ ? 'cargo build --release --features embed-model'
356
+ : 'cargo build --release --no-default-features';
357
+ }
358
+
333
359
  function runRepairs(results) {
334
360
  const fixable = results.filter(r => r.fixId);
335
361
  if (fixable.length === 0) return 0;
@@ -353,14 +379,23 @@ function runRepairs(results) {
353
379
  }
354
380
  break;
355
381
  }
382
+ // Preserve the current binary's feature set \u2014 never silently downgrade
383
+ // a hybrid (embed-model) dev binary to FTS5-only (which also ping-pongs
384
+ // against a manual `--features embed-model` build).
385
+ const embed = detectEmbedModel(findBinary());
386
+ const buildCmd = devBuildCommand(embed === true);
356
387
  console.log('\n Building binary...');
357
- console.log(' \u2192 cargo build --release --no-default-features');
388
+ if (embed === null) {
389
+ console.log(' (could not detect current feature set \u2014 building FTS5-only;');
390
+ console.log(' for semantic search rebuild with `cargo build --release --features embed-model`)');
391
+ }
392
+ console.log(` \u2192 ${buildCmd}`);
358
393
  try {
359
394
  const projectRoot = path.resolve(__dirname, '..', '..');
360
- execSync('cargo build --release --no-default-features', {
395
+ execSync(buildCmd, {
361
396
  cwd: projectRoot,
362
397
  stdio: 'inherit',
363
- timeout: 300000,
398
+ timeout: 600000, // embed-model (Candle) builds exceed the old 5min
364
399
  });
365
400
  clearBinaryCache();
366
401
  console.log(' \u2705 Build complete');
@@ -374,13 +409,16 @@ function runRepairs(results) {
374
409
  case 'binary-missing': {
375
410
  console.log('\n Installing binary...');
376
411
  if (isDevMode()) {
412
+ // No binary to probe \u2014 build the fast FTS5 binary, but point at the
413
+ // hybrid option so FTS5 isn't silently presented as the only choice.
377
414
  console.log(' \u2192 cargo build --release --no-default-features');
415
+ console.log(' (for semantic search: cargo build --release --features embed-model)');
378
416
  try {
379
417
  const projectRoot = path.resolve(__dirname, '..', '..');
380
418
  execSync('cargo build --release --no-default-features', {
381
419
  cwd: projectRoot,
382
420
  stdio: 'inherit',
383
- timeout: 300000,
421
+ timeout: 600000,
384
422
  });
385
423
  clearBinaryCache();
386
424
  console.log(' \u2705 Build complete');
@@ -500,7 +538,7 @@ function runDoctor(opts = {}) {
500
538
  return { results, issueCount: issues.length };
501
539
  }
502
540
 
503
- module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, surveyHookCoverage, relicRepairGuard, classifyEmbeddings };
541
+ module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, detectEmbedModel, devBuildCommand };
504
542
 
505
543
  if (require.main === module) {
506
544
  const args = process.argv.slice(2);
@@ -122,3 +122,30 @@ test('classifyEmbeddings OK for hybrid (partial + complete) and no-embeddable',
122
122
  assert.equal(none.status, 'ok');
123
123
  assert.match(none.detail, /no embeddable nodes/);
124
124
  });
125
+
126
+ // ── dev-rebuild feature preservation (no silent hybrid→FTS5 downgrade / ping-pong) ──
127
+ test('devBuildCommand preserves feature set: hybrid → --features embed-model, fts → --no-default-features', () => {
128
+ const { devBuildCommand } = require('./doctor');
129
+ assert.match(devBuildCommand(true), /--features embed-model/);
130
+ assert.doesNotMatch(devBuildCommand(true), /--no-default-features/);
131
+ assert.match(devBuildCommand(false), /--no-default-features/);
132
+ assert.doesNotMatch(devBuildCommand(false), /--features embed-model/);
133
+ });
134
+
135
+ test('detectEmbedModel reads model_available from `health-check --json`; probe failure → null (never a false downgrade signal)', () => {
136
+ const { detectEmbedModel } = require('./doctor');
137
+ // hybrid binary
138
+ const hybridStub = (_bin, args) => {
139
+ assert.deepEqual(args, ['health-check', '--json']);
140
+ return JSON.stringify({ model_available: true });
141
+ };
142
+ assert.equal(detectEmbedModel('/bin/cg', hybridStub), true);
143
+ // FTS5-only binary
144
+ assert.equal(detectEmbedModel('/bin/cg', () => JSON.stringify({ model_available: false })), false);
145
+ // probe throws (binary broken) → null (caller defaults to FTS5 + note, not a downgrade claim)
146
+ assert.equal(detectEmbedModel('/bin/cg', () => { throw new Error('boom'); }), null);
147
+ // unparseable output → null
148
+ assert.equal(detectEmbedModel('/bin/cg', () => 'not json'), null);
149
+ // no binary → null
150
+ assert.equal(detectEmbedModel(null), null);
151
+ });
@@ -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
+ });
@@ -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
- 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');
@@ -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
- 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.72.0",
3
+ "version": "0.73.1",
4
4
  "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,10 +35,10 @@
35
35
  "node": ">=16"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@sdsrs/code-graph-linux-x64": "0.72.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.72.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.72.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.72.0",
42
- "@sdsrs/code-graph-win32-x64": "0.72.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.73.1",
39
+ "@sdsrs/code-graph-linux-arm64": "0.73.1",
40
+ "@sdsrs/code-graph-darwin-x64": "0.73.1",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.73.1",
42
+ "@sdsrs/code-graph-win32-x64": "0.73.1"
43
43
  }
44
44
  }