@sdsrs/code-graph 0.82.1 → 0.84.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.
package/README.md CHANGED
@@ -7,7 +7,7 @@ A high-performance code knowledge graph server implementing the [Model Context P
7
7
  - **Multi-language parsing** — Tree-sitter AST extraction across tiers of depth:
8
8
  - **Full** (calls + imports + inheritance + HTTP routes + test markers): TypeScript/TSX, JavaScript, Go, Python, Rust, Java
9
9
  - **Smoke-tested** (calls + imports + inheritance): C#, Kotlin, Ruby, PHP, Swift, Dart
10
- - **Limited** (functions + calls + `#include` imports + gtest test markers; `Class::method` scope qualification deferred): C, C++
10
+ - **Limited** (functions + calls + `#include` imports + gtest test markers + C++ base-class inheritance; `Class::method` scope qualification deferred): C, C++
11
11
  - **Scripting**: Bash (functions + commands + `source`/`.` imports), Markdown (headings)
12
12
  - **File-FTS only** (no AST symbol extraction): HTML, CSS, JSON
13
13
  - **Semantic code search** — Hybrid BM25 full-text + vector semantic search with Reciprocal Rank Fusion (RRF), powered by sqlite-vec
@@ -301,7 +301,7 @@ Available when installed as a Claude Code plugin:
301
301
  |----------|-----------|-------------------|
302
302
  | TypeScript | .ts, .tsx | calls, imports, exports, inherits, implements, routes_to |
303
303
  | JavaScript | .js, .jsx, .mjs, .cjs | calls, imports, exports, inherits, routes_to |
304
- | Go | .go | calls, imports, routes_to |
304
+ | Go | .go | calls, imports, inherits, routes_to |
305
305
  | Python | .py, .pyi | calls, imports, inherits, routes_to |
306
306
  | Rust | .rs | calls, imports, inherits, implements |
307
307
  | Java | .java | calls, imports, inherits, implements |
@@ -310,7 +310,7 @@ Available when installed as a Claude Code plugin:
310
310
  | Ruby | .rb | calls, imports, inherits |
311
311
  | PHP | .php | calls, imports, inherits, implements |
312
312
  | Swift | .swift | calls, imports, inherits |
313
- | Dart | .dart | calls, imports, implements |
313
+ | Dart | .dart | calls, imports, inherits, implements |
314
314
  | C | .c, .h | calls, imports |
315
315
  | C++ | .cpp, .cc, .cxx, .hpp | calls, imports, inherits |
316
316
  | HTML | .html, .htm | structural parsing |
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.82.1",
7
+ "version": "0.84.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -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.82.1",
3
+ "version": "0.84.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.82.1",
39
- "@sdsrs/code-graph-linux-arm64": "0.82.1",
40
- "@sdsrs/code-graph-darwin-x64": "0.82.1",
41
- "@sdsrs/code-graph-darwin-arm64": "0.82.1",
42
- "@sdsrs/code-graph-win32-x64": "0.82.1"
38
+ "@sdsrs/code-graph-linux-x64": "0.84.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.84.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.84.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.84.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.84.0"
43
43
  }
44
44
  }