@sdsrs/code-graph 0.74.7 → 0.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.74.7",
7
+ "version": "0.75.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -243,6 +243,67 @@ function runOverviewAnswer(opts = {}) {
243
243
  }
244
244
  }
245
245
 
246
+ /**
247
+ * v0.75 — Run `code-graph-mcp callgraph <symbol>` for the cross-file caller/callee
248
+ * tree. This is the ONE thing a raw grep CANNOT return: a symbol-targeted grep
249
+ * hands the model the definition + same-file usages it already scoped to, but NOT
250
+ * "who calls this across the repo". The 2026-06-26 inject audit (13 events, 0
251
+ * CONSUMED) found the grep-echo payload redundant precisely because it re-stated
252
+ * the model's own hits; the caller tree is the marginal signal grep can't give.
253
+ *
254
+ * "hits" requires an actual EDGE line (`← called by` / `→ calls`) — a bare symbol
255
+ * header with no edges (leaf symbol, or name not in the graph) carries no marginal
256
+ * value over the grep the model already ran, so it degrades to `no-hits` and the
257
+ * caller falls back to the grep/show echo. Same bounded/best-effort posture as the
258
+ * sibling runners; any failure → `unavailable` / `no-binary`, never a new failure.
259
+ * @returns {{status: 'hits', text: string, truncated: boolean}
260
+ * | {status: 'no-hits'}
261
+ * | {status: 'no-binary'}
262
+ * | {status: 'unavailable'}}
263
+ */
264
+ function runCallgraphAnswer(opts = {}) {
265
+ const {
266
+ cwd,
267
+ symbol,
268
+ timeoutMs = DEFAULT_TIMEOUT_MS,
269
+ maxBytes = DEFAULT_MAX_BYTES,
270
+ } = opts;
271
+ try {
272
+ if (typeof symbol !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(symbol)) {
273
+ return { status: 'unavailable' };
274
+ }
275
+ let binary = opts.binary;
276
+ if (binary === undefined) {
277
+ binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
278
+ }
279
+ if (!binary) return { status: 'no-binary' };
280
+
281
+ const res = spawnSync(binary, ['callgraph', symbol], {
282
+ cwd,
283
+ timeout: timeoutMs,
284
+ encoding: 'utf8',
285
+ maxBuffer: 4 * 1024 * 1024,
286
+ stdio: ['ignore', 'pipe', 'ignore'],
287
+ env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
288
+ });
289
+ if (res.error || res.signal) return { status: 'unavailable' };
290
+ // grep-parity exit codes: 1 = symbol not found (no graph node).
291
+ if (res.status === 1) return { status: 'no-hits' };
292
+ if (res.status !== 0) return { status: 'unavailable' };
293
+ const out = (res.stdout || '').trim();
294
+ // Only an edge-bearing tree is marginal over the grep the model already ran.
295
+ if (!out || out.startsWith(NO_MATCH_PREFIX) ||
296
+ !(out.includes('← called by') || out.includes('→ calls'))) {
297
+ return { status: 'no-hits' };
298
+ }
299
+ const { text, truncated } = truncateAtLine(out, maxBytes);
300
+ return { status: 'hits', text, truncated };
301
+ } catch {
302
+ return { status: 'unavailable' };
303
+ }
304
+ }
305
+
246
306
  module.exports = {
247
- runGrepAnswer, runShowAnswer, runOverviewAnswer, truncateAtLine, sanitizeSearchPath,
307
+ runGrepAnswer, runShowAnswer, runOverviewAnswer, runCallgraphAnswer,
308
+ truncateAtLine, sanitizeSearchPath,
248
309
  };
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
4
4
  const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
- const { runGrepAnswer, runShowAnswer, runOverviewAnswer, truncateAtLine } = require('./cg-answer');
7
+ const { runGrepAnswer, runShowAnswer, runOverviewAnswer, runCallgraphAnswer, truncateAtLine } = require('./cg-answer');
8
8
 
9
9
  // Stub "binary": a node script that reacts to its first real arg so one stub
10
10
  // covers hits / no-hits / error / timeout cases.
