@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.
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/mcp-launcher.js +23 -43
- package/claude-plugin/scripts/mcp-stub.js +214 -0
- package/claude-plugin/scripts/mcp-stub.test.js +207 -0
- package/claude-plugin/scripts/post-grep-inject.js +128 -8
- package/claude-plugin/scripts/post-grep-inject.test.js +260 -2
- package/package.json +6 -6
|
@@ -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
|
-
|
|
42
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.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
|
}
|