@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 |
|
|
@@ -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.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.
|
|
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.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
|
}
|