graph-indexer 2.1.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +312 -1
  2. package/README.md +236 -312
  3. package/SECURITY.md +53 -58
  4. package/bin/graph-indexer.mjs +251 -0
  5. package/integrations/claude-code/.claude-plugin/plugin.json +9 -0
  6. package/integrations/claude-code/.mcp.json +8 -0
  7. package/integrations/claude-code/agents/code-structure.md +22 -0
  8. package/integrations/claude-code/hooks/gi-hook.mjs +103 -0
  9. package/integrations/claude-code/hooks/hooks.json +16 -0
  10. package/integrations/opencode/graph-indexer.js +129 -0
  11. package/package.json +32 -109
  12. package/src/cli/helper.mjs +67 -0
  13. package/src/cli/hook.mjs +366 -0
  14. package/src/cli/init.mjs +272 -0
  15. package/src/cli/resident.mjs +185 -0
  16. package/src/index/discover.mjs +128 -0
  17. package/src/index/fingerprint.mjs +30 -0
  18. package/src/index/graph.mjs +61 -0
  19. package/src/index/indexer.mjs +476 -0
  20. package/src/index/modules.mjs +332 -0
  21. package/src/index/resolver.mjs +961 -0
  22. package/src/mcp/render.mjs +60 -0
  23. package/src/mcp/server.mjs +147 -0
  24. package/src/mcp/tools.mjs +974 -0
  25. package/src/parse/arity.mjs +153 -0
  26. package/src/parse/extract.mjs +1038 -0
  27. package/src/parse/lang/csharp.mjs +106 -0
  28. package/src/parse/lang/ecma.mjs +438 -0
  29. package/src/parse/lang/go.mjs +194 -0
  30. package/src/parse/lang/jvm.mjs +118 -0
  31. package/src/parse/lang/kotlin.mjs +97 -0
  32. package/src/parse/lang/misc.mjs +115 -0
  33. package/src/parse/lang/native.mjs +126 -0
  34. package/src/parse/lang/python.mjs +153 -0
  35. package/src/parse/lang/rust.mjs +157 -0
  36. package/src/parse/lang/scripting.mjs +219 -0
  37. package/src/parse/languages.mjs +48 -0
  38. package/src/parse/runtime.mjs +56 -0
  39. package/src/query/check.mjs +345 -0
  40. package/src/query/facts.mjs +323 -0
  41. package/src/query/intel.mjs +683 -0
  42. package/src/query/read.mjs +81 -0
  43. package/src/query/testcmd.mjs +130 -0
  44. package/src/query/textsearch.mjs +178 -0
  45. package/src/search/search.mjs +210 -0
  46. package/src/search/tokenize.mjs +188 -0
  47. package/src/store/db.mjs +169 -0
  48. package/src/util/git.mjs +30 -0
  49. package/src/util/paths.mjs +63 -0
  50. package/src/util/regex.mjs +2 -0
  51. package/vendor/grammars/bash.wasm.gz +0 -0
  52. package/vendor/grammars/c.wasm.gz +0 -0
  53. package/vendor/grammars/c_sharp.wasm.gz +0 -0
  54. package/vendor/grammars/cpp.wasm.gz +0 -0
  55. package/vendor/grammars/css.wasm.gz +0 -0
  56. package/vendor/grammars/go.wasm.gz +0 -0
  57. package/vendor/grammars/java.wasm.gz +0 -0
  58. package/vendor/grammars/javascript.wasm.gz +0 -0
  59. package/vendor/grammars/kotlin.wasm.gz +0 -0
  60. package/vendor/grammars/manifest.json +148 -0
  61. package/vendor/grammars/php.wasm.gz +0 -0
  62. package/vendor/grammars/python.wasm.gz +0 -0
  63. package/vendor/grammars/ruby.wasm.gz +0 -0
  64. package/vendor/grammars/rust.wasm.gz +0 -0
  65. package/vendor/grammars/scala.wasm.gz +0 -0
  66. package/vendor/grammars/tsx.wasm.gz +0 -0
  67. package/vendor/grammars/typescript.wasm.gz +0 -0
  68. package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
  69. package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  70. package/cli-ui.mjs +0 -78
  71. package/colbert.mjs +0 -168
  72. package/config.mjs +0 -494
  73. package/daemon-ctl.mjs +0 -175
  74. package/daemon-lock.mjs +0 -69
  75. package/embedders/python/mlx_embed_server.py +0 -61
  76. package/embedders/python/requirements-mlx.txt +0 -5
  77. package/embedders/setup-mlx.mjs +0 -110
  78. package/embeddings.mjs +0 -571
  79. package/engine/binary.mjs +0 -437
  80. package/engine/memory.mjs +0 -1003
  81. package/engine/sqlite.mjs +0 -1136
  82. package/enrichment.mjs +0 -521
  83. package/git-signals.mjs +0 -248
  84. package/indexer.mjs +0 -546
  85. package/init.mjs +0 -1809
  86. package/layout.mjs +0 -123
  87. package/mcp/centrality.mjs +0 -117
  88. package/mcp/format.mjs +0 -61
  89. package/mcp/resolver.mjs +0 -116
  90. package/mcp/symbolgraph.mjs +0 -137
  91. package/mcp/taint.mjs +0 -225
  92. package/mcp/tools.mjs +0 -1582
  93. package/mcp/topology.mjs +0 -676
  94. package/mcp-server.mjs +0 -197
  95. package/parse/extractor.mjs +0 -628
  96. package/parse/imports.mjs +0 -365
  97. package/parse/interprocedural.mjs +0 -134
  98. package/parse/languages.mjs +0 -253
  99. package/parse/metadata.mjs +0 -701
  100. package/parse/routes.mjs +0 -327
  101. package/parse/scip.mjs +0 -391
  102. package/parse/taint-patterns.mjs +0 -119
  103. package/prompts/CORE.md +0 -91
  104. package/prompts/DOMAIN_TEMPLATE.md +0 -46
  105. package/prompts/INTEGRATION.md +0 -123
  106. package/prompts/frameworks/ANDROID.md +0 -17
  107. package/prompts/frameworks/ASPNET_CORE.md +0 -19
  108. package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
  109. package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
  110. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
  111. package/prompts/frameworks/RAILS.md +0 -20
  112. package/prompts/frameworks/REACT.md +0 -20
  113. package/prompts/frameworks/SPRING_BOOT.md +0 -20
  114. package/prompts/languages/BASH.md +0 -22
  115. package/prompts/languages/C.md +0 -21
  116. package/prompts/languages/CSHARP.md +0 -24
  117. package/prompts/languages/CSS_SCSS.md +0 -22
  118. package/prompts/languages/GO.md +0 -24
  119. package/prompts/languages/JAVA.md +0 -23
  120. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
  121. package/prompts/languages/KOTLIN.md +0 -24
  122. package/prompts/languages/PHP.md +0 -23
  123. package/prompts/languages/PYTHON.md +0 -23
  124. package/prompts/languages/RUBY.md +0 -23
  125. package/prompts/languages/RUST.md +0 -21
  126. package/prompts/languages/SWIFT.md +0 -21
  127. package/seal.mjs +0 -400
  128. package/search-core.mjs +0 -777
  129. package/search-sparse.mjs +0 -183
  130. package/storage.mjs +0 -73
  131. package/watch-daemon.mjs +0 -289
package/mcp/topology.mjs DELETED
@@ -1,676 +0,0 @@
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
- // Precedence: intra-procedural receiver type → the opt-in inter-procedural
343
- // fixpoint result (recv_resolved_type, written at index time) → the 1-hop
344
- // query-time factory return-type fallback. The fixpoint resolves multi-hop /
345
- // unannotated factory chains the 1-hop path cannot.
346
- const toks = s.recv_type ? _typeMatchTokens(s.recv_type)
347
- : s.recv_resolved_type ? _typeMatchTokens(s.recv_resolved_type)
348
- : s.recv_via_call ? [...calleeReturnTokens(s.recv_via_call)] : null;
349
- const m = toks && toks.find(t => typeMatchSet.has(t));
350
- if (m) { typeMatch = m; break; }
351
- }
352
- }
353
-
354
- // `proven` (A1): the binding is UNAMBIGUOUS — a sole definition, or pinned by the
355
- // receiver's TYPE (typed receiver / A3 fixpoint / this-in-class / explicit target
356
- // class). The precise resolver lifts these to the `resolved` edge tier. Import- and
357
- // proximity-based reasons are likely-but-not-proven → stay `high`.
358
- let reason = '', proven = false;
359
- if (uniqueTarget && !classFiltered) { reason = 'sole definition'; proven = true; }
360
- else if (recvs.has('this') && callerClass && targetClasses.has(callerClass)) { reason = `this.${targetFunction}()`; proven = true; }
361
- else if (recvs.has('') && targetIsFreeFn) { reason = `${targetFunction}()`; proven = false; }
362
- else if (typeMatch) { reason = `${classDisplay.get(typeMatch) || typeMatch}.${targetFunction}()`; proven = true; }
363
- else if (deps.some(d => targetFiles.has(d))) { reason = 'imports definition'; proven = false; }
364
- else if (targetFiles.has(caller.file_path)) { reason = 'same file'; proven = false; }
365
- else if (tcl && [...recvs].some(r => r && r.toLowerCase() === tcl)) { reason = `${targetClass}.${targetFunction}()`; proven = true; }
366
-
367
- const recvHint = [...recvs].map(r =>
368
- r === '' ? `${targetFunction}()` : r === 'this' ? `this.${targetFunction}()` : `${r}.${targetFunction}()`
369
- ).join(', ');
370
-
371
- (reason ? high : nameOnly).push({ chunk: caller, reason, recvHint, proven: reason ? proven : false });
372
- }
373
-
374
- return { high, nameOnly, targetDefs, ambiguous, hasSiteData, classFiltered };
375
- }
376
-
377
- // ─── Symbol-level references (file→file topology, sharpened to symbol→symbol) ─────
378
-
379
- /**
380
- * Resolve *which symbols* reference a target symbol — not just which files. Fuses
381
- * the three reference kinds the index records and classifies each by confidence
382
- * using the same cheap, index-time signals as the call graph (no type inference):
383
- *
384
- * • calls — `findCallers` + classifyCallers (high / name-only blast radius);
385
- * • inherits — chunks whose `extends` names the symbol (subclasses / implementers);
386
- * • types — chunks whose `type_refs` names the symbol (params, returns, fields).
387
- *
388
- * For the non-call kinds, a referer is **high-confidence** when the target is the
389
- * sole definition, the referer's file imports a file that defines it, or it is
390
- * defined in the same file; otherwise it is **name-only** (a same-named symbol
391
- * may be meant). This is the symbol-granular "used by" that file-level topology
392
- * (getImportedBy) can only approximate.
393
- *
394
- * @returns {{ symbol:string, targetDefs:object[], ambiguous:boolean,
395
- * calls:ReturnType<typeof classifyCallers>,
396
- * inherits:Array, types:Array }}
397
- * inherits/types items are { chunk, confidence, reason }.
398
- */
399
- export function findReferences(db, symbol, { targetClass = null } = {}) {
400
- const calls = classifyCallers(db, symbol, { targetClass });
401
- const { targetDefs, ambiguous } = calls;
402
- const targetFiles = new Set(targetDefs.map(d => d.file_path));
403
- const uniqueTarget = targetDefs.length <= 1;
404
- const key = String(symbol).toLowerCase().trim();
405
-
406
- const inherits = [], types = [];
407
- for (const ref of db.findReferers(symbol)) {
408
- const deps = db.getDependencies(ref.file_path) || [];
409
- const imports = deps.some(d => targetFiles.has(d));
410
- const sameFile = targetFiles.has(ref.file_path);
411
- const reason = uniqueTarget ? 'sole definition'
412
- : imports ? 'imports definition'
413
- : sameFile ? 'same file' : '';
414
- const confidence = reason ? 'high' : 'name-only';
415
- // proven (A1): a sole definition is an unambiguous binding → eligible for `resolved`.
416
- const proven = uniqueTarget;
417
- if ((ref.extends || []).some(t => t.toLowerCase() === key)) inherits.push({ chunk: ref, confidence, reason, proven });
418
- if ((ref.type_refs || []).some(t => t.toLowerCase() === key)) types.push({ chunk: ref, confidence, reason, proven });
419
- }
420
- const order = (a, b) => (a.confidence === b.confidence
421
- ? a.chunk.file_path.localeCompare(b.chunk.file_path)
422
- : a.confidence === 'high' ? -1 : 1);
423
- inherits.sort(order); types.sort(order);
424
-
425
- // A2 v2: precise cross-file references from a locally-generated SCIP index (--resolver scip).
426
- // SCIP occurrences are kind-agnostic ("symbol used here"), so this is the binding-precise,
427
- // cross-file "used by" set: it both DISAMBIGUATES same-named symbols (each referer is attached
428
- // to the one definition SCIP bound it to) and RECOVERS references the AST/name heuristic above
429
- // misses entirely. Confidence is `resolved` — trustworthy, not a name guess. Empty (and so
430
- // byte-identical) on every index built without a SCIP resolver. Isolated artifact → parity-free.
431
- const references = [];
432
- if (db.hasScipRefs?.()) {
433
- const seen = new Set();
434
- for (const def of targetDefs) {
435
- for (const refId of db.getScipReferers(def.id)) {
436
- if (seen.has(refId)) continue;
437
- seen.add(refId);
438
- const chunk = db.getChunk(refId);
439
- if (chunk) references.push({ chunk, confidence: 'resolved', reason: 'scip' });
440
- }
441
- }
442
- references.sort((a, b) =>
443
- a.chunk.file_path.localeCompare(b.chunk.file_path)
444
- || ((a.chunk.start_line || 0) - (b.chunk.start_line || 0))
445
- || (a.chunk.id < b.chunk.id ? -1 : a.chunk.id > b.chunk.id ? 1 : 0));
446
- }
447
-
448
- return { symbol, targetDefs, ambiguous, calls, inherits, types, references };
449
- }
450
-
451
- // ─── HTTP route → handler resolution ──────────────────────────────────────────────
452
-
453
- /**
454
- * HTTP routes mapped to their handler chunks. Pure helper over the store contract
455
- * (`db.findRoutes`), so it is backend-agnostic and importable by tests/agent-trace.
456
- *
457
- * Filtering:
458
- * • method — optional HTTP verb, case-insensitive (GET/POST/…); omitted = all.
459
- * • path — a query starting with '/' is a PREFIX match (uses the backend's
460
- * indexed prefix filter); one containing '{' or ':' (a route-pattern
461
- * hint) — or any other non-'/' query — is a CONTAINS match.
462
- *
463
- * Each result inlines the handler chunk's id/name/node_type/start_line/end_line
464
- * (null when the handler isn't a chunk). Deterministically sorted (file_path, line,
465
- * method, path) so the in-memory and SQLite backends return byte-identical output.
466
- *
467
- * @returns {Array<{method,path,handler_name,handler_chunk_id,file_path,line,framework,
468
- * name,node_type,start_line,end_line,id}>}
469
- */
470
- export function findRoutes(db, { method = null, path: pathQuery = null } = {}) {
471
- const isPrefix = typeof pathQuery === 'string' && pathQuery.startsWith('/');
472
- const base = db.findRoutes({ method: method || null, pathPrefix: isPrefix ? pathQuery : null });
473
- const needle = (typeof pathQuery === 'string' && pathQuery && !isPrefix) ? pathQuery.toLowerCase() : null;
474
- const rows = (needle ? base.filter(r => String(r.path || '').toLowerCase().includes(needle)) : base)
475
- .map(r => {
476
- const c = r.chunk || null;
477
- return {
478
- method: r.method, path: r.path, handler_name: r.handler_name,
479
- handler_chunk_id: r.handler_chunk_id, file_path: r.file_path,
480
- line: r.line, framework: r.framework,
481
- name: c?.name ?? null, node_type: c?.node_type ?? null,
482
- start_line: c?.start_line ?? null, end_line: c?.end_line ?? null,
483
- id: c?.id ?? null,
484
- };
485
- });
486
- rows.sort((a, b) =>
487
- (a.file_path < b.file_path ? -1 : a.file_path > b.file_path ? 1 : 0)
488
- || ((a.line || 0) - (b.line || 0))
489
- || (a.method < b.method ? -1 : a.method > b.method ? 1 : 0)
490
- || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
491
- return rows;
492
- }
493
-
494
- // ─── Bounded connected-subgraph traversal (multi-hop in one call) ─────────────────
495
-
496
- /** ~tokens for a node card (1 token ≈ 4 chars). */
497
- function _subgraphCardTokens(c) {
498
- 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);
499
- }
500
-
501
- /** Stable order so the subgraph is byte-identical across backends and runs. */
502
- function _subgraphSort(arr) {
503
- return arr.slice().sort((a, b) =>
504
- (a.file_path < b.file_path ? -1 : a.file_path > b.file_path ? 1 : 0)
505
- || (a.start_line - b.start_line)
506
- || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
507
- }
508
-
509
- /**
510
- * Breadth-first connected subgraph around a seed symbol — callees (what it calls),
511
- * high-confidence callers (the precise blast radius, via classifyCallers), and
512
- * type/inheritance referers — bounded by node count, hop depth AND a token budget.
513
- * One call replaces the search_code → get_call_graph → find_references round-trips a
514
- * "trace this flow across files" task otherwise needs. Reuses only index-time signals
515
- * (no type inference); fully deterministic (every neighbour list is sorted, ties broken
516
- * on id) so it is reproducible and backend-agnostic.
517
- *
518
- * @returns {{ seed:string, found:boolean, truncated:boolean,
519
- * nodes:Array<{id,name,node_type,class_context,file_path,start_line,end_line,signature,depth}>,
520
- * edges:Array<{from:string,to:string,kind:'calls'|'references'}> }}
521
- */
522
- export function buildSubgraph(db, seed, { maxNodes = 12, maxDepth = 2, tokenBudget = null } = {}) {
523
- const seedDefs = db.resolveSymbol(seed);
524
- if (seedDefs.length === 0) return { seed, found: false, truncated: false, nodes: [], edges: [] };
525
-
526
- const nodes = new Map(); // id → { chunk, depth }
527
- const edges = [];
528
- const edgeSeen = new Set();
529
- let budget = (tokenBudget != null && tokenBudget > 0) ? tokenBudget : Infinity;
530
- let truncated = false;
531
-
532
- const addEdge = (from, to, kind) => {
533
- if (!from || !to || from === to) return;
534
- const k = `${from} ${to} ${kind}`;
535
- if (!edgeSeen.has(k)) { edgeSeen.add(k); edges.push({ from, to, kind }); }
536
- };
537
- const tryAdd = (c, depth) => {
538
- if (!c) return false;
539
- if (nodes.has(c.id)) return false;
540
- if (nodes.size >= maxNodes) { truncated = true; return false; }
541
- const t = _subgraphCardTokens(c);
542
- if (nodes.size > 0 && t > budget) { truncated = true; return false; }
543
- nodes.set(c.id, { chunk: c, depth });
544
- budget -= t;
545
- return true;
546
- };
547
-
548
- const queue = [];
549
- for (const d of _subgraphSort(seedDefs)) if (tryAdd(d, 0)) queue.push(d.id);
550
-
551
- for (let head = 0; head < queue.length; head++) {
552
- const entry = nodes.get(queue[head]);
553
- if (!entry || entry.depth >= maxDepth) continue;
554
- const chunk = entry.chunk;
555
- const d = entry.depth + 1;
556
-
557
- for (const name of [...new Set(chunk.calls || [])].sort()) {
558
- const defs = db.resolveSymbol(name);
559
- if (!defs.length) continue;
560
- const target = _subgraphSort(defs)[0];
561
- const added = tryAdd(target, d);
562
- if (nodes.has(target.id)) addEdge(chunk.id, target.id, 'calls');
563
- if (added) queue.push(target.id);
564
- }
565
- if (chunk.name && chunk.name !== 'anonymous') {
566
- for (const caller of _subgraphSort(classifyCallers(db, chunk.name).high.map(h => h.chunk))) {
567
- const added = tryAdd(caller, d);
568
- if (nodes.has(caller.id)) addEdge(caller.id, chunk.id, 'calls');
569
- if (added) queue.push(caller.id);
570
- }
571
- for (const ref of _subgraphSort(db.findReferers(chunk.name))) {
572
- const added = tryAdd(ref, d);
573
- if (nodes.has(ref.id)) addEdge(ref.id, chunk.id, 'references');
574
- if (added) queue.push(ref.id);
575
- }
576
- }
577
- }
578
-
579
- const nodeList = [...nodes.values()].map(({ chunk: c, depth }) => ({
580
- id: c.id, name: c.name, node_type: c.node_type, class_context: c.class_context || null,
581
- file_path: c.file_path, start_line: c.start_line, end_line: c.end_line,
582
- signature: extractSignatureLine(c.code_snippet).split('\n')[0].trim().slice(0, 120), depth,
583
- }));
584
- return { seed, found: true, truncated, nodes: nodeList, edges };
585
- }
586
-
587
- /**
588
- * Reverse-direction transitive blast radius (C4): from a set of seed chunks (the symbols /
589
- * files about to change), the transitively-affected REFERRERS — callers, subclasses, and type
590
- * users — bounded by hop depth and node count.
591
- *
592
- * Uses the persistent symbol graph (getEdges) when present — precise and chunk-level — and
593
- * otherwise falls back to query-time classifyCallers / findReferences. HIGH-confidence
594
- * referrers drive the transitive closure (a precise blast radius that does not explode on
595
- * ambiguous names); the DIRECT name_only referrers of the seed are surfaced separately for the
596
- * agent to verify. Fully deterministic (every list is sorted: depth, file, line, id).
597
- *
598
- * @param {object} db
599
- * @param {object[]} seedChunks The chunks being changed.
600
- * @param {object} [opts]
601
- * @param {number} [opts.maxDepth] Transitive hops to follow (default 3).
602
- * @param {number} [opts.maxNodes] Cap on impacted chunks (default 200).
603
- * @returns {{ impacted:Array<{chunk,depth,kind}>, ambiguous:Array<{chunk,kind}>,
604
- * truncated:boolean, usedGraph:boolean }}
605
- */
606
- export function buildImpact(db, seedChunks, { maxDepth = 3, maxNodes = 200, precision = 'standard' } = {}) {
607
- const hasGraph = typeof db.hasSymbolGraph === 'function' && db.hasSymbolGraph();
608
- const seedIds = new Set(seedChunks.map(c => c.id));
609
-
610
- // A1 precision dial. `standard` follows the high-confidence closure (resolved + high);
611
- // `strict` follows ONLY provably-unambiguous bindings (the `resolved` edge tier, or — with
612
- // no graph — the `proven` query-time callers) for a false-positive-free blast radius.
613
- const strict = precision === 'strict';
614
- const keepEdge = strict ? (c) => c === 'resolved' : (c) => c === 'high' || c === 'resolved';
615
- const keepRef = (proven) => !strict || Boolean(proven);
616
-
617
- const highReferrers = (chunk) => {
618
- const out = [];
619
- if (hasGraph) {
620
- for (const e of db.getEdges(chunk.id, { direction: 'in' }))
621
- if (e.chunk && keepEdge(e.confidence)) out.push({ chunk: e.chunk, kind: e.kind });
622
- return out;
623
- }
624
- if (!chunk.name || chunk.name === 'anonymous') return out;
625
- const tcl = chunk.class_context || null;
626
- const seen = new Set();
627
- for (const h of classifyCallers(db, chunk.name, { targetClass: tcl }).high)
628
- if (keepRef(h.proven) && !seen.has(h.chunk.id)) { seen.add(h.chunk.id); out.push({ chunk: h.chunk, kind: 'calls' }); }
629
- const refs = findReferences(db, chunk.name, { targetClass: tcl });
630
- for (const r of refs.inherits) if (r.confidence === 'high' && keepRef(r.proven) && !seen.has(r.chunk.id)) { seen.add(r.chunk.id); out.push({ chunk: r.chunk, kind: 'extends' }); }
631
- for (const r of refs.types) if (r.confidence === 'high' && keepRef(r.proven) && !seen.has(r.chunk.id)) { seen.add(r.chunk.id); out.push({ chunk: r.chunk, kind: 'type' }); }
632
- return out;
633
- };
634
- const nameOnlyReferrers = (chunk) => {
635
- const out = [];
636
- if (hasGraph) {
637
- for (const e of db.getEdges(chunk.id, { direction: 'in' }))
638
- if (e.chunk && e.confidence === 'name_only') out.push({ chunk: e.chunk, kind: e.kind });
639
- return out;
640
- }
641
- if (!chunk.name || chunk.name === 'anonymous') return out;
642
- for (const n of classifyCallers(db, chunk.name, { targetClass: chunk.class_context || null }).nameOnly)
643
- out.push({ chunk: n.chunk, kind: 'calls' });
644
- return out;
645
- };
646
-
647
- const ambiguous = new Map();
648
- for (const s of seedChunks) for (const r of nameOnlyReferrers(s))
649
- if (!seedIds.has(r.chunk.id) && !ambiguous.has(r.chunk.id)) ambiguous.set(r.chunk.id, r);
650
-
651
- const impacted = new Map();
652
- const visited = new Set(seedIds);
653
- let truncated = false;
654
- let frontier = seedChunks.map(c => ({ chunk: c }));
655
- for (let depth = 1; depth <= maxDepth && !truncated; depth++) {
656
- const next = [];
657
- for (const { chunk } of frontier) {
658
- for (const r of highReferrers(chunk)) {
659
- if (visited.has(r.chunk.id)) continue;
660
- if (impacted.size >= maxNodes) { truncated = true; break; }
661
- visited.add(r.chunk.id);
662
- impacted.set(r.chunk.id, { chunk: r.chunk, depth, kind: r.kind });
663
- next.push({ chunk: r.chunk });
664
- }
665
- if (truncated) break;
666
- }
667
- if (next.length === 0) break;
668
- frontier = next;
669
- }
670
-
671
- const byPlace = (a, b) => (a.chunk.file_path < b.chunk.file_path ? -1 : a.chunk.file_path > b.chunk.file_path ? 1 : 0)
672
- || (a.chunk.start_line - b.chunk.start_line) || (a.chunk.id < b.chunk.id ? -1 : 1);
673
- const impactedList = [...impacted.values()].sort((a, b) => (a.depth - b.depth) || byPlace(a, b));
674
- const ambiguousList = [...ambiguous.values()].sort(byPlace);
675
- return { impacted: impactedList, ambiguous: ambiguousList, truncated, usedGraph: hasGraph, precision };
676
- }