@@ -24,6 +24,15 @@ else if (pattern === 'NothingHere') {
24
24
  } else if (pattern === 'NothingHereExit1') {
25
25
  // v0.50 grep-parity binary: no match → empty stdout + exit 1
26
26
  process.exit(1);
27
+ } else if (pattern === 'HasCallers') {
28
+ // callgraph with real edges → runCallgraphAnswer 'hits'
29
+ process.stdout.write(
30
+ 'HasCallers (src/a.rs)\\n' +
31
+ ' \\u2190 called by: alpha (src/b.rs)\\n' +
32
+ ' \\u2192 calls: beta (src/c.rs)\\n');
33
+ } else if (pattern === 'LeafSymbol') {
34
+ // callgraph with a bare header, no edge lines → 'no-hits' (no marginal value)
35
+ process.stdout.write('LeafSymbol (src/a.rs)\\n');
27
36
  } else {
28
37
  process.stdout.write(
29
38
  'src/storage/db.rs:42 fn ' + pattern + '() {\\n' +
@@ -251,3 +260,50 @@ test('runOverviewAnswer: empty/oversized dir → unavailable (never spawns)', ()
251
260
  runOverviewAnswer({ cwd: stubDir, dir: 'a'.repeat(301), binary: stubBinary() }).status,
252
261
  'unavailable');
253
262
  });
263
+
264
+ // ── runCallgraphAnswer (v0.75) — cross-file caller/callee tree ────────
265
+
266
+ test('runCallgraphAnswer: edge-bearing tree → hits with caller/callee lines', () => {
267
+ const r = runCallgraphAnswer({ cwd: stubDir, symbol: 'HasCallers', binary: stubBinary() });
268
+ assert.equal(r.status, 'hits');
269
+ assert.match(r.text, /called by: alpha/);
270
+ assert.match(r.text, /calls: beta/);
271
+ });
272
+
273
+ test('runCallgraphAnswer: passes callgraph subcommand + symbol as argv', () => {
274
+ // 'HasCallers' is the only stub branch that emits edges; assert it reached it.
275
+ const r = runCallgraphAnswer({ cwd: stubDir, symbol: 'HasCallers', binary: stubBinary() });
276
+ assert.equal(r.status, 'hits');
277
+ assert.match(r.text, /← called by/);
278
+ });
279
+
280
+ test('runCallgraphAnswer: bare header with no edges → no-hits (no marginal value)', () => {
281
+ const r = runCallgraphAnswer({ cwd: stubDir, symbol: 'LeafSymbol', binary: stubBinary() });
282
+ assert.equal(r.status, 'no-hits');
283
+ });
284
+
285
+ test('runCallgraphAnswer: symbol not in graph (exit 1) → no-hits', () => {
286
+ const r = runCallgraphAnswer({ cwd: stubDir, symbol: 'NothingHereExit1', binary: stubBinary() });
287
+ assert.equal(r.status, 'no-hits');
288
+ });
289
+
290
+ test('runCallgraphAnswer: failing binary → unavailable', () => {
291
+ const r = runCallgraphAnswer({ cwd: stubDir, symbol: 'ExplodePlease', binary: stubBinary() });
292
+ assert.equal(r.status, 'unavailable');
293
+ });
294
+
295
+ test('runCallgraphAnswer: missing binary → no-binary (distinct from runtime unavailable)', () => {
296
+ const r = runCallgraphAnswer({ cwd: stubDir, symbol: 'HasCallers', binary: null });
297
+ assert.equal(r.status, 'no-binary');
298
+ });
299
+
300
+ test('runCallgraphAnswer: non-identifier symbol → unavailable (never spawns)', () => {
301
+ assert.equal(runCallgraphAnswer({ cwd: stubDir, symbol: 'a|b', binary: stubBinary() }).status, 'unavailable');
302
+ assert.equal(runCallgraphAnswer({ cwd: stubDir, symbol: '', binary: stubBinary() }).status, 'unavailable');
303
+ assert.equal(runCallgraphAnswer({ cwd: stubDir, symbol: 'def foo', binary: stubBinary() }).status, 'unavailable');
304
+ });
305
+
306
+ test('runCallgraphAnswer: timeout → unavailable', () => {
307
+ const r = runCallgraphAnswer({ cwd: stubDir, symbol: 'HangForever', binary: stubBinary(), timeoutMs: 300 });
308
+ assert.equal(r.status, 'unavailable');
309
+ });
@@ -25,7 +25,7 @@ const path = require('path');
25
25
  const crypto = require('crypto');
26
26
  const { cgTmpDir } = require('./tmp-dir');
27
27
  const { recordRecommendation } = require('./recommendation-log');
28
- const { runGrepAnswer, runShowAnswer, sanitizeSearchPath } = require('./cg-answer');
28
+ const { runGrepAnswer, runShowAnswer, runCallgraphAnswer, sanitizeSearchPath } = require('./cg-answer');
29
29
  const { emitPostToolContext } = require('./hook-emit');
30
30
  const {
31
31
  splitTopLevelSegments,
@@ -65,8 +65,21 @@ function findFoldableGrepSegment(cmd) {
65
65
  // Short header so the model recognizes this as cg's parallel structural view of
66
66
  // the grep it just ran (the grep already executed; this is additive context).
67
67
  const INJECT_HEADER = '[code-graph] AST-aware view of your grep (ran alongside):';
68
+ // callgraph mode carries the cross-file caller/callee tree — the marginal signal
69
+ // the grep CANNOT return (2026-06-26 audit: the grep-echo above was redundant
70
+ // with the model's own hits; the caller tree is what moves behavior).
71
+ const CALLGRAPH_HEADER =
72
+ '[code-graph] Cross-file call graph for the symbol you grepped (grep can\'t show this):';
68
73
 
69
74
  function buildInjectText(answer, mode) {
75
+ if (mode === 'callgraph') {
76
+ const lines = [CALLGRAPH_HEADER, answer.text];
77
+ if (answer.truncated) {
78
+ lines.push('(truncated — run `code-graph-mcp callgraph <symbol>` yourself for the full tree)');
79
+ }
80
+ lines.push('`← called by` = callers, `→ calls` = callees, across all files — use these directly.');
81
+ return lines.join('\n');
82
+ }
70
83
  const lines = [INJECT_HEADER, answer.text];
71
84
  if (answer.truncated) {
72
85
  lines.push(mode === 'show'
@@ -142,18 +155,39 @@ function runMain() {
142
155
 
143
156
  const { segment, block } = found;
144
157
  // Run the answer exactly like the deny path.
145
- const pattern = translateBreToRg(segment, pickBlockPattern(segment));
158
+ const rawPattern = pickBlockPattern(segment);
159
+ const pattern = translateBreToRg(segment, rawPattern);
146
160
  const searchPath = sanitizeSearchPath(extractSearchPath(segment));
147
161
  let answer = { status: 'unavailable' };
148
162
  let answeredMode = block.mode;
149
- if (block.mode === 'show') {
150
- answer = runShowAnswer({ cwd: root, symbols: block.symbols });
151
- if (answer.status !== 'hits' && pattern) {
163
+
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) {
172
+ const cg = runCallgraphAnswer({ cwd: root, symbol });
173
+ if (cg.status === 'hits') {
174
+ answer = cg;
175
+ answeredMode = 'callgraph';
176
+ }
177
+ }
178
+
179
+ if (answer.status !== 'hits') {
180
+ if (block.mode === 'show') {
181
+ answeredMode = 'show';
182
+ answer = runShowAnswer({ cwd: root, symbols: block.symbols });
183
+ if (answer.status !== 'hits' && pattern) {
184
+ answeredMode = 'grep';
185
+ answer = runGrepAnswer({ cwd: root, pattern, searchPath });
186
+ }
187
+ } else if (pattern) {
152
188
  answeredMode = 'grep';
153
189
  answer = runGrepAnswer({ cwd: root, pattern, searchPath });
154
190
  }
155
- } else if (pattern) {
156
- answer = runGrepAnswer({ cwd: root, pattern, searchPath });
157
191
  }
158
192
 
159
193
  // Only inject on hits — no-hits / unavailable / no-binary stay silent (the grep
@@ -95,6 +95,19 @@ test('buildInjectText: no truncation note when not truncated', () => {
95
95
  assert.doesNotMatch(out, /truncated/);
96
96
  });
97
97
 
98
+ test('buildInjectText: callgraph mode uses the cross-file header (not the grep-echo header)', () => {
99
+ const out = buildInjectText({ text: ' ← called by: alpha (src/b.rs)', truncated: false }, 'callgraph');
100
+ assert.match(out, /Cross-file call graph/);
101
+ assert.match(out, /grep can't show this/);
102
+ assert.doesNotMatch(out, /AST-aware view of your grep/);
103
+ assert.match(out, /← called by` = callers/);
104
+ });
105
+
106
+ test('buildInjectText: callgraph truncation note points at the callgraph command', () => {
107
+ const out = buildInjectText({ text: 'tree', truncated: true }, 'callgraph');
108
+ assert.match(out, /code-graph-mcp callgraph <symbol>/);
109
+ });
110
+
98
111
  // ── opt-out / kill switch ───────────────────────────────────────────
99
112
 
100
113
  test('isSilenced: CODE_GRAPH_QUIET_HOOKS=1 → silenced; default not', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.74.7",
3
+ "version": "0.75.0",
4
4
  "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,10 +35,10 @@
35
35
  "node": ">=16"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@sdsrs/code-graph-linux-x64": "0.74.7",
39
- "@sdsrs/code-graph-linux-arm64": "0.74.7",
40
- "@sdsrs/code-graph-darwin-x64": "0.74.7",
41
- "@sdsrs/code-graph-darwin-arm64": "0.74.7",
42
- "@sdsrs/code-graph-win32-x64": "0.74.7"
38
+ "@sdsrs/code-graph-linux-x64": "0.75.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.75.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.75.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.75.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.75.0"
43
43
  }
44
44
  }