graph-indexer 1.2.0 → 2.0.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +412 -0
  2. package/README.md +216 -596
  3. package/SECURITY.md +68 -0
  4. package/TRADEMARK.md +31 -0
  5. package/cli-ui.mjs +78 -0
  6. package/config.mjs +179 -28
  7. package/daemon-ctl.mjs +175 -0
  8. package/daemon-lock.mjs +69 -0
  9. package/embedders/python/mlx_embed_server.py +61 -0
  10. package/embedders/python/requirements-mlx.txt +5 -0
  11. package/embedders/setup-mlx.mjs +110 -0
  12. package/embeddings.mjs +489 -0
  13. package/engine/binary.mjs +399 -0
  14. package/{core-engine.mjs → engine/memory.mjs} +107 -440
  15. package/{sqlite-store.mjs → engine/sqlite.mjs} +137 -42
  16. package/enrichment.mjs +111 -32
  17. package/git-signals.mjs +248 -0
  18. package/indexer.mjs +151 -42
  19. package/init.mjs +1603 -171
  20. package/layout.mjs +123 -0
  21. package/mcp/format.mjs +61 -0
  22. package/mcp/tools.mjs +1157 -0
  23. package/mcp/topology.mjs +551 -0
  24. package/mcp-server.mjs +55 -24
  25. package/package.json +44 -13
  26. package/parse/extractor.mjs +617 -0
  27. package/parse/imports.mjs +365 -0
  28. package/parse/languages.mjs +89 -0
  29. package/parse/metadata.mjs +655 -0
  30. package/parse/routes.mjs +327 -0
  31. package/prompts/CORE.md +86 -0
  32. package/prompts/DOMAIN_TEMPLATE.md +46 -0
  33. package/prompts/INTEGRATION.md +123 -0
  34. package/prompts/frameworks/ANDROID.md +17 -0
  35. package/prompts/frameworks/ASPNET_CORE.md +19 -0
  36. package/prompts/frameworks/FASTAPI_DJANGO.md +19 -0
  37. package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
  38. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +18 -0
  39. package/prompts/frameworks/RAILS.md +20 -0
  40. package/prompts/frameworks/REACT.md +20 -0
  41. package/prompts/frameworks/SPRING_BOOT.md +19 -0
  42. package/prompts/languages/BASH.md +22 -0
  43. package/prompts/languages/C.md +21 -0
  44. package/prompts/languages/CSHARP.md +24 -0
  45. package/prompts/languages/CSS_SCSS.md +22 -0
  46. package/prompts/languages/GO.md +23 -0
  47. package/prompts/languages/JAVA.md +23 -0
  48. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
  49. package/prompts/languages/KOTLIN.md +24 -0
  50. package/prompts/languages/PHP.md +23 -0
  51. package/prompts/languages/PYTHON.md +23 -0
  52. package/prompts/languages/RUBY.md +23 -0
  53. package/prompts/languages/RUST.md +21 -0
  54. package/prompts/languages/SWIFT.md +21 -0
  55. package/search-core.mjs +254 -43
  56. package/storage.mjs +28 -3
  57. package/watch-daemon.mjs +69 -23
  58. package/PROMPT.md +0 -182
  59. package/mcp-tools.mjs +0 -545
  60. package/parser-utils.mjs +0 -1170
@@ -0,0 +1,551 @@
1
+ /**
2
+ * @file mcp/topology.mjs
3
+ * @description MCP topology helpers: caller classification, symbol references,
4
+ * route mapping, connected-subgraph traversal, and HyDE.
5
+ * @author MaquinaTech <https://github.com/MaquinaTech>
6
+ * @copyright (c) 2026 MaquinaTech. All rights reserved.
7
+ * @license MIT
8
+ */
9
+ import { isNaturalLanguageQuery } from '../search-core.mjs';
10
+ import { extractSignatureLine } from './format.mjs';
11
+
12
+ // ─── Low-confidence handoff ───────────────────────────────────────────────────
13
+ // The dominant failure mode for behavioural queries is "didn't nail the exact
14
+ // symbol" — yet most of those misses still land the correct FILE in the top
15
+ // results. When a natural-language query yields no dominant match, surfacing the
16
+ // distinct candidate files lets the agent `get_file_skeleton` them instead of
17
+ // reading whole files blind. The gate is derived entirely from the returned
18
+ // ranking (no extra work, a few tokens) and is deliberately conservative so it
19
+ // NEVER fires on a confident symbolic hit:
20
+ // • a pinned `exact_tokens` → the caller already knows the symbol;
21
+ // • a keyword / symbol-lookup query (not natural language) → rank-1-dominant;
22
+ // • a top result that clearly separates from #2 (≥2× the fused score, the same
23
+ // factor as the exact-name boost) → a dominant match;
24
+ // • results confined to a single file → nothing cross-file to hand off.
25
+ // The 2× separation maps to fuseAndRank's exact-name boost multiplier — it is a
26
+ // structural constant, not a value fit to the benchmark queries.
27
+ export function assessConfidence(matches, fullQuery, exactPinned, limit = 5) {
28
+ const distinctFiles = [];
29
+ const seen = new Set();
30
+ for (const m of matches) {
31
+ const fp = m?.chunk?.file_path;
32
+ if (fp && !seen.has(fp)) { seen.add(fp); distinctFiles.push(fp); }
33
+ }
34
+ const dominates = matches.length >= 2 && matches[0].score >= 2 * matches[1].score;
35
+ const lowConfidence = !exactPinned
36
+ && isNaturalLanguageQuery(fullQuery)
37
+ && matches.length >= 2
38
+ && !dominates
39
+ && distinctFiles.length >= 2;
40
+ return { lowConfidence, candidateFiles: lowConfidence ? distinctFiles.slice(0, limit) : [] };
41
+ }
42
+
43
+ // ─── Query-side HyDE (opt-in) ─────────────────────────────────────────────────
44
+ // Behavioural queries often share NO vocabulary with the code that answers them.
45
+ // HyDE (Hypothetical Document Embeddings) closes that gap on the QUERY side: a
46
+ // local LLM writes a short hypothetical implementation of the request, we embed
47
+ // THAT, and blend it with the raw query vector (never replace it — the raw query
48
+ // is the anchor). The chunk side already does this via chunk.hyde/summaries; this
49
+ // is its query-time complement. Gated off by default → when disabled, search is
50
+ // byte-identical and the eval/parity are untouched. Per-query result is cached for
51
+ // the process lifetime so repeated queries pay the generation cost once.
52
+ const HYDE_ALPHA = 0.5; // blend weight on the hypothetical vector (0 = pure query, 1 = pure HyDE)
53
+ const _hydeCache = new Map(); // normalized query → blended Float32Array
54
+
55
+ // ─── Repo-language detection (drives the language-aware HyDE prompt) ───────────
56
+ const _EXT_FAMILY = {
57
+ ts: 'typescript', tsx: 'typescript',
58
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
59
+ py: 'python', go: 'go', rs: 'rust', java: 'java',
60
+ kt: 'kotlin', kts: 'kotlin', cs: 'csharp', rb: 'ruby', php: 'php',
61
+ swift: 'swift', c: 'c', h: 'c',
62
+ sh: 'bash', bash: 'bash', zsh: 'bash',
63
+ scss: 'scss', css: 'scss', sass: 'scss',
64
+ };
65
+ const _LARAVEL_DIRS = new Set([
66
+ 'models', 'http', 'controllers', 'console', 'providers',
67
+ 'middleware', 'requests', 'builders', 'repositories', 'services',
68
+ ]);
69
+
70
+ /**
71
+ * Pick the canonical language/framework key for a repo from its index stats.
72
+ * @param {Map<string, number>} extCounts extension → chunk count (db.stats().extCounts)
73
+ * @param {Iterable<{file_path?:string}>} chunksIterable bounded path scan (db.iterateChunks())
74
+ * @returns {string|null} one of typescript|javascript|python|go|rust|java-spring|java|
75
+ * kotlin|csharp|ruby|php-laravel|php|swift|c|bash|scss, or null when ambiguous/unknown
76
+ * (null → buildHydePrompt falls back to the generic, never-regressing prompt).
77
+ */
78
+ export function detectRepoLanguage(extCounts, chunksIterable = []) {
79
+ const score = new Map();
80
+ if (extCounts && typeof extCounts.forEach === 'function') {
81
+ for (const [ext, n] of extCounts) {
82
+ const fam = _EXT_FAMILY[String(ext).toLowerCase()];
83
+ if (fam) score.set(fam, (score.get(fam) || 0) + n);
84
+ }
85
+ }
86
+ if (score.size === 0) return null;
87
+
88
+ let springSignal = false;
89
+ const laravelDirs = new Set();
90
+ let laravelKw = false;
91
+ let scanned = 0;
92
+ for (const chunk of (chunksIterable || [])) {
93
+ if (scanned++ >= 400) break;
94
+ const fp = String(chunk?.file_path || '').toLowerCase();
95
+ if (!fp) continue;
96
+ if (fp.includes('springframework')) springSignal = true;
97
+ for (const seg of fp.split('/')) {
98
+ if (_LARAVEL_DIRS.has(seg)) laravelDirs.add(seg);
99
+ if (seg === 'laravel' || seg === 'eloquent' || seg === 'artisan' || seg === 'symfony') laravelKw = true;
100
+ }
101
+ }
102
+
103
+ const ts = score.get('typescript') || 0;
104
+ const js = score.get('javascript') || 0;
105
+ const web = ts + js;
106
+ const ranking = [];
107
+ for (const [fam, n] of score) {
108
+ if (fam === 'typescript' || fam === 'javascript') continue;
109
+ ranking.push([fam, n]);
110
+ }
111
+ if (web > 0) ranking.push(['web', web]);
112
+ ranking.sort((a, b) => b[1] - a[1]);
113
+ const [topFam, topScore] = ranking[0];
114
+ const secondScore = ranking[1]?.[1] ?? 0;
115
+
116
+ if ((score.get('java') || 0) > 0 && springSignal) return 'java-spring';
117
+
118
+ if (secondScore > 0 && secondScore >= 0.9 * topScore) return null;
119
+
120
+ if (topFam === 'web') return ts >= 0.6 * web ? 'typescript' : 'javascript';
121
+ if (topFam === 'php') return (laravelKw || laravelDirs.size >= 2) ? 'php-laravel' : 'php';
122
+ if (topFam === 'java') return springSignal ? 'java-spring' : 'java';
123
+ return topFam;
124
+ }
125
+
126
+ // HyDE must never degrade below no-HyDE when language is unknown/ambiguous.
127
+ function _genericHydePrompt(query) {
128
+ return (
129
+ `Write a short, realistic code snippet (5-15 lines, any language) that implements or `
130
+ + `directly answers the request below. Output ONLY code — no prose, no markdown fences, `
131
+ + `no comments explaining yourself.\n\nRequest: ${query}\n\nCode:`
132
+ );
133
+ }
134
+
135
+ const _HYDE_LANG = {
136
+ typescript: {
137
+ name: 'TypeScript',
138
+ idioms: 'Match NestJS/React idioms: @Injectable/@Controller providers with constructor injection (private readonly svc: Service) and async handlers, or a React FC using useState/useEffect with a typed props interface.',
139
+ primer: 'export ',
140
+ },
141
+ javascript: {
142
+ name: 'JavaScript',
143
+ idioms: 'Match Axios/Express idioms: middleware (req, res, next), router.get/post, module.exports, request/response interceptors, and promise chains.',
144
+ primer: 'const ',
145
+ },
146
+ python: {
147
+ name: 'Python',
148
+ idioms: 'Match FastAPI/Django idioms: @router.get/@router.post async handlers with Depends() and Pydantic BaseModel, or Django class-based views and models with CharField/ForeignKey and QuerySet .filter()/.select_related().',
149
+ primer: 'async def ',
150
+ },
151
+ go: {
152
+ name: 'Go',
153
+ idioms: 'Match Gin idioms: handlers func(c *gin.Context) calling c.JSON(http.StatusOK, ...) and c.ShouldBindJSON(&req), middleware as gin.HandlerFunc, and router groups r.Group("/api").',
154
+ primer: 'func ',
155
+ },
156
+ rust: {
157
+ name: 'Rust',
158
+ idioms: 'Match idiomatic Rust: pub struct/trait definitions, impl blocks, #[derive(Debug, Clone)], Result/Option return types, match arms, and use crate:: imports.',
159
+ primer: 'pub ',
160
+ },
161
+ 'java-spring': {
162
+ name: 'Java (Spring Boot)',
163
+ idioms: 'Match Spring Boot idioms: @Service/@Repository/@RestController annotations, constructor injection, @Transactional, ResponseEntity<T>, JpaRepository<Entity, ID>, and @GetMapping/@PostMapping.',
164
+ primer: 'import org.springframework.',
165
+ },
166
+ java: {
167
+ name: 'Java',
168
+ idioms: 'Match standard Java idioms: class/interface definitions, generics, Optional<T>, try/catch, and stream().filter().map().collect().',
169
+ primer: 'public class ',
170
+ },
171
+ kotlin: {
172
+ name: 'Kotlin (Android)',
173
+ idioms: 'Match Android/Kotlin idioms: ViewModel with LiveData/StateFlow, coroutines (suspend fun, viewModelScope.launch), Hilt injection (@HiltViewModel, @Inject constructor), and companion object.',
174
+ primer: 'class ',
175
+ },
176
+ csharp: {
177
+ name: 'C# (ASP.NET Core)',
178
+ idioms: 'Match ASP.NET Core idioms: [ApiController]/[Route] controllers, async Task<IActionResult>, [HttpGet]/[HttpPost], ILogger<T> injection, and Entity Framework (.Include, .Where, .FirstOrDefaultAsync).',
179
+ primer: 'public class ',
180
+ },
181
+ ruby: {
182
+ name: 'Ruby (Rails)',
183
+ idioms: 'Match Rails idioms: ActiveRecord models (belongs_to, has_many, scope, validate), controller actions (def index/show/create) with before_action, and render json:.',
184
+ primer: 'class ',
185
+ },
186
+ 'php-laravel': {
187
+ name: 'PHP (Laravel/Symfony)',
188
+ idioms: 'Match Laravel idioms: Eloquent models (extends Model, $fillable, hasMany/belongsTo), Service classes with constructor injection, Controllers extending Controller, Artisan commands (extends Command, handle()), and Facades (Route::, Auth::). For Symfony use #[Route] attributes and autowired services.',
189
+ primer: '<?php\n\nnamespace App\\',
190
+ },
191
+ php: {
192
+ name: 'PHP',
193
+ idioms: 'Match idiomatic PHP: namespaced classes, typed properties and method signatures, constructor injection, and interface implementations.',
194
+ primer: '<?php\n\n',
195
+ },
196
+ swift: {
197
+ name: 'Swift',
198
+ idioms: 'Match Swift/Alamofire idioms: AF.request(...).responseDecodable, Session, RequestInterceptor/EventMonitor, Result<T, Error> completion handlers, @discardableResult, and protocol conformances.',
199
+ primer: 'public ',
200
+ },
201
+ c: {
202
+ name: 'C',
203
+ idioms: 'Match idiomatic C: function definitions with pointer parameters, struct definitions, malloc/free with NULL checks, and a typed C API (e.g. cJSON_Parse, cJSON_GetObjectItem, cJSON_CreateObject).',
204
+ primer: 'static ',
205
+ },
206
+ bash: {
207
+ name: 'Bash',
208
+ idioms: 'Match idiomatic Bash: function definitions name() { ... }, local variables, case statements, [[ ]] conditionals, $() command substitution, and return 0/1.',
209
+ primer: null,
210
+ },
211
+ scss: {
212
+ name: 'SCSS',
213
+ idioms: 'Match idiomatic SCSS: @mixin definitions used via @include, nested selectors, $variable declarations, @extend, and &:hover/&:focus patterns.',
214
+ primer: null,
215
+ },
216
+ };
217
+
218
+ export function buildHydePrompt(query, lang = null) {
219
+ const spec = lang ? _HYDE_LANG[lang] : null;
220
+ if (!spec) return _genericHydePrompt(query);
221
+ const primer = spec.primer ?? `${spec.name} code:\n`;
222
+ return (
223
+ `Write a short, realistic ${spec.name} code snippet (5-15 lines) that implements or `
224
+ + `directly answers the request below. ${spec.idioms} Output ONLY code — no prose, `
225
+ + `no markdown fences, no comments explaining yourself.\n\nRequest: ${query}\n\n${primer}`
226
+ );
227
+ }
228
+
229
+ export function blendVectors(a, b, alpha = HYDE_ALPHA) {
230
+ const out = new Float32Array(a.length);
231
+ for (let i = 0; i < a.length; i++) out[i] = (1 - alpha) * a[i] + alpha * b[i];
232
+ return out;
233
+ }
234
+
235
+ /**
236
+ * Augment a query vector with a hypothetical-snippet embedding. Best-effort: any
237
+ * failure (generator down, dim mismatch, empty snippet) returns the raw vector
238
+ * unchanged, so HyDE can never degrade a query below the no-HyDE baseline.
239
+ */
240
+ export async function hydeQueryVector(query, rawVec, { embedder, generate, lang = null }) {
241
+ if (!rawVec || !embedder || !generate) return rawVec;
242
+ const norm = query.trim().toLowerCase();
243
+ if (_hydeCache.has(norm)) return _hydeCache.get(norm);
244
+ let blended = rawVec;
245
+ try {
246
+ const snippet = await generate(buildHydePrompt(query, lang));
247
+ if (snippet && snippet.trim()) {
248
+ const hydeVec = await embedder.embedQuery(snippet.slice(0, 2000));
249
+ if (hydeVec && hydeVec.length === rawVec.length) blended = blendVectors(rawVec, hydeVec);
250
+ }
251
+ } catch { /* keep the raw vector */ }
252
+ _hydeCache.set(norm, blended);
253
+ return blended;
254
+ }
255
+
256
+ /** Lowercased capitalized type tokens from a type / return-type string —
257
+ * "Promise<OrderRepo>" → ["promise","orderrepo"]. Lets an inferred receiver type
258
+ * (parse/metadata.mjs attaches recv_type / recv_via_call to call sites) be matched
259
+ * against a target method's defining class without parsing the type grammar. */
260
+ function _typeMatchTokens(str) {
261
+ const out = [];
262
+ if (!str) return out;
263
+ for (const m of String(str).match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || []) {
264
+ if (/^[A-Z]/.test(m)) out.push(m.toLowerCase());
265
+ }
266
+ return out;
267
+ }
268
+
269
+ // ─── Call-graph confidence (precise blast radius) ────────────────────────────────
270
+
271
+ /**
272
+ * Split the bare name-match callers of a function into **high-confidence** (the
273
+ * real blast radius) vs **name-only** (an ambiguous same-named symbol elsewhere).
274
+ *
275
+ * The call graph matches by callee name, so `get_call_graph("save")` otherwise
276
+ * returns callers of *every* `save()` in the repo. This re-classifies them using
277
+ * only cheap, index-time signals — no type inference:
278
+ * • target uniqueness — one symbol named X ⇒ every caller is unambiguous;
279
+ * • receiver hints — `this.X` from the same class, or a direct `X()` to a
280
+ * free function (captured by parse/metadata.mjs extractCallSites);
281
+ * • the file import graph — a caller whose file imports the file defining X is
282
+ * very likely calling that X.
283
+ * `targetClass` scopes the question to one class's method.
284
+ *
285
+ * @returns {{ high:Array, nameOnly:Array, targetDefs:object[], ambiguous:boolean,
286
+ * hasSiteData:boolean, classFiltered:boolean }}
287
+ * high/nameOnly items are { chunk, reason, recvHint }.
288
+ */
289
+ export function classifyCallers(db, targetFunction, { targetClass = null } = {}) {
290
+ const callers = db.findCallers(targetFunction);
291
+ const allDefs = db.resolveSymbol(targetFunction);
292
+ let targetDefs = allDefs;
293
+
294
+ let classFiltered = false;
295
+ if (targetClass) {
296
+ const tcl = String(targetClass).toLowerCase();
297
+ const scoped = allDefs.filter(d => (d.class_context || '').toLowerCase() === tcl);
298
+ if (scoped.length) { targetDefs = scoped; classFiltered = true; }
299
+ }
300
+
301
+ const targetClasses = new Set(targetDefs.map(d => (d.class_context || '').toLowerCase()).filter(Boolean));
302
+ const targetFiles = new Set(targetDefs.map(d => d.file_path));
303
+ const targetIsFreeFn = targetDefs.some(d => !d.class_context);
304
+ const uniqueTarget = targetDefs.length <= 1;
305
+ const ambiguous = allDefs.length > 1;
306
+ const tcl = targetClass ? String(targetClass).toLowerCase() : null;
307
+
308
+ // Receiver-type promotion: a caller through a typed/inferred receiver whose type is
309
+ // the class that defines the target method is a real caller, even though its raw
310
+ // receiver hint is a variable name (`const s = getStore(); s.save()`). Match the
311
+ // inferred type against the target's defining class(es) — or an explicit target
312
+ // class. recv_via_call factories are resolved to their recorded return type here,
313
+ // at query time, so no extra index state is needed (memoised per call).
314
+ const typeMatchSet = new Set(targetClasses);
315
+ if (tcl) typeMatchSet.add(tcl);
316
+ const classDisplay = new Map();
317
+ for (const d of targetDefs) { const c = d.class_context; if (c) classDisplay.set(c.toLowerCase(), c); }
318
+ if (targetClass) classDisplay.set(String(targetClass).toLowerCase(), String(targetClass));
319
+ const returnTokenCache = new Map();
320
+ const calleeReturnTokens = (callee) => {
321
+ if (returnTokenCache.has(callee)) return returnTokenCache.get(callee);
322
+ const toks = new Set();
323
+ for (const def of db.resolveSymbol(callee) || []) for (const t of _typeMatchTokens(def.return_type)) toks.add(t);
324
+ returnTokenCache.set(callee, toks);
325
+ return toks;
326
+ };
327
+
328
+ let hasSiteData = false;
329
+ const high = [], nameOnly = [];
330
+
331
+ for (const caller of callers) {
332
+ const sites = (caller.call_sites || []).filter(s => s && s.name === targetFunction);
333
+ if (sites.length) hasSiteData = true;
334
+ const recvs = new Set(sites.map(s => s.recv));
335
+ const callerClass = (caller.class_context || '').toLowerCase();
336
+ const deps = db.getDependencies(caller.file_path) || [];
337
+
338
+ // First site whose inferred receiver type names a target class (lowercased token).
339
+ let typeMatch = null;
340
+ if (typeMatchSet.size) {
341
+ for (const s of sites) {
342
+ const toks = s.recv_type ? _typeMatchTokens(s.recv_type)
343
+ : s.recv_via_call ? [...calleeReturnTokens(s.recv_via_call)] : null;
344
+ const m = toks && toks.find(t => typeMatchSet.has(t));
345
+ if (m) { typeMatch = m; break; }
346
+ }
347
+ }
348
+
349
+ let reason = '';
350
+ if (uniqueTarget && !classFiltered) reason = 'sole definition';
351
+ else if (recvs.has('this') && callerClass && targetClasses.has(callerClass)) reason = `this.${targetFunction}()`;
352
+ else if (recvs.has('') && targetIsFreeFn) reason = `${targetFunction}()`;
353
+ else if (typeMatch) reason = `${classDisplay.get(typeMatch) || typeMatch}.${targetFunction}()`;
354
+ else if (deps.some(d => targetFiles.has(d))) reason = 'imports definition';
355
+ else if (targetFiles.has(caller.file_path)) reason = 'same file';
356
+ else if (tcl && [...recvs].some(r => r && r.toLowerCase() === tcl)) reason = `${targetClass}.${targetFunction}()`;
357
+
358
+ const recvHint = [...recvs].map(r =>
359
+ r === '' ? `${targetFunction}()` : r === 'this' ? `this.${targetFunction}()` : `${r}.${targetFunction}()`
360
+ ).join(', ');
361
+
362
+ (reason ? high : nameOnly).push({ chunk: caller, reason, recvHint });
363
+ }
364
+
365
+ return { high, nameOnly, targetDefs, ambiguous, hasSiteData, classFiltered };
366
+ }
367
+
368
+ // ─── Symbol-level references (file→file topology, sharpened to symbol→symbol) ─────
369
+
370
+ /**
371
+ * Resolve *which symbols* reference a target symbol — not just which files. Fuses
372
+ * the three reference kinds the index records and classifies each by confidence
373
+ * using the same cheap, index-time signals as the call graph (no type inference):
374
+ *
375
+ * • calls — `findCallers` + classifyCallers (high / name-only blast radius);
376
+ * • inherits — chunks whose `extends` names the symbol (subclasses / implementers);
377
+ * • types — chunks whose `type_refs` names the symbol (params, returns, fields).
378
+ *
379
+ * For the non-call kinds, a referer is **high-confidence** when the target is the
380
+ * sole definition, the referer's file imports a file that defines it, or it is
381
+ * defined in the same file; otherwise it is **name-only** (a same-named symbol
382
+ * may be meant). This is the symbol-granular "used by" that file-level topology
383
+ * (getImportedBy) can only approximate.
384
+ *
385
+ * @returns {{ symbol:string, targetDefs:object[], ambiguous:boolean,
386
+ * calls:ReturnType<typeof classifyCallers>,
387
+ * inherits:Array, types:Array }}
388
+ * inherits/types items are { chunk, confidence, reason }.
389
+ */
390
+ export function findReferences(db, symbol, { targetClass = null } = {}) {
391
+ const calls = classifyCallers(db, symbol, { targetClass });
392
+ const { targetDefs, ambiguous } = calls;
393
+ const targetFiles = new Set(targetDefs.map(d => d.file_path));
394
+ const uniqueTarget = targetDefs.length <= 1;
395
+ const key = String(symbol).toLowerCase().trim();
396
+
397
+ const inherits = [], types = [];
398
+ for (const ref of db.findReferers(symbol)) {
399
+ const deps = db.getDependencies(ref.file_path) || [];
400
+ const imports = deps.some(d => targetFiles.has(d));
401
+ const sameFile = targetFiles.has(ref.file_path);
402
+ const reason = uniqueTarget ? 'sole definition'
403
+ : imports ? 'imports definition'
404
+ : sameFile ? 'same file' : '';
405
+ const confidence = reason ? 'high' : 'name-only';
406
+ if ((ref.extends || []).some(t => t.toLowerCase() === key)) inherits.push({ chunk: ref, confidence, reason });
407
+ if ((ref.type_refs || []).some(t => t.toLowerCase() === key)) types.push({ chunk: ref, confidence, reason });
408
+ }
409
+ const order = (a, b) => (a.confidence === b.confidence
410
+ ? a.chunk.file_path.localeCompare(b.chunk.file_path)
411
+ : a.confidence === 'high' ? -1 : 1);
412
+ inherits.sort(order); types.sort(order);
413
+
414
+ return { symbol, targetDefs, ambiguous, calls, inherits, types };
415
+ }
416
+
417
+ // ─── HTTP route → handler resolution ──────────────────────────────────────────────
418
+
419
+ /**
420
+ * HTTP routes mapped to their handler chunks. Pure helper over the store contract
421
+ * (`db.findRoutes`), so it is backend-agnostic and importable by tests/agent-trace.
422
+ *
423
+ * Filtering:
424
+ * • method — optional HTTP verb, case-insensitive (GET/POST/…); omitted = all.
425
+ * • path — a query starting with '/' is a PREFIX match (uses the backend's
426
+ * indexed prefix filter); one containing '{' or ':' (a route-pattern
427
+ * hint) — or any other non-'/' query — is a CONTAINS match.
428
+ *
429
+ * Each result inlines the handler chunk's id/name/node_type/start_line/end_line
430
+ * (null when the handler isn't a chunk). Deterministically sorted (file_path, line,
431
+ * method, path) so the in-memory and SQLite backends return byte-identical output.
432
+ *
433
+ * @returns {Array<{method,path,handler_name,handler_chunk_id,file_path,line,framework,
434
+ * name,node_type,start_line,end_line,id}>}
435
+ */
436
+ export function findRoutes(db, { method = null, path: pathQuery = null } = {}) {
437
+ const isPrefix = typeof pathQuery === 'string' && pathQuery.startsWith('/');
438
+ const base = db.findRoutes({ method: method || null, pathPrefix: isPrefix ? pathQuery : null });
439
+ const needle = (typeof pathQuery === 'string' && pathQuery && !isPrefix) ? pathQuery.toLowerCase() : null;
440
+ const rows = (needle ? base.filter(r => String(r.path || '').toLowerCase().includes(needle)) : base)
441
+ .map(r => {
442
+ const c = r.chunk || null;
443
+ return {
444
+ method: r.method, path: r.path, handler_name: r.handler_name,
445
+ handler_chunk_id: r.handler_chunk_id, file_path: r.file_path,
446
+ line: r.line, framework: r.framework,
447
+ name: c?.name ?? null, node_type: c?.node_type ?? null,
448
+ start_line: c?.start_line ?? null, end_line: c?.end_line ?? null,
449
+ id: c?.id ?? null,
450
+ };
451
+ });
452
+ rows.sort((a, b) =>
453
+ (a.file_path < b.file_path ? -1 : a.file_path > b.file_path ? 1 : 0)
454
+ || ((a.line || 0) - (b.line || 0))
455
+ || (a.method < b.method ? -1 : a.method > b.method ? 1 : 0)
456
+ || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
457
+ return rows;
458
+ }
459
+
460
+ // ─── Bounded connected-subgraph traversal (multi-hop in one call) ─────────────────
461
+
462
+ /** ~tokens for a node card (1 token ≈ 4 chars). */
463
+ function _subgraphCardTokens(c) {
464
+ return Math.ceil(`${c.class_context ? c.class_context + '.' : ''}${c.name} [${c.node_type}] ${c.file_path}:${c.start_line}-${c.end_line} ${extractSignatureLine(c.code_snippet).split('\n')[0]}`.length / 4);
465
+ }
466
+
467
+ /** Stable order so the subgraph is byte-identical across backends and runs. */
468
+ function _subgraphSort(arr) {
469
+ return arr.slice().sort((a, b) =>
470
+ (a.file_path < b.file_path ? -1 : a.file_path > b.file_path ? 1 : 0)
471
+ || (a.start_line - b.start_line)
472
+ || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
473
+ }
474
+
475
+ /**
476
+ * Breadth-first connected subgraph around a seed symbol — callees (what it calls),
477
+ * high-confidence callers (the precise blast radius, via classifyCallers), and
478
+ * type/inheritance referers — bounded by node count, hop depth AND a token budget.
479
+ * One call replaces the search_code → get_call_graph → find_references round-trips a
480
+ * "trace this flow across files" task otherwise needs. Reuses only index-time signals
481
+ * (no type inference); fully deterministic (every neighbour list is sorted, ties broken
482
+ * on id) so it is reproducible and backend-agnostic.
483
+ *
484
+ * @returns {{ seed:string, found:boolean, truncated:boolean,
485
+ * nodes:Array<{id,name,node_type,class_context,file_path,start_line,end_line,signature,depth}>,
486
+ * edges:Array<{from:string,to:string,kind:'calls'|'references'}> }}
487
+ */
488
+ export function buildSubgraph(db, seed, { maxNodes = 12, maxDepth = 2, tokenBudget = null } = {}) {
489
+ const seedDefs = db.resolveSymbol(seed);
490
+ if (seedDefs.length === 0) return { seed, found: false, truncated: false, nodes: [], edges: [] };
491
+
492
+ const nodes = new Map(); // id → { chunk, depth }
493
+ const edges = [];
494
+ const edgeSeen = new Set();
495
+ let budget = (tokenBudget != null && tokenBudget > 0) ? tokenBudget : Infinity;
496
+ let truncated = false;
497
+
498
+ const addEdge = (from, to, kind) => {
499
+ if (!from || !to || from === to) return;
500
+ const k = `${from} ${to} ${kind}`;
501
+ if (!edgeSeen.has(k)) { edgeSeen.add(k); edges.push({ from, to, kind }); }
502
+ };
503
+ const tryAdd = (c, depth) => {
504
+ if (!c) return false;
505
+ if (nodes.has(c.id)) return false;
506
+ if (nodes.size >= maxNodes) { truncated = true; return false; }
507
+ const t = _subgraphCardTokens(c);
508
+ if (nodes.size > 0 && t > budget) { truncated = true; return false; }
509
+ nodes.set(c.id, { chunk: c, depth });
510
+ budget -= t;
511
+ return true;
512
+ };
513
+
514
+ const queue = [];
515
+ for (const d of _subgraphSort(seedDefs)) if (tryAdd(d, 0)) queue.push(d.id);
516
+
517
+ for (let head = 0; head < queue.length; head++) {
518
+ const entry = nodes.get(queue[head]);
519
+ if (!entry || entry.depth >= maxDepth) continue;
520
+ const chunk = entry.chunk;
521
+ const d = entry.depth + 1;
522
+
523
+ for (const name of [...new Set(chunk.calls || [])].sort()) {
524
+ const defs = db.resolveSymbol(name);
525
+ if (!defs.length) continue;
526
+ const target = _subgraphSort(defs)[0];
527
+ const added = tryAdd(target, d);
528
+ if (nodes.has(target.id)) addEdge(chunk.id, target.id, 'calls');
529
+ if (added) queue.push(target.id);
530
+ }
531
+ if (chunk.name && chunk.name !== 'anonymous') {
532
+ for (const caller of _subgraphSort(classifyCallers(db, chunk.name).high.map(h => h.chunk))) {
533
+ const added = tryAdd(caller, d);
534
+ if (nodes.has(caller.id)) addEdge(caller.id, chunk.id, 'calls');
535
+ if (added) queue.push(caller.id);
536
+ }
537
+ for (const ref of _subgraphSort(db.findReferers(chunk.name))) {
538
+ const added = tryAdd(ref, d);
539
+ if (nodes.has(ref.id)) addEdge(ref.id, chunk.id, 'references');
540
+ if (added) queue.push(ref.id);
541
+ }
542
+ }
543
+ }
544
+
545
+ const nodeList = [...nodes.values()].map(({ chunk: c, depth }) => ({
546
+ id: c.id, name: c.name, node_type: c.node_type, class_context: c.class_context || null,
547
+ file_path: c.file_path, start_line: c.start_line, end_line: c.end_line,
548
+ signature: extractSignatureLine(c.code_snippet).split('\n')[0].trim().slice(0, 120), depth,
549
+ }));
550
+ return { seed, found: true, truncated, nodes: nodeList, edges };
551
+ }