@sdsrs/code-graph 0.83.0 → 0.84.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.83.0",
7
+ "version": "0.84.1",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -11,6 +11,7 @@ const { spawn, spawnSync } = require('child_process');
11
11
  const path = require('path');
12
12
  const fs = require('fs');
13
13
  const { isNonProjectCwd } = require('./project-detect');
14
+ const { serveEmptyMcpStub } = require('./mcp-stub');
14
15
 
15
16
  // Set plugin root so find-binary.js can locate bundled/dev binaries
16
17
  // Always derive from __dirname — CLAUDE_PLUGIN_ROOT can leak from other plugins
@@ -38,46 +39,8 @@ function projectHasLocalCodeGraphMcp(cwd) {
38
39
  } catch { return false; }
39
40
  }
40
41
 
41
- function serveEmptyMcpStub() {
42
- let buf = '';
43
- process.stdin.setEncoding('utf8');
44
- process.stdin.on('data', (chunk) => {
45
- buf += chunk;
46
- let nl;
47
- while ((nl = buf.indexOf('\n')) >= 0) {
48
- const line = buf.slice(0, nl).trim();
49
- buf = buf.slice(nl + 1);
50
- if (!line) continue;
51
- let req;
52
- try { req = JSON.parse(line); } catch { continue; }
53
- if (!req || typeof req.method !== 'string') continue;
54
- // JSON-RPC notifications (id missing) get no response.
55
- if (typeof req.id === 'undefined') continue;
56
- const method = req.method;
57
- let result, error;
58
- if (method === 'initialize') {
59
- result = {
60
- protocolVersion: '2024-11-05',
61
- capabilities: { tools: { listChanged: false } },
62
- serverInfo: { name: 'code-graph-mcp (plugin stub, dedup)', version: '0.31.1' },
63
- };
64
- } else if (method === 'tools/list') {
65
- result = { tools: [] };
66
- } else if (method === 'resources/list') {
67
- result = { resources: [] };
68
- } else if (method === 'prompts/list') {
69
- result = { prompts: [] };
70
- } else {
71
- error = { code: -32601, message: 'method not found (plugin MCP is in dedup stub mode)' };
72
- }
73
- const resp = error
74
- ? { jsonrpc: '2.0', id: req.id, error }
75
- : { jsonrpc: '2.0', id: req.id, result };
76
- process.stdout.write(JSON.stringify(resp) + '\n');
77
- }
78
- });
79
- process.stdin.on('end', () => process.exit(0));
80
- }
42
+ // serveEmptyMcpStub lives in ./mcp-stub.js — a permanent 0-tool stub by default,
43
+ // or an in-place upgrading stub when passed { upgrade } (see the non-project gate).
81
44
 
82
45
  if (process.env.CODE_GRAPH_FORCE_PLUGIN_MCP !== '1' && projectHasLocalCodeGraphMcp(process.cwd())) {
83
46
  process.stderr.write(
@@ -98,10 +61,27 @@ if (process.env.CODE_GRAPH_FORCE_PLUGIN_MCP !== '1' && projectHasLocalCodeGraphM
98
61
  // CODE_GRAPH_FORCE_PLUGIN_MCP=1 override as the dedup gate above.
99
62
  if (process.env.CODE_GRAPH_FORCE_PLUGIN_MCP !== '1' && isNonProjectCwd(process.cwd())) {
100
63
  process.stderr.write(
101
- '[code-graph] non-project cwd (no .git/manifest); plugin MCP serving 0 tools, ' +
102
- 'no index created. Set CODE_GRAPH_FORCE_PLUGIN_MCP=1 to override.\n'
64
+ '[code-graph] non-project cwd (no .git/manifest); plugin MCP serving 0 tools ' +
65
+ '(auto-upgrades to real tools if this dir becomes a project — no restart). ' +
66
+ 'Set CODE_GRAPH_FORCE_PLUGIN_MCP=1 to override.\n'
103
67
  );
104
- serveEmptyMcpStub();
68
+ // Upgradeable stub: the non-project verdict is re-checked on a poll. If the
69
+ // cwd becomes a real project (git init / scaffold) with no local code-graph
70
+ // server, spawn the real binary and hand the live MCP connection over to it —
71
+ // fixes the "stub latched at launch" gap without a Claude Code restart.
72
+ serveEmptyMcpStub({
73
+ upgrade: {
74
+ shouldUpgrade: () =>
75
+ !isNonProjectCwd(process.cwd()) && !projectHasLocalCodeGraphMcp(process.cwd()),
76
+ spawnReal: () => {
77
+ const { findBinary } = require('./find-binary');
78
+ const bin = findBinary();
79
+ if (!bin) return null;
80
+ process.stderr.write(`[code-graph] cwd became a project — upgrading plugin MCP to real tools via ${bin} (restart Claude Code for full tool steering)\n`);
81
+ return spawn(bin, ['serve'], { stdio: ['pipe', 'pipe', 'inherit'], env: process.env });
82
+ },
83
+ },
84
+ });
105
85
  return;
106
86
  }
107
87
 
@@ -0,0 +1,214 @@
1
+ 'use strict';
2
+ /**
3
+ * Minimal MCP server used by mcp-launcher.js when the plugin should NOT run the
4
+ * real binary — either the project already registers its own code-graph server
5
+ * (dedup) or the cwd is not a project (e.g. /tmp headless calls).
6
+ *
7
+ * Two modes:
8
+ * serveEmptyMcpStub() permanent 0-tool stub (dedup / genuine /tmp)
9
+ * serveEmptyMcpStub({ upgrade }) 0-tool stub that UPGRADES in place
10
+ *
11
+ * The upgrade path closes the "stub latch" gap: the non-project gate is
12
+ * evaluated once at launcher start, so a directory that becomes a project
13
+ * mid-session (bare dir → `git init` + scaffold) would otherwise stay toolless
14
+ * until a full Claude Code restart. With { upgrade } the stub advertises
15
+ * `tools.listChanged:true`, polls `shouldUpgrade()`, and when the cwd becomes a
16
+ * project spawns the real binary, proxies JSON-RPC to it, and emits
17
+ * `notifications/tools/list_changed` so the client re-fetches the (now real)
18
+ * tool list — no restart. Genuinely non-project /tmp callers never satisfy
19
+ * shouldUpgrade(), so they stay cheap (never spawn the binary).
20
+ *
21
+ * Known limitation: the child's ENTIRE `initialize` result is swallowed (the
22
+ * client keeps the stub's) — not just the code-graph `instructions` block but
23
+ * all negotiated server capabilities and the protocolVersion. For a tools-only
24
+ * server that's fine; tools work immediately, but the instructions steering and
25
+ * any non-tool capability only appear after a normal restart. Acceptable: tools
26
+ * are the point, and MCP has no post-init way to re-deliver `instructions`.
27
+ *
28
+ * Deps (input / output / spawn timing / exit) are injectable so the proxy
29
+ * handoff is unit-testable without spawning a real server.
30
+ */
31
+
32
+ const DEFAULT_POLL_MS = 4000;
33
+ // Bound the retry loop: a persistently unresolvable/broken binary would
34
+ // otherwise re-spawn every pollMs for the whole session (~60s at the default).
35
+ const MAX_UPGRADE_FAILURES = 15;
36
+ // Distinct from any id Claude Code uses (it increments from 1) so the child's
37
+ // reply to our replayed initialize is unambiguous to swallow.
38
+ const SENTINEL_ID = 2147483646;
39
+
40
+ function serveEmptyMcpStub(opts = {}) {
41
+ const input = opts.input || process.stdin;
42
+ const output = opts.output || process.stdout;
43
+ const upgrade = opts.upgrade || null;
44
+ const setIv = opts.setInterval || setInterval;
45
+ const clearIv = opts.clearInterval || clearInterval;
46
+ const exit = opts.exit || ((code) => process.exit(code));
47
+ const canUpgrade = !!upgrade;
48
+
49
+ let savedInitialize = null; // client's initialize request, replayed to the child
50
+ let child = null; // real binary, once upgraded
51
+ let childReady = false; // child finished its (replayed) handshake
52
+ const queuedForChild = []; // client lines seen after spawn, before child is ready
53
+ let poller = null;
54
+ let upgradeFailures = 0; // consecutive failed upgrade attempts (see noteUpgradeFailure)
55
+
56
+ function writeCc(obj) { output.write(JSON.stringify(obj) + '\n'); }
57
+
58
+ function stubInitializeResult() {
59
+ if (canUpgrade) {
60
+ // Only tools.listChanged is load-bearing (it lets the client honor our
61
+ // later notifications/tools/list_changed). Deliberately NOT advertising
62
+ // resources/prompts here so a genuine /tmp caller that never upgrades
63
+ // stays as cheap as the permanent stub (no extra resources/prompts probes).
64
+ return {
65
+ protocolVersion: '2024-11-05',
66
+ capabilities: { tools: { listChanged: true } },
67
+ serverInfo: { name: 'code-graph-mcp (plugin stub, upgrading)', version: '0.31.1' },
68
+ };
69
+ }
70
+ return {
71
+ protocolVersion: '2024-11-05',
72
+ capabilities: { tools: { listChanged: false } },
73
+ serverInfo: { name: 'code-graph-mcp (plugin stub, dedup)', version: '0.31.1' },
74
+ };
75
+ }
76
+
77
+ function answerAsStub(req) {
78
+ if (typeof req.id === 'undefined') return; // JSON-RPC notification → no response
79
+ const m = req.method;
80
+ let result, error;
81
+ if (m === 'initialize') result = stubInitializeResult();
82
+ else if (m === 'tools/list') result = { tools: [] };
83
+ else if (m === 'resources/list') result = { resources: [] };
84
+ else if (m === 'prompts/list') result = { prompts: [] };
85
+ else error = {
86
+ code: -32601,
87
+ message: canUpgrade
88
+ ? 'method not found (plugin MCP stub; upgrades when cwd becomes a project)'
89
+ : 'method not found (plugin MCP is in dedup stub mode)',
90
+ };
91
+ writeCc(error ? { jsonrpc: '2.0', id: req.id, error } : { jsonrpc: '2.0', id: req.id, result });
92
+ }
93
+
94
+ // ---- client → stub (or → child once proxying) ----
95
+ let buf = '';
96
+ input.setEncoding('utf8');
97
+ input.on('data', (chunk) => {
98
+ buf += chunk;
99
+ let nl;
100
+ while ((nl = buf.indexOf('\n')) >= 0) {
101
+ const line = buf.slice(0, nl).trim();
102
+ buf = buf.slice(nl + 1);
103
+ if (!line) continue;
104
+ if (child) { // proxy mode
105
+ if (childReady) {
106
+ try { child.stdin.write(line + '\n'); }
107
+ catch { /* child stream gone; 'error'/'exit' handler cleans up */ }
108
+ } else {
109
+ queuedForChild.push(line);
110
+ }
111
+ continue;
112
+ }
113
+ let req;
114
+ try { req = JSON.parse(line); } catch { continue; }
115
+ if (!req || typeof req.method !== 'string') continue;
116
+ if (req.method === 'initialize') savedInitialize = req;
117
+ answerAsStub(req);
118
+ }
119
+ });
120
+ input.on('end', () => {
121
+ if (child) { try { child.stdin.end(); } catch { /* ok */ } }
122
+ else exit(0);
123
+ });
124
+
125
+ // ---- upgrade: poll, then hand the live connection to the real binary ----
126
+ function noteUpgradeFailure(reason) {
127
+ // After the cap, stop polling and surface a one-time actionable hint instead
128
+ // of re-spawning a doomed binary forever.
129
+ if (++upgradeFailures < MAX_UPGRADE_FAILURES) return;
130
+ if (poller) { clearIv(poller); poller = null; }
131
+ process.stderr.write(`[code-graph] plugin MCP could not upgrade after ${upgradeFailures} attempts (${reason}); restart Claude Code once this project is set up. Staying in 0-tool stub.\n`);
132
+ }
133
+
134
+ function attemptUpgrade() {
135
+ if (child || !upgrade) return;
136
+ if (!upgrade.shouldUpgrade()) return; // not a project yet — not a failure
137
+ const spawned = upgrade.spawnReal();
138
+ if (!spawned) { noteUpgradeFailure('binary-unresolved'); return; }
139
+ if (poller) { clearIv(poller); poller = null; }
140
+ child = spawned;
141
+ beginProxy();
142
+ }
143
+
144
+ function fallBackToStub(reason) {
145
+ // Child spawned but died/errored before it was ready: answer anything the
146
+ // client queued (so it doesn't hang on those ids), resume polling, and count
147
+ // the failure toward the retry cap.
148
+ process.stderr.write(`[code-graph] plugin MCP upgrade aborted (${reason}); staying in 0-tool stub, will retry\n`);
149
+ const requeued = queuedForChild.splice(0);
150
+ child = null;
151
+ childReady = false;
152
+ for (const line of requeued) {
153
+ try { const req = JSON.parse(line); if (req && typeof req.method === 'string') answerAsStub(req); }
154
+ catch { /* ignore */ }
155
+ }
156
+ if (upgrade && !poller) poller = setIv(attemptUpgrade, upgrade.pollMs || DEFAULT_POLL_MS);
157
+ noteUpgradeFailure(reason);
158
+ }
159
+
160
+ function beginProxy() {
161
+ const thisChild = child; // guard against terminal events from a superseded spawn
162
+ thisChild.stdin.on('error', () => { /* EPIPE if the child died — 'error'/'exit' handle it */ });
163
+ thisChild.stdout.on('error', () => {});
164
+
165
+ const params = (savedInitialize && savedInitialize.params) || {
166
+ protocolVersion: '2024-11-05', capabilities: {},
167
+ clientInfo: { name: 'code-graph-plugin-launcher', version: '0' },
168
+ };
169
+ // Replay initialize under a sentinel id; the child's reply is swallowed
170
+ // (the client already received OUR initialize result).
171
+ thisChild.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: SENTINEL_ID, method: 'initialize', params }) + '\n');
172
+
173
+ let cbuf = '';
174
+ thisChild.stdout.setEncoding('utf8');
175
+ thisChild.stdout.on('data', (chunk) => {
176
+ cbuf += chunk;
177
+ let nl;
178
+ while ((nl = cbuf.indexOf('\n')) >= 0) {
179
+ const line = cbuf.slice(0, nl);
180
+ cbuf = cbuf.slice(nl + 1);
181
+ if (!line.trim()) continue;
182
+ let msg = null;
183
+ try { msg = JSON.parse(line); } catch { /* forward non-JSON verbatim */ }
184
+ if (msg && msg.id === SENTINEL_ID) {
185
+ // Child handshake complete: finish MCP init, flush queued client
186
+ // requests, and tell the client its tool list changed.
187
+ thisChild.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
188
+ childReady = true;
189
+ for (const l of queuedForChild.splice(0)) thisChild.stdin.write(l + '\n');
190
+ writeCc({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
191
+ continue; // swallow the sentinel reply
192
+ }
193
+ output.write(line + '\n'); // forward child → client verbatim
194
+ }
195
+ });
196
+ thisChild.on('error', () => {
197
+ if (child !== thisChild) return; // superseded spawn — ignore
198
+ if (childReady) { exit(1); return; } // a ready child broke → die like the binary died (avoid a flapping stub)
199
+ fallBackToStub('spawn-error');
200
+ });
201
+ thisChild.on('exit', (code, signal) => {
202
+ if (child !== thisChild) return;
203
+ if (!childReady) { fallBackToStub('early-exit'); return; }
204
+ if (signal) process.kill(process.pid, signal);
205
+ else exit(code == null ? 1 : code);
206
+ });
207
+ }
208
+
209
+ if (upgrade) poller = setIv(attemptUpgrade, upgrade.pollMs || DEFAULT_POLL_MS);
210
+
211
+ return { attemptUpgrade, _state: () => ({ hasChild: !!child, childReady }) };
212
+ }
213
+
214
+ module.exports = { serveEmptyMcpStub, SENTINEL_ID, DEFAULT_POLL_MS, MAX_UPGRADE_FAILURES };
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const { PassThrough } = require('stream');
5
+ const { EventEmitter } = require('events');
6
+ const { serveEmptyMcpStub, SENTINEL_ID } = require('./mcp-stub');
7
+
8
+ const tick = () => new Promise((r) => setImmediate(r));
9
+
10
+ // A fake child_process: collect what the launcher writes to child.stdin, and
11
+ // let the test push child.stdout lines + emit exit/error.
12
+ function makeFakeChild() {
13
+ const stdin = new PassThrough();
14
+ const stdout = new PassThrough();
15
+ const ee = new EventEmitter();
16
+ const linesToChild = [];
17
+ let sbuf = '';
18
+ stdin.setEncoding('utf8');
19
+ stdin.on('data', (c) => {
20
+ sbuf += c;
21
+ let nl;
22
+ while ((nl = sbuf.indexOf('\n')) >= 0) { linesToChild.push(sbuf.slice(0, nl)); sbuf = sbuf.slice(nl + 1); }
23
+ });
24
+ return {
25
+ stdin, stdout,
26
+ on: (e, cb) => ee.on(e, cb),
27
+ emit: (e, ...a) => ee.emit(e, ...a),
28
+ linesToChild,
29
+ };
30
+ }
31
+
32
+ function makeRig(upgrade) {
33
+ const input = new PassThrough();
34
+ const out = [];
35
+ const output = { write: (s) => { out.push(s); return true; } };
36
+ let exitCode = null;
37
+ const handle = serveEmptyMcpStub({
38
+ input, output,
39
+ setInterval: () => 0, clearInterval: () => {}, // no real timer; drive attemptUpgrade() manually
40
+ exit: (c) => { exitCode = c; },
41
+ upgrade,
42
+ });
43
+ const send = (obj) => input.write(JSON.stringify(obj) + '\n');
44
+ return { input, out, handle, send, getExit: () => exitCode };
45
+ }
46
+
47
+ const INIT = { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'cc', version: '1' } } };
48
+
49
+ test('permanent stub: 0 tools, listChanged:false, unknown method → -32601', async () => {
50
+ const { out, send } = makeRig(null);
51
+ send(INIT);
52
+ send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
53
+ send({ jsonrpc: '2.0', id: 3, method: 'nope/nope', params: {} });
54
+ await tick();
55
+ const initResp = JSON.parse(out[0]);
56
+ assert.equal(initResp.result.capabilities.tools.listChanged, false);
57
+ assert.match(initResp.result.serverInfo.name, /dedup/);
58
+ assert.deepEqual(JSON.parse(out[1]).result.tools, []);
59
+ assert.equal(JSON.parse(out[2]).error.code, -32601);
60
+ });
61
+
62
+ test('upgradeable stub advertises tools.listChanged:true', async () => {
63
+ const { out, send } = makeRig({ shouldUpgrade: () => false, spawnReal: () => null });
64
+ send(INIT);
65
+ await tick();
66
+ assert.equal(JSON.parse(out[0]).result.capabilities.tools.listChanged, true);
67
+ });
68
+
69
+ test('upgradeable stub does NOT spawn while shouldUpgrade() is false', async () => {
70
+ let spawnCalls = 0;
71
+ const { handle } = makeRig({ shouldUpgrade: () => false, spawnReal: () => { spawnCalls++; return null; } });
72
+ handle.attemptUpgrade();
73
+ await tick();
74
+ assert.equal(spawnCalls, 0);
75
+ assert.equal(handle._state().hasChild, false);
76
+ });
77
+
78
+ test('upgrade handoff: proxies to real binary + emits tools/list_changed + forwards real tools', async () => {
79
+ const child = makeFakeChild();
80
+ let allow = false;
81
+ const { out, send, handle } = makeRig({ shouldUpgrade: () => allow, spawnReal: () => child });
82
+
83
+ // 1. client initialize + tools/list while still a non-project stub
84
+ send(INIT);
85
+ send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
86
+ await tick();
87
+ assert.equal(JSON.parse(out[0]).result.capabilities.tools.listChanged, true);
88
+ assert.deepEqual(JSON.parse(out[1]).result.tools, []); // stub → empty
89
+ const outLenBeforeUpgrade = out.length;
90
+
91
+ // 2. cwd becomes a project → upgrade fires
92
+ allow = true;
93
+ handle.attemptUpgrade();
94
+ await tick();
95
+ assert.equal(handle._state().hasChild, true);
96
+ // launcher replayed initialize to the child under the sentinel id
97
+ const replay = JSON.parse(child.linesToChild[0]);
98
+ assert.equal(replay.method, 'initialize');
99
+ assert.equal(replay.id, SENTINEL_ID);
100
+
101
+ // 3. child answers the sentinel initialize
102
+ child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: SENTINEL_ID, result: { capabilities: { tools: { listChanged: false } }, instructions: 'real server' } }) + '\n');
103
+ await tick();
104
+ // launcher must: send initialized to child, and tools/list_changed to client
105
+ assert.ok(child.linesToChild.some((l) => JSON.parse(l).method === 'notifications/initialized'));
106
+ const newClientMsgs = out.slice(outLenBeforeUpgrade).map((s) => JSON.parse(s));
107
+ assert.ok(newClientMsgs.some((m) => m.method === 'notifications/tools/list_changed'),
108
+ 'client must be told tools changed');
109
+ // the sentinel init reply must NOT leak to the client
110
+ assert.ok(!newClientMsgs.some((m) => m.id === SENTINEL_ID), 'sentinel reply must be swallowed');
111
+
112
+ // 4. client re-requests tools/list → forwarded to child (not answered empty)
113
+ const childLinesBefore = child.linesToChild.length;
114
+ send({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} });
115
+ await tick();
116
+ const forwarded = child.linesToChild.slice(childLinesBefore).map((l) => JSON.parse(l));
117
+ assert.ok(forwarded.some((m) => m.id === 3 && m.method === 'tools/list'), 'tools/list forwarded to real binary');
118
+
119
+ // 5. child returns real tools → forwarded verbatim to client
120
+ child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: 3, result: { tools: [{ name: 'get_call_graph' }, { name: 'semantic_code_search' }] } }) + '\n');
121
+ await tick();
122
+ const toolResp = out.map((s) => JSON.parse(s)).find((m) => m.id === 3);
123
+ assert.ok(toolResp, 'client received a tools/list response');
124
+ assert.deepEqual(toolResp.result.tools.map((t) => t.name), ['get_call_graph', 'semantic_code_search']);
125
+ });
126
+
127
+ test('upgrade with unresolvable binary stays a stub and keeps answering', async () => {
128
+ let allow = true;
129
+ const { out, send, handle } = makeRig({ shouldUpgrade: () => allow, spawnReal: () => null });
130
+ send(INIT);
131
+ await tick();
132
+ handle.attemptUpgrade(); // spawnReal returns null → no child
133
+ await tick();
134
+ assert.equal(handle._state().hasChild, false);
135
+ send({ jsonrpc: '2.0', id: 9, method: 'tools/list', params: {} });
136
+ await tick();
137
+ const resp = out.map((s) => JSON.parse(s)).find((m) => m.id === 9);
138
+ assert.deepEqual(resp.result.tools, []); // still served by the stub
139
+ });
140
+
141
+ test('upgrade: a request in the handoff window is queued then flushed to the child in order', async () => {
142
+ const child = makeFakeChild();
143
+ const { send, handle } = makeRig({ shouldUpgrade: () => true, spawnReal: () => child });
144
+ send(INIT);
145
+ await tick();
146
+ handle.attemptUpgrade(); // child spawned; sentinel initialize sent; NOT ready yet
147
+ await tick();
148
+ // client sends a call while the child is mid-handshake → must be queued, not forwarded
149
+ send({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'x' } });
150
+ await tick();
151
+ assert.equal(child.linesToChild.length, 1);
152
+ assert.equal(JSON.parse(child.linesToChild[0]).id, SENTINEL_ID);
153
+ // child completes its handshake → queued request flushes AFTER initialized
154
+ child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: SENTINEL_ID, result: { capabilities: {} } }) + '\n');
155
+ await tick();
156
+ const methods = child.linesToChild.map((l) => { const m = JSON.parse(l); return m.method || `id:${m.id}`; });
157
+ assert.deepEqual(methods, ['initialize', 'notifications/initialized', 'tools/call']);
158
+ });
159
+
160
+ test('upgrade: child that exits before ready falls back to stub and answers queued requests', async () => {
161
+ const child = makeFakeChild();
162
+ const { out, send, handle } = makeRig({ shouldUpgrade: () => true, spawnReal: () => child });
163
+ send(INIT);
164
+ await tick();
165
+ handle.attemptUpgrade(); // child spawned, not ready
166
+ await tick();
167
+ send({ jsonrpc: '2.0', id: 8, method: 'tools/list', params: {} }); // queued during handoff
168
+ await tick();
169
+ child.emit('exit', 1, null); // child dies BEFORE the sentinel reply
170
+ await tick();
171
+ assert.equal(handle._state().hasChild, false); // reverted to stub
172
+ const resp = out.map((s) => JSON.parse(s)).find((m) => m.id === 8);
173
+ assert.ok(resp, 'queued request got a stub answer (client not left hanging)');
174
+ assert.deepEqual(resp.result.tools, []);
175
+ });
176
+
177
+ test('upgrade: error on a READY child exits instead of reverting to a flapping stub', async () => {
178
+ const child = makeFakeChild();
179
+ const { send, handle, getExit } = makeRig({ shouldUpgrade: () => true, spawnReal: () => child });
180
+ send(INIT);
181
+ await tick();
182
+ handle.attemptUpgrade();
183
+ await tick();
184
+ child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: SENTINEL_ID, result: { capabilities: {} } }) + '\n');
185
+ await tick();
186
+ assert.equal(handle._state().childReady, true);
187
+ child.emit('error', new Error('boom')); // a READY child errors
188
+ await tick();
189
+ assert.equal(handle._state().hasChild, true, 'must NOT revert to stub (that would flap)');
190
+ assert.equal(getExit(), 1, 'ready-child error exits the launcher like the binary died');
191
+ });
192
+
193
+ test('poller is wired to attemptUpgrade at the default interval', () => {
194
+ const input = new PassThrough();
195
+ const out = [];
196
+ let captured = null, spawnCalls = 0;
197
+ serveEmptyMcpStub({
198
+ input, output: { write: (s) => out.push(s) },
199
+ setInterval: (fn, ms) => { captured = { fn, ms }; return 1; },
200
+ clearInterval: () => {}, exit: () => {},
201
+ upgrade: { shouldUpgrade: () => true, spawnReal: () => { spawnCalls++; return null; } },
202
+ });
203
+ assert.ok(captured, 'poller started via setInterval');
204
+ assert.equal(captured.ms, 4000); // DEFAULT_POLL_MS
205
+ captured.fn(); // simulate one poll tick
206
+ assert.equal(spawnCalls, 1); // tick attempted the upgrade
207
+ });
@@ -62,6 +62,115 @@ function findFoldableGrepSegment(cmd) {
62
62
  return null;
63
63
  }
64
64
 
65
+ // Callgraph is the marginal-value inject (cross-file caller tree the grep can't
66
+ // return); the grep/show echo modes measured redundant (2026-06-26 audit: 0
67
+ // CONSUMED). Prior gate required the WHOLE grep pattern to be one identifier, so
68
+ // an alternation / multi-symbol grep (`markSuperseded|created_at`, `foo|bar_baz`)
69
+ // fell to the echo. These bounds widen it: extract the identifier tokens and try
70
+ // callgraph on each until one has real edges. Cheap — callgraph is ~30ms/call.
71
+ const MAX_CALLGRAPH_SYMBOLS = 3;
72
+ const MIN_MULTI_SYMBOL_LEN = 3;
73
+
74
+ /**
75
+ * Identifier tokens from a grep pattern, as callgraph candidates. A lone
76
+ * identifier returns [itself] (any length — exact prior behavior). A multi-token
77
+ * / regex pattern (alternation, word-boundaries, char classes) is stripped of
78
+ * backslash escapes FIRST — so `\bdate` yields `date`, not `bdate` (the letter
79
+ * after `\b`/`\d`/`\w` is a regex metachar, not part of the symbol) — then its
80
+ * identifier tokens are collected: <3-char noise dropped, deduped, capped. Order
81
+ * preserved so the FIRST alternand (usually the primary symbol) is tried first.
82
+ * runCallgraphAnswer self-filters non-symbols (returns `hits` only with real
83
+ * edges), so a junk token just costs one ~30ms no-hits call.
84
+ * @param {string} rawPattern
85
+ * @returns {string[]}
86
+ */
87
+ function extractCallgraphSymbols(rawPattern) {
88
+ if (typeof rawPattern !== 'string' || !rawPattern) return [];
89
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(rawPattern)) return [rawPattern];
90
+ const cleaned = rawPattern.replace(/\\[A-Za-z]/g, ' ');
91
+ const seen = new Set();
92
+ const out = [];
93
+ for (const m of cleaned.matchAll(/[A-Za-z_][A-Za-z0-9_]*/g)) {
94
+ const tok = m[0];
95
+ if (tok.length < MIN_MULTI_SYMBOL_LEN) continue;
96
+ if (seen.has(tok)) continue;
97
+ seen.add(tok);
98
+ out.push(tok);
99
+ if (out.length >= MAX_CALLGRAPH_SYMBOLS) break;
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * The command's actual stdout, from the PostToolUse payload. VERIFIED against the
106
+ * Claude Code runtime (v2.1.198 binary): the hook input carries `tool_response`,
107
+ * and the Bash result is the OBJECT `{stdout, stderr, interrupted, ...}` — so
108
+ * `tool_response.stdout` is the real, load-bearing path. (The published hooks doc
109
+ * says a top-level `tool_output` string, which the runtime does NOT emit — checked
110
+ * first only for forward-compat if a future version adopts the documented name.)
111
+ * `tool_response` as a bare string / `.output` are extra defensive fallbacks. null
112
+ * when no output field is present → the gate can't confirm redundancy → it injects
113
+ * (pre-gate behavior, no regression on any unhandled shape).
114
+ * @returns {string|null}
115
+ */
116
+ function extractGrepOutput(input) {
117
+ if (!input || typeof input !== 'object') return null;
118
+ if (typeof input.tool_output === 'string') return input.tool_output; // doc-stated, forward-compat
119
+ const tr = input.tool_response;
120
+ if (typeof tr === 'string') return tr;
121
+ if (tr && typeof tr === 'object') {
122
+ if (typeof tr.stdout === 'string') return tr.stdout; // ← real runtime shape (Bash result obj)
123
+ if (typeof tr.output === 'string') return tr.output;
124
+ }
125
+ return null;
126
+ }
127
+
128
+ // A stdout line that looks like a grep HIT, as opposed to a sibling `echo`/prose
129
+ // line. grep prints one of: `path:content` (-H), `path:line:content` (-rn),
130
+ // `line:content` (-n on a single named file — NO path prefix), or a bare `path`
131
+ // (-l). Recognizing all four is what lets the gate skip a real hit in ANY of these
132
+ // formats (the compound greps the model actually runs use all of them) while still
133
+ // NOT counting `echo "find Sym" && grep Sym wrongpath/` (grep MISSED → only the echo
134
+ // prose lands, which matches none of these shapes), so the additive grep-empty inject
135
+ // stays reachable and measurable post-ship.
136
+ const GREP_HIT_LINE = /(?:^|\s)[^\s:]*[/.][^\s:]*:|^\s*\d+:/; // path:… OR linenum: (single-file -n)
137
+ const BARE_PATH_LINE = /^\S*[/.]\S+$/; // grep -l: a lone path, no spaces
138
+ // GREP_HIT_LINE has two `[^\s:]*` stars before a required `:`, so a long line that
139
+ // carries `/` or `.` but NO colon backtracks O(n²) (~33s on a 400KB line). grep's
140
+ // hit marker (`path:` / `NN:`) is always at the START of the line, so testing only a
141
+ // bounded prefix is faithful AND caps the scan — untrusted grep stdout on a blocking
142
+ // hook must not stall.
143
+ const HIT_SHAPE_SCAN_MAX = 256;
144
+
145
+ /**
146
+ * Did the command's own output already surface the grepped symbol? The inject is
147
+ * redundant exactly then — the model has the hits in front of it (2026-07-03 audit:
148
+ * 18/18 injects 0 CONSUMED, all on greps that already hit). Two guards keep this from
149
+ * over-suppressing the additive (grep-missed) case: (1) only GREP-HIT-SHAPED lines
150
+ * count — a sibling `echo "…Sym…"` prose line does NOT (fixes the common
151
+ * `echo <Symbol> && grep <Symbol>` shape); (2) the identifier is matched as a WHOLE
152
+ * WORD, so a `date` alternand isn't swallowed by `update`/`validate`. When unsure it
153
+ * returns false → the caller injects (safe side: tax, never a wrong/missing answer).
154
+ * Uses the same identifier tokenization as the callgraph path.
155
+ * @returns {boolean} true only when a grep hit for the symbol is CONFIRMED in output.
156
+ */
157
+ function grepFoundPattern(output, rawPattern) {
158
+ if (typeof output !== 'string' || !output) return false;
159
+ const ids = extractCallgraphSymbols(rawPattern);
160
+ if (ids.length === 0) return false;
161
+ // ids are pure `[A-Za-z_]\w*` tokens (no regex metachars) → safe to embed in \b…\b.
162
+ const wordRes = ids.map((id) => new RegExp(`\\b${id}\\b`));
163
+ return output.split('\n').some((line) => {
164
+ // Decide hit-shape from a bounded prefix (ReDoS guard — see HIT_SHAPE_SCAN_MAX).
165
+ // A `-l` bare path is short, so a line longer than the cap is never one.
166
+ const head = line.length > HIT_SHAPE_SCAN_MAX ? line.slice(0, HIT_SHAPE_SCAN_MAX) : line;
167
+ const hitShaped = GREP_HIT_LINE.test(head)
168
+ || (line.length <= HIT_SHAPE_SCAN_MAX && BARE_PATH_LINE.test(line));
169
+ if (!hitShaped) return false;
170
+ return wordRes.some((re) => re.test(line)); // \b…\b is linear — safe on the full line
171
+ });
172
+ }
173
+
65
174
  // Short header so the model recognizes this as cg's parallel structural view of
66
175
  // the grep it just ran (the grep already executed; this is additive context).
67
176
  const INJECT_HEADER = '[code-graph] AST-aware view of your grep (ran alongside):';
@@ -156,23 +265,31 @@ function runMain() {
156
265
  const { segment, block } = found;
157
266
  // Run the answer exactly like the deny path.
158
267
  const rawPattern = pickBlockPattern(segment);
268
+ // Grep-response gate (2026-07-03 audit: 18/18 injects were 0 CONSUMED because they
269
+ // re-stated hits the model already had). If the command's OWN output already
270
+ // surfaced the grepped symbol, the inject is redundant → skip it, saving the
271
+ // ~1KB context tax. Only a grep that found NOTHING (or an unreadable output —
272
+ // no regression on older CC) proceeds: then cg's structural answer (the real
273
+ // location / cross-file callers a failed grep never showed) is genuinely additive.
274
+ if (grepFoundPattern(extractGrepOutput(input), rawPattern)) return;
159
275
  const pattern = translateBreToRg(segment, rawPattern);
160
276
  const searchPath = sanitizeSearchPath(extractSearchPath(segment));
161
277
  let answer = { status: 'unavailable' };
162
278
  let answeredMode = block.mode;
163
279
 
164
- // PREFER the cross-file caller/callee tree when the grep targets a single clean
165
- // identifier that is the marginal signal a raw grep can't return (2026-06-26
166
- // inject audit: 13 events / 0 CONSUMED because the grep-echo just re-stated the
167
- // model's own hits). runCallgraphAnswer returns `hits` ONLY when the symbol has
168
- // real edges; a leaf/absent symbol degrades to the show/grep echo below.
169
- const symbol = (typeof rawPattern === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(rawPattern))
170
- ? rawPattern : null;
171
- if (symbol) {
280
+ // PREFER the cross-file caller/callee tree the marginal signal a raw grep can't
281
+ // return (2026-06-26 inject audit: 13 events / 0 CONSUMED because the grep-echo
282
+ // just re-stated the model's own hits). Try every identifier the pattern
283
+ // carries (alternation / multi-symbol grep), not just a lone-identifier pattern,
284
+ // stopping at the first symbol with real edges. runCallgraphAnswer returns `hits`
285
+ // ONLY when the symbol has edges a leaf/absent symbol self-filters to the
286
+ // show/grep echo below.
287
+ for (const symbol of extractCallgraphSymbols(rawPattern)) {
172
288
  const cg = runCallgraphAnswer({ cwd: root, symbol });
173
289
  if (cg.status === 'hits') {
174
290
  answer = cg;
175
291
  answeredMode = 'callgraph';
292
+ break;
176
293
  }
177
294
  }
178
295
 
@@ -209,6 +326,9 @@ if (require.main === module) {
209
326
 
210
327
  module.exports = {
211
328
  findFoldableGrepSegment,
329
+ extractCallgraphSymbols,
330
+ extractGrepOutput,
331
+ grepFoundPattern,
212
332
  buildInjectText,
213
333
  isSilenced,
214
334
  isInjectDisabled,
@@ -9,12 +9,163 @@ const { cgTmpDir } = require('./tmp-dir');
9
9
 
10
10
  const {
11
11
  findFoldableGrepSegment,
12
+ extractCallgraphSymbols,
13
+ extractGrepOutput,
14
+ grepFoundPattern,
12
15
  isSilenced,
13
16
  isInjectDisabled,
14
17
  buildInjectText,
15
18
  commandHash,
16
19
  } = require('./post-grep-inject');
17
20
 
21
+ // ── grep-response gate ──────────────────────────────────────────────
22
+ // 2026-07-03 audit: 18/18 injects were 0 CONSUMED — they re-stated hits the model
23
+ // already had in its OWN grep output. PostToolUse hands the hook the command's
24
+ // actual output (tool_response); skip the inject when the grep already surfaced the
25
+ // symbol (redundant), inject only when it found nothing (cg's structural answer is
26
+ // then genuinely additive: "it's actually here / who calls it").
27
+
28
+ test('extractGrepOutput: reads top-level tool_output string (doc-stated shape; forward-compat)', () => {
29
+ assert.equal(extractGrepOutput({ tool_output: 'src/a.rs:1 hit' }), 'src/a.rs:1 hit');
30
+ });
31
+
32
+ test('extractGrepOutput: reads tool_response.stdout (VERIFIED real CC runtime shape — Bash result obj)', () => {
33
+ // CC v2.1.198 binary: hook input = {tool_response:{stdout,stderr,interrupted,...}}.
34
+ // This is the load-bearing path the gate actually fires on in production.
35
+ assert.equal(extractGrepOutput({ tool_response: { stdout: 'src/a.rs:1 hit' } }), 'src/a.rs:1 hit');
36
+ });
37
+
38
+ test('extractGrepOutput: defensive fallback — tool_response as a bare string', () => {
39
+ assert.equal(extractGrepOutput({ tool_response: 'raw output' }), 'raw output');
40
+ });
41
+
42
+ test('extractGrepOutput: defensive fallback — tool_response.output field', () => {
43
+ assert.equal(extractGrepOutput({ tool_response: { output: 'out text' } }), 'out text');
44
+ });
45
+
46
+ test('extractGrepOutput: absent output → null (unknown, caller injects — no regression)', () => {
47
+ assert.equal(extractGrepOutput({}), null);
48
+ assert.equal(extractGrepOutput({ tool_response: {} }), null);
49
+ assert.equal(extractGrepOutput(null), null);
50
+ });
51
+
52
+ test('grepFoundPattern: output line containing the symbol → true (grep hit)', () => {
53
+ assert.equal(grepFoundPattern('src/foo.rs:7 fn EmbeddingModel()', 'EmbeddingModel'), true);
54
+ });
55
+
56
+ test('grepFoundPattern: no line contains the symbol → false (grep found nothing)', () => {
57
+ // e.g. `echo "===" && grep Sym f` where grep matched nothing — only the echo lands.
58
+ assert.equal(grepFoundPattern('===\n', 'EmbeddingModel'), false);
59
+ });
60
+
61
+ test('grepFoundPattern: alternation — ANY alternand present → true', () => {
62
+ assert.equal(grepFoundPattern('src/x.rs:3 created_at', 'markSuperseded|created_at'), true);
63
+ });
64
+
65
+ test('grepFoundPattern: null / empty output or pattern → false', () => {
66
+ assert.equal(grepFoundPattern(null, 'Sym'), false);
67
+ assert.equal(grepFoundPattern('', 'Sym'), false);
68
+ assert.equal(grepFoundPattern('anything', ''), false);
69
+ assert.equal(grepFoundPattern('anything', null), false);
70
+ });
71
+
72
+ test('grepFoundPattern: sibling echo mentions the symbol but grep MISSED → false (no hit-shaped line)', () => {
73
+ // `echo "search for EmbeddingModel" && grep EmbeddingModel wrongpath/` where grep
74
+ // found nothing → stdout is just the echo prose. Must NOT count as a hit, or the
75
+ // additive grep-empty inject is unreachable for this common shape (review MEDIUM).
76
+ assert.equal(grepFoundPattern('search for EmbeddingModel', 'EmbeddingModel'), false);
77
+ assert.equal(grepFoundPattern('=== callers of EmbeddingModel ===', 'EmbeddingModel'), false);
78
+ });
79
+
80
+ test('grepFoundPattern: identifier matched as a WHOLE WORD, not a substring', () => {
81
+ // `date` must not be swallowed by `update`/`validate` on a real hit line (review LOW#2).
82
+ assert.equal(grepFoundPattern('src/x.rs:3 updated the row and validated it', 'TaskState|date'), false);
83
+ // …but a genuine whole-word hit on a hit-shaped line still counts.
84
+ assert.equal(grepFoundPattern('src/x.rs:3 const date = now()', 'TaskState|date'), true);
85
+ });
86
+
87
+ test('grepFoundPattern: bare path line (grep -l output) with the symbol → true', () => {
88
+ assert.equal(grepFoundPattern('src/getVocabulary.rs', 'getVocabulary'), true);
89
+ });
90
+
91
+ test('grepFoundPattern: single-file `grep -n` linenum:content hit (no path prefix) → true', () => {
92
+ // Real shape from a compound `grep -n Sym onefile.mjs` — the hit line is
93
+ // `2:import { parseGitHubUrl }` with NO path token. Must still count as a hit.
94
+ assert.equal(grepFoundPattern('2:import { parseGitHubUrl } from "../x.mjs";', 'parseGitHubUrl'), true);
95
+ });
96
+
97
+ test('grepFoundPattern: long colon-free line does NOT ReDoS (bounded prefix scan)', () => {
98
+ // GREP_HIT_LINE's two `[^\s:]*` stars backtrack O(n²) on a long line carrying `/`.`
99
+ // but no colon (~33s on 400KB pre-fix). The prefix cap must keep it O(1)/line.
100
+ const huge = '/x.'.repeat(200000) + ' EmbeddingModel'; // ~600KB, has /. but no colon
101
+ const t0 = process.hrtime.bigint();
102
+ const r = grepFoundPattern(huge, 'EmbeddingModel');
103
+ const ms = Number(process.hrtime.bigint() - t0) / 1e6;
104
+ assert.ok(ms < 200, `grepFoundPattern took ${ms.toFixed(0)}ms on a 600KB line — ReDoS regressed`);
105
+ // Not a grep-hit-shaped line (no colon in the prefix, too long for a bare path) → false.
106
+ assert.equal(r, false);
107
+ });
108
+
109
+ test('grepFoundPattern: symbol present only in prose (no path token on the line) → false', () => {
110
+ // Defends the hit-line requirement: a plain content line without a path:col prefix
111
+ // (e.g. a `grep` on a single unnamed file, or non-grep sibling output) → inject
112
+ // (safe over-inject) rather than a false-skip.
113
+ assert.equal(grepFoundPattern('the EmbeddingModel struct is here', 'EmbeddingModel'), false);
114
+ });
115
+
116
+ // ── extractCallgraphSymbols ─────────────────────────────────────────
117
+ // Widen callgraph eligibility: an alternation / multi-symbol grep pattern
118
+ // used to fall to the redundant grep-echo because the WHOLE pattern wasn't a lone
119
+ // identifier. Extract the identifier tokens (callgraph self-filters non-symbols).
120
+
121
+ test('extractCallgraphSymbols: a lone identifier → [itself] (prior behavior)', () => {
122
+ assert.deepEqual(extractCallgraphSymbols('markSuperseded'), ['markSuperseded']);
123
+ });
124
+
125
+ test('extractCallgraphSymbols: a lone SHORT identifier is preserved (no length filter on the fast path)', () => {
126
+ // The <3-char length filter applies ONLY to multi-token extraction; a grep for
127
+ // a lone 2-char symbol must still get its callgraph, exactly as before.
128
+ assert.deepEqual(extractCallgraphSymbols('ok'), ['ok']);
129
+ });
130
+
131
+ test('extractCallgraphSymbols: alternation → each identifier in order', () => {
132
+ assert.deepEqual(
133
+ extractCallgraphSymbols('markSuperseded|created_at'),
134
+ ['markSuperseded', 'created_at']);
135
+ });
136
+
137
+ test('extractCallgraphSymbols: strips regex escapes so `\\bdate` yields `date`, not `bdate`', () => {
138
+ // The letter after \b/\d/\w is a regex metachar, not part of the symbol.
139
+ assert.deepEqual(
140
+ extractCallgraphSymbols('markSuperseded|\\bdate:|created_at'),
141
+ ['markSuperseded', 'date', 'created_at']);
142
+ });
143
+
144
+ test('extractCallgraphSymbols: drops <3-char noise tokens in multi mode', () => {
145
+ // `a|bb|ccc` → only `ccc` survives (a=1, bb=2 filtered).
146
+ assert.deepEqual(extractCallgraphSymbols('a|bb|ccc'), ['ccc']);
147
+ });
148
+
149
+ test('extractCallgraphSymbols: dedups repeated tokens, order-preserving', () => {
150
+ assert.deepEqual(extractCallgraphSymbols('foo|bar|foo'), ['foo', 'bar']);
151
+ });
152
+
153
+ test('extractCallgraphSymbols: caps attempts at 3', () => {
154
+ assert.deepEqual(
155
+ extractCallgraphSymbols('aaa|bbb|ccc|ddd|eee'),
156
+ ['aaa', 'bbb', 'ccc']);
157
+ });
158
+
159
+ test('extractCallgraphSymbols: non-string / empty → []', () => {
160
+ assert.deepEqual(extractCallgraphSymbols(null), []);
161
+ assert.deepEqual(extractCallgraphSymbols(''), []);
162
+ assert.deepEqual(extractCallgraphSymbols(undefined), []);
163
+ });
164
+
165
+ test('extractCallgraphSymbols: pattern with no identifier token → []', () => {
166
+ assert.deepEqual(extractCallgraphSymbols('\\d+\\.\\d+'), []);
167
+ });
168
+
18
169
  // ── Pure logic: findFoldableGrepSegment ─────────────────────────────
19
170
  // Reuses splitTopLevelSegments + classifyBlock from pre-grep-guide. The FIRST
20
171
  // segment whose head is grep AND whose classifyBlock is non-null is the foldable
@@ -135,10 +286,15 @@ function e2eFixture(stubBody) {
135
286
  return { dir, stub };
136
287
  }
137
288
 
138
- function runHook(cmd, fixture, extraEnv = {}, cwdOverride) {
289
+ function runHook(cmd, fixture, extraEnv = {}, cwdOverride, toolOutput) {
290
+ const payload = { tool_input: { command: cmd } };
291
+ // Drive the REAL CC runtime shape (verified against the v2.1.198 binary): the Bash
292
+ // result reaches the hook as `tool_response.stdout`. Absent → unknown → the gate
293
+ // injects (pre-gate behavior; no regression).
294
+ if (toolOutput !== undefined) payload.tool_response = { stdout: toolOutput };
139
295
  return spawnSync(process.execPath, [path.join(__dirname, 'post-grep-inject.js')], {
140
296
  cwd: cwdOverride || fixture.dir,
141
- input: JSON.stringify({ tool_input: { command: cmd } }),
297
+ input: JSON.stringify(payload),
142
298
  encoding: 'utf8',
143
299
  env: {
144
300
  ...process.env,
@@ -251,6 +407,108 @@ test('e2e: per-command cooldown — verbatim re-run within window injects only o
251
407
  }
252
408
  });
253
409
 
410
+ test('e2e: alternation grep `Alpha|Beta` → callgraph mode when a symbol has edges', () => {
411
+ // The whole pattern is not a lone identifier, but the FIRST alternand resolves
412
+ // to a symbol with cross-file edges → callgraph payload, not the grep echo.
413
+ const uniq = `AltCg${Date.now()}`;
414
+ const fixture = e2eFixture(
415
+ // stub: argv = [node, stub, subcmd, sym/pattern, ...]. callgraph → edge-bearing
416
+ // tree; anything else (grep) → a plain hit line.
417
+ `const sub = process.argv[2], arg = process.argv[3];\n` +
418
+ `if (sub === 'callgraph') { process.stdout.write(arg + '\\n \\u2190 called by: someCaller (src/x.rs:3)\\n'); process.exit(0); }\n` +
419
+ `process.stdout.write('src/foo.rs:7 fn ' + arg + '()\\n');`);
420
+ const cmd = `echo "x" && grep "${uniq}|OtherSym" src/`;
421
+ try {
422
+ const res = runHook(cmd, fixture);
423
+ assert.equal(res.status, 0);
424
+ const out = JSON.parse(res.stdout);
425
+ assert.match(out.hookSpecificOutput.additionalContext, /Cross-file call graph/,
426
+ 'a resolving alternand must produce the callgraph payload, not the grep echo');
427
+ assert.match(out.hookSpecificOutput.additionalContext, /called by: someCaller/);
428
+ const recs = fs.readFileSync(
429
+ path.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
430
+ const rec = JSON.parse(recs.trim().split('\n').pop());
431
+ assert.equal(rec.action, 'inject');
432
+ assert.equal(rec.mode, 'callgraph', 'inject rec must record mode:callgraph');
433
+ } finally {
434
+ cleanupFixture(fixture, cmd);
435
+ }
436
+ });
437
+
438
+ test('e2e: alternation grep, no symbol has edges → falls back to grep echo (grep mode)', () => {
439
+ // callgraph returns exit 1 (no node) for every alternand → the grep-echo path
440
+ // still delivers, mode:grep. Guards that widening never LOSES the echo fallback.
441
+ const uniq = `AltEcho${Date.now()}`;
442
+ const fixture = e2eFixture(
443
+ `const sub = process.argv[2], arg = process.argv[3];\n` +
444
+ `if (sub === 'callgraph') { process.exit(1); }\n` +
445
+ `process.stdout.write('src/foo.rs:7 fn matched()\\n');`);
446
+ const cmd = `echo "x" && grep "${uniq}|OtherSym" src/`;
447
+ try {
448
+ const res = runHook(cmd, fixture);
449
+ assert.equal(res.status, 0);
450
+ const out = JSON.parse(res.stdout);
451
+ assert.match(out.hookSpecificOutput.additionalContext, /AST-aware view of your grep/);
452
+ const recs = fs.readFileSync(
453
+ path.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
454
+ const rec = JSON.parse(recs.trim().split('\n').pop());
455
+ assert.equal(rec.mode, 'grep');
456
+ } finally {
457
+ cleanupFixture(fixture, cmd);
458
+ }
459
+ });
460
+
461
+ test('e2e: grep-response gate — grep ALREADY showed the symbol → skip inject (redundant)', () => {
462
+ // The model's own grep output contains the symbol → inject would re-state hits it
463
+ // already has (the 18/18-CONSUMED=0 case). Even though the stub WOULD answer, the
464
+ // gate suppresses the redundant inject.
465
+ const uniq = `GateHit${Date.now()}`;
466
+ const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
467
+ const cmd = `echo "x" && grep "${uniq}" src/`;
468
+ const grepOutput = `src/real.rs:42 fn ${uniq}() { // the model's own grep already found it`;
469
+ try {
470
+ const res = runHook(cmd, fixture, {}, undefined, grepOutput);
471
+ assert.equal(res.status, 0);
472
+ assert.equal(res.stdout.trim(), '', 'a grep that already surfaced the symbol must NOT trigger a redundant inject');
473
+ } finally {
474
+ cleanupFixture(fixture, cmd);
475
+ }
476
+ });
477
+
478
+ test('e2e: grep-response gate — grep found NOTHING → inject (cg answer is additive)', () => {
479
+ // The grep produced no hit for the symbol (dialect/scope miss) → cg's structural
480
+ // answer is genuinely new info → inject fires.
481
+ const uniq = `GateMiss${Date.now()}`;
482
+ const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
483
+ const cmd = `echo "===" && grep "${uniq}" src/`;
484
+ const grepOutput = `===\n`; // only the echo landed; grep matched nothing
485
+ try {
486
+ const res = runHook(cmd, fixture, {}, undefined, grepOutput);
487
+ assert.equal(res.status, 0);
488
+ const out = JSON.parse(res.stdout);
489
+ assert.match(out.hookSpecificOutput.additionalContext, new RegExp(uniq),
490
+ 'a grep that found nothing must still get the additive cg answer');
491
+ } finally {
492
+ cleanupFixture(fixture, cmd);
493
+ }
494
+ });
495
+
496
+ test('e2e: grep-response gate — absent output field → inject (no regression on unknown)', () => {
497
+ // No tool_response (older CC, or unreadable) → the gate can't confirm redundancy →
498
+ // it injects, exactly as before the gate existed.
499
+ const uniq = `GateUnknown${Date.now()}`;
500
+ const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
501
+ const cmd = `echo "x" && grep "${uniq}" src/`;
502
+ try {
503
+ const res = runHook(cmd, fixture); // no toolOutput arg
504
+ assert.equal(res.status, 0);
505
+ const out = JSON.parse(res.stdout);
506
+ assert.match(out.hookSpecificOutput.additionalContext, new RegExp(uniq));
507
+ } finally {
508
+ cleanupFixture(fixture, cmd);
509
+ }
510
+ });
511
+
254
512
  test('e2e: no index up to $HOME → silent exit 0', () => {
255
513
  // A cwd with no .code-graph anywhere up the tree resolves to null root → exit.
256
514
  const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'post-grep-noidx-'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.83.0",
3
+ "version": "0.84.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.83.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.83.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.83.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.83.0",
42
- "@sdsrs/code-graph-win32-x64": "0.83.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.84.1",
39
+ "@sdsrs/code-graph-linux-arm64": "0.84.1",
40
+ "@sdsrs/code-graph-darwin-x64": "0.84.1",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.84.1",
42
+ "@sdsrs/code-graph-win32-x64": "0.84.1"
43
43
  }
44
44
  }