graph-indexer 1.2.0 → 2.1.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 (70) hide show
  1. package/CHANGELOG.md +854 -0
  2. package/README.md +258 -595
  3. package/SECURITY.md +85 -0
  4. package/TRADEMARK.md +31 -0
  5. package/cli-ui.mjs +78 -0
  6. package/colbert.mjs +168 -0
  7. package/config.mjs +408 -32
  8. package/daemon-ctl.mjs +175 -0
  9. package/daemon-lock.mjs +69 -0
  10. package/embedders/python/mlx_embed_server.py +61 -0
  11. package/embedders/python/requirements-mlx.txt +5 -0
  12. package/embedders/setup-mlx.mjs +110 -0
  13. package/embeddings.mjs +571 -0
  14. package/engine/binary.mjs +437 -0
  15. package/{core-engine.mjs → engine/memory.mjs} +321 -441
  16. package/{sqlite-store.mjs → engine/sqlite.mjs} +413 -45
  17. package/enrichment.mjs +206 -32
  18. package/git-signals.mjs +248 -0
  19. package/indexer.mjs +399 -45
  20. package/init.mjs +1626 -171
  21. package/layout.mjs +123 -0
  22. package/mcp/centrality.mjs +117 -0
  23. package/mcp/format.mjs +61 -0
  24. package/mcp/resolver.mjs +116 -0
  25. package/mcp/symbolgraph.mjs +137 -0
  26. package/mcp/taint.mjs +225 -0
  27. package/mcp/tools.mjs +1582 -0
  28. package/mcp/topology.mjs +676 -0
  29. package/mcp-server.mjs +80 -25
  30. package/package.json +66 -27
  31. package/parse/extractor.mjs +628 -0
  32. package/parse/imports.mjs +365 -0
  33. package/parse/interprocedural.mjs +134 -0
  34. package/parse/languages.mjs +253 -0
  35. package/parse/metadata.mjs +701 -0
  36. package/parse/routes.mjs +327 -0
  37. package/parse/scip.mjs +391 -0
  38. package/parse/taint-patterns.mjs +119 -0
  39. package/prompts/CORE.md +91 -0
  40. package/prompts/DOMAIN_TEMPLATE.md +46 -0
  41. package/prompts/INTEGRATION.md +123 -0
  42. package/prompts/frameworks/ANDROID.md +17 -0
  43. package/prompts/frameworks/ASPNET_CORE.md +19 -0
  44. package/prompts/frameworks/FASTAPI_DJANGO.md +20 -0
  45. package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
  46. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +19 -0
  47. package/prompts/frameworks/RAILS.md +20 -0
  48. package/prompts/frameworks/REACT.md +20 -0
  49. package/prompts/frameworks/SPRING_BOOT.md +20 -0
  50. package/prompts/languages/BASH.md +22 -0
  51. package/prompts/languages/C.md +21 -0
  52. package/prompts/languages/CSHARP.md +24 -0
  53. package/prompts/languages/CSS_SCSS.md +22 -0
  54. package/prompts/languages/GO.md +24 -0
  55. package/prompts/languages/JAVA.md +23 -0
  56. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
  57. package/prompts/languages/KOTLIN.md +24 -0
  58. package/prompts/languages/PHP.md +23 -0
  59. package/prompts/languages/PYTHON.md +23 -0
  60. package/prompts/languages/RUBY.md +23 -0
  61. package/prompts/languages/RUST.md +21 -0
  62. package/prompts/languages/SWIFT.md +21 -0
  63. package/seal.mjs +400 -0
  64. package/search-core.mjs +405 -44
  65. package/search-sparse.mjs +183 -0
  66. package/storage.mjs +33 -3
  67. package/watch-daemon.mjs +75 -24
  68. package/PROMPT.md +0 -182
  69. package/mcp-tools.mjs +0 -545
  70. package/parser-utils.mjs +0 -1170
@@ -0,0 +1,365 @@
1
+ /**
2
+ * @file parse/imports.mjs
3
+ * @description Import resolution, barrel export expansion, embedding payload
4
+ * builders, and legacy Ollama embedding helpers.
5
+ * @author MaquinaTech <https://github.com/MaquinaTech>
6
+ * @copyright (c) 2026 MaquinaTech. All rights reserved.
7
+ * @license MIT
8
+ */
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { getParserForFile, EXTENSIONS } from './languages.mjs';
12
+ import { truncateForEmbedding } from '../search-core.mjs';
13
+
14
+ // JS-family import-specifier extensions. Under TS node16/nodenext these appear in
15
+ // specifiers (`./x.js`) while the source on disk is `.ts`/`.tsx` — so when the literal
16
+ // path is missing we strip one of these and re-probe the real source extensions.
17
+ const JS_FAMILY_EXT = new Set(['.js', '.jsx', '.mjs', '.cjs']);
18
+
19
+ // ─── Ollama host / model resolution ──────────────────────────────────────────
20
+ // Priority: caller override → OLLAMA_HOST env var → PROJECT .graph-indexer.json
21
+ // (MCP_PROJECT_ROOT or cwd — NOT the package directory: when graph-indexer is
22
+ // installed as a dependency, the user's config lives in their project root) →
23
+ // default. Mirrors config.mjs precedence so every entry point agrees.
24
+ //
25
+ // OLLAMA_HOST in the shell is Ollama's binding address (e.g. "0.0.0.0:11434"), not an
26
+ // HTTP client URL. We normalise bare "host:port" strings by adding http:// and translating
27
+ // 0.0.0.0 → localhost so fetches work in both formats.
28
+
29
+ let _cachedHost = null;
30
+ let _cachedEmbedModel = null;
31
+ function _normalizeOllamaHost(raw) {
32
+ if (!raw) return null;
33
+ if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
34
+ return 'http://' + raw.replace(/^0\.0\.0\.0/, 'localhost');
35
+ }
36
+ function _readProjectConfig() {
37
+ const root = process.env.MCP_PROJECT_ROOT || process.cwd();
38
+ try {
39
+ return JSON.parse(fs.readFileSync(path.join(root, '.graph-indexer.json'), 'utf8'));
40
+ } catch { return null; }
41
+ }
42
+ function _resolveOllamaHost(override) {
43
+ if (override) return _normalizeOllamaHost(override) || 'http://localhost:11434';
44
+ if (_cachedHost) return _cachedHost;
45
+ if (process.env.OLLAMA_HOST) {
46
+ _cachedHost = _normalizeOllamaHost(process.env.OLLAMA_HOST) || 'http://localhost:11434';
47
+ return _cachedHost;
48
+ }
49
+ const cfg = _readProjectConfig();
50
+ _cachedHost = _normalizeOllamaHost(cfg?.ollamaHost) || 'http://localhost:11434';
51
+ return _cachedHost;
52
+ }
53
+ function _resolveEmbedModel(override) {
54
+ if (override) return override;
55
+ if (_cachedEmbedModel) return _cachedEmbedModel;
56
+ const cfg = _readProjectConfig();
57
+ _cachedEmbedModel = cfg?.embedModel || 'nomic-embed-text';
58
+ return _cachedEmbedModel;
59
+ }
60
+
61
+ // ─── Barrel export resolution ─────────────────────────────────────────────────
62
+
63
+ // Module-level cache: barrelAbsPath → Map<exportedName, sourceRelPath>
64
+ const _barrelCache = new Map();
65
+
66
+ /**
67
+ * Parses a barrel file (index.ts / index.js) and returns a map of
68
+ * exportedName → sourceFilePath (relative to projectRoot).
69
+ * e.g. { useAuthStore: 'src/stores/authStore.ts' }
70
+ */
71
+ export function resolveBarrelExports(barrelAbsPath, projectRoot) {
72
+ if (_barrelCache.has(barrelAbsPath)) return _barrelCache.get(barrelAbsPath);
73
+
74
+ const result = new Map();
75
+ _barrelCache.set(barrelAbsPath, result);
76
+
77
+ const ext = path.extname(barrelAbsPath);
78
+ if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext)) return result;
79
+
80
+ let content;
81
+ try { content = fs.readFileSync(barrelAbsPath, 'utf-8'); } catch { return result; }
82
+
83
+ const parser = getParserForFile(ext);
84
+ if (!parser) return result;
85
+
86
+ let tree;
87
+ try { tree = parser.parse((offset) => offset < content.length ? content.slice(offset, offset + 4096) : null); } catch { return result; }
88
+
89
+ const barrelDir = path.dirname(barrelAbsPath);
90
+
91
+ function walk(node) {
92
+ if (node.type === 'export_statement') {
93
+ const fromNode = node.children.find(c => c.type === 'string');
94
+ if (!fromNode) { node.children.forEach(walk); return; }
95
+
96
+ const rawSource = fromNode.text.replace(/['"]/g, '');
97
+ if (!rawSource.startsWith('.')) { node.children.forEach(walk); return; }
98
+
99
+ const absSource = path.resolve(barrelDir, rawSource);
100
+ let finalAbs = null;
101
+ if (EXTENSIONS.has(path.extname(absSource)) && fs.existsSync(absSource)) {
102
+ finalAbs = absSource;
103
+ } else {
104
+ for (const e of EXTENSIONS) {
105
+ if (fs.existsSync(absSource + e)) { finalAbs = absSource + e; break; }
106
+ const idx = path.join(absSource, 'index' + e);
107
+ if (fs.existsSync(idx)) { finalAbs = idx; break; }
108
+ }
109
+ }
110
+ if (!finalAbs) { node.children.forEach(walk); return; }
111
+
112
+ const relSource = path.relative(projectRoot, finalAbs).replace(/\\/g, '/');
113
+
114
+ const namedExports = node.children.find(c => c.type === 'named_imports' || c.type === 'export_clause');
115
+ if (namedExports) {
116
+ for (const child of namedExports.children) {
117
+ if (child.type === 'import_specifier' || child.type === 'export_specifier') {
118
+ const names = child.children.filter(c => c.type === 'identifier');
119
+ if (names.length > 0) {
120
+ result.set(names[names.length - 1].text, relSource);
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ const starNode = node.children.find(c => c.text === '*');
127
+ if (starNode) {
128
+ result.set('*', relSource);
129
+ }
130
+ }
131
+ node.children.forEach(walk);
132
+ }
133
+ walk(tree.rootNode);
134
+ return result;
135
+ }
136
+
137
+ // ─── Go module-name cache (reads go.mod once per project root) ──────────────
138
+ const _goModCache = new Map();
139
+ function _readGoModuleName(projectRoot) {
140
+ if (_goModCache.has(projectRoot)) return _goModCache.get(projectRoot);
141
+ const modFile = path.join(projectRoot, 'go.mod');
142
+ let name = null;
143
+ if (fs.existsSync(modFile)) {
144
+ try {
145
+ const first = fs.readFileSync(modFile, 'utf-8').split('\n').find(l => l.trimStart().startsWith('module '));
146
+ if (first) name = first.trim().replace(/^module\s+/, '').split(/\s/)[0];
147
+ } catch { /* ignore */ }
148
+ }
149
+ _goModCache.set(projectRoot, name);
150
+ return name;
151
+ }
152
+
153
+ export function resolveLocalImports(rawImports, fromFileRelPath, projectRoot) {
154
+ const fileDir = path.dirname(path.join(projectRoot, fromFileRelPath));
155
+ const ext = path.extname(fromFileRelPath);
156
+ const resolved = [];
157
+ for (const raw of rawImports) {
158
+ if (raw.startsWith('.')) {
159
+ const absResolved = path.resolve(fileDir, raw);
160
+ const existingExt = path.extname(absResolved);
161
+ let finalAbs = null;
162
+ if (existingExt && EXTENSIONS.has(existingExt) && fs.existsSync(absResolved)) {
163
+ finalAbs = absResolved;
164
+ } else {
165
+ // TypeScript ESM convention (node16/nodenext moduleResolution): the import
166
+ // specifier carries a JS-family extension (`./Fade.js`) but the source file on
167
+ // disk is `.ts`/`.tsx`. The literal path doesn't exist, so strip that extension
168
+ // and re-probe the candidate source extensions — otherwise EVERY relative import
169
+ // in such a project drops and the file gets empty Deps:/Used by: topology.
170
+ const probeBase = (existingExt && JS_FAMILY_EXT.has(existingExt))
171
+ ? absResolved.slice(0, -existingExt.length)
172
+ : absResolved;
173
+ for (const e of EXTENSIONS) {
174
+ if (fs.existsSync(probeBase + e)) { finalAbs = probeBase + e; break; }
175
+ const idx = path.join(probeBase, 'index' + e);
176
+ if (fs.existsSync(idx)) { finalAbs = idx; break; }
177
+ // Python: also try __init__.py for package directories
178
+ if (e === '.py') {
179
+ const init = path.join(probeBase, '__init__.py');
180
+ if (fs.existsSync(init)) { finalAbs = init; break; }
181
+ }
182
+ }
183
+ }
184
+ if (finalAbs) {
185
+ const relPath = path.relative(projectRoot, finalAbs).replace(/\\/g, '/');
186
+ const baseName = path.basename(finalAbs, path.extname(finalAbs));
187
+ if (baseName === 'index' && ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(path.extname(finalAbs))) {
188
+ const barrelMap = resolveBarrelExports(finalAbs, projectRoot);
189
+ if (barrelMap.size > 0) {
190
+ const sources = new Set(barrelMap.values());
191
+ for (const src of sources) {
192
+ if (!resolved.includes(src)) resolved.push(src);
193
+ }
194
+ } else {
195
+ if (!resolved.includes(relPath)) resolved.push(relPath);
196
+ }
197
+ } else {
198
+ if (!resolved.includes(relPath)) resolved.push(relPath);
199
+ }
200
+ }
201
+ }
202
+ // ── Go intra-module imports: module-prefix paths mapped to local dirs ──
203
+ else if (ext === '.go') {
204
+ const modName = _readGoModuleName(projectRoot);
205
+ if (modName && raw.startsWith(modName + '/')) {
206
+ const subPkg = raw.slice(modName.length + 1);
207
+ const absDir = path.join(projectRoot, subPkg);
208
+ if (fs.existsSync(absDir)) {
209
+ try {
210
+ const goFiles = fs.readdirSync(absDir)
211
+ .filter(f => f.endsWith('.go') && !f.includes('_test'))
212
+ .slice(0, 5); // cap to avoid fanning out too many deps per package
213
+ for (const gof of goFiles) {
214
+ const rel = path.relative(projectRoot, path.join(absDir, gof)).replace(/\\/g, '/');
215
+ if (!resolved.includes(rel)) resolved.push(rel);
216
+ }
217
+ } catch { /* directory unreadable — skip */ }
218
+ }
219
+ }
220
+ }
221
+ // ── Rust crate-local: crate::module::item → src/module.rs ──────────
222
+ else if (ext === '.rs' && raw.startsWith('crate::')) {
223
+ const parts = raw.slice('crate::'.length).split('::').filter(Boolean);
224
+ for (let depth = parts.length; depth >= 1; depth--) {
225
+ const subPath = parts.slice(0, depth).join('/');
226
+ const candidates = [
227
+ path.join(projectRoot, 'src', subPath + '.rs'),
228
+ path.join(projectRoot, 'src', subPath, 'mod.rs'),
229
+ ];
230
+ let found = false;
231
+ for (const c of candidates) {
232
+ if (fs.existsSync(c)) {
233
+ resolved.push(path.relative(projectRoot, c).replace(/\\/g, '/'));
234
+ found = true; break;
235
+ }
236
+ }
237
+ if (found) break;
238
+ }
239
+ }
240
+ // ── C / Bash non-dotted relative includes ──────────────────────────
241
+ // C `#include "net/socket.h"` and Bash `source lib/util.sh` are resolved
242
+ // relative to the including file's directory, then to the project root.
243
+ else if ((ext === '.c' || ext === '.h' || ext === '.sh' || ext === '.bash')) {
244
+ for (const abs of [path.resolve(fileDir, raw), path.join(projectRoot, raw)]) {
245
+ if (EXTENSIONS.has(path.extname(abs)) && fs.existsSync(abs)) {
246
+ const rel = path.relative(projectRoot, abs).replace(/\\/g, '/');
247
+ if (!rel.startsWith('..') && !resolved.includes(rel)) resolved.push(rel);
248
+ break;
249
+ }
250
+ }
251
+ }
252
+ }
253
+ return resolved;
254
+ }
255
+
256
+ /**
257
+ * Build the text payload sent to the embedding model for a chunk.
258
+ *
259
+ * Shared by the bootstrap indexer (indexer.mjs) and the watch daemon
260
+ * (watch-daemon.mjs) so a chunk yields the SAME embedding regardless of which
261
+ * path embedded it first. This matters because the cache key (content_hash) is
262
+ * derived from code + docstring only — it does NOT include this payload — so two
263
+ * divergent payloads for the same hash would silently produce inconsistent
264
+ * embeddings across a full re-index vs. an incremental update.
265
+ *
266
+ * @param {object} chunk Semantic chunk: { file_path, node_type, name, docstring, type_refs, code_snippet }.
267
+ * @param {string[]} depRelPaths Resolved local imports of the chunk's file (project-relative paths).
268
+ * @returns {string}
269
+ */
270
+ export function buildEmbeddingPayload(chunk, depRelPaths = [], bodyOverride = null) {
271
+ const neighbors = depRelPaths
272
+ .map(d => path.basename(d, path.extname(d)))
273
+ .filter(Boolean);
274
+ const topologicalContext = neighbors.length
275
+ ? `This code architectural neighborhood connects with: ${neighbors.join(', ')}.`
276
+ : '';
277
+ // Decorators and inheritance edges are NOT included here (A/B-tested: neutral
278
+ // on vector recall, regression on BM25 — kept as metadata only).
279
+ // LLM summary leads: declarative voice aligns with nomic's search_document training
280
+ // objective. hyde/questions are excluded — stopword noise dilutes the code fingerprint.
281
+ // Concept keywords (chunk.hyde) go to BM25 only via buildLexicalDocument.
282
+ return [
283
+ chunk.summary || '',
284
+ `File Location: ${chunk.file_path}`,
285
+ `Symbol Name: ${chunk.node_type} -> ${chunk.name}`,
286
+ chunk.docstring ? `Developer Documentation: ${chunk.docstring}` : '',
287
+ chunk.type_refs?.length ? `Type References: ${chunk.type_refs.join(', ')}` : '',
288
+ topologicalContext,
289
+ `--- Source Code ---`,
290
+ // bodyOverride carries the full source for oversized chunks (>3000 chars); BM25
291
+ // keeps using code_snippet so lexical and dense channels stay independently capped.
292
+ bodyOverride || chunk.code_snippet,
293
+ ].filter(Boolean).join('\n');
294
+ }
295
+
296
+ /**
297
+ * The full source body of a chunk whose code_snippet was truncated by the 3000-char
298
+ * cap — sliced from the file content by line range — so the dense channel can window
299
+ * the WHOLE definition (the lexical/BM25 path stays capped, byte-identical). Returns
300
+ * null when the chunk is not truncated (snippet == body) or is a god-class skeleton
301
+ * (its methods are already their own chunks). Used at embed time by the indexer and
302
+ * the watch daemon, both of which hold the source content.
303
+ *
304
+ * @param {object} chunk A semantic chunk (needs code_snippet, start_line, end_line).
305
+ * @param {string} fileContent The chunk's full file source.
306
+ * @returns {string|null}
307
+ */
308
+ export function fullBodyForEmbedding(chunk, fileContent) {
309
+ if (!chunk || !fileContent) return null;
310
+ if ((chunk.code_snippet?.length || 0) < 3000) return null; // not truncated
311
+ if (chunk.code_snippet.includes('Large class:')) return null; // god-class skeleton
312
+ if (!chunk.start_line || !chunk.end_line) return null;
313
+ const body = fileContent.split('\n').slice(chunk.start_line - 1, chunk.end_line).join('\n');
314
+ return body.length > chunk.code_snippet.length ? body : null;
315
+ }
316
+
317
+ export async function getLocalEmbedding(text, graceful = true, { ollamaHost, model } = {}) {
318
+ if (process.env.INDEXER_EMBEDDINGS === 'off') return null; // lexical-only mode
319
+ const host = _resolveOllamaHost(ollamaHost);
320
+ const embedModel = _resolveEmbedModel(model);
321
+ const MAX_RETRIES = 3;
322
+ const safeText = "search_query: " + truncateForEmbedding(text);
323
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
324
+ try {
325
+ const res = await fetch(`${host}/api/embeddings`, {
326
+ method: "POST", headers: { "Content-Type": "application/json" },
327
+ body: JSON.stringify({ model: embedModel, prompt: safeText }),
328
+ signal: AbortSignal.timeout(15000),
329
+ });
330
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
331
+ const data = await res.json();
332
+ return data.embedding;
333
+ } catch (err) {
334
+ if (attempt < MAX_RETRIES) await new Promise(r => setTimeout(r, 500 * 2 ** attempt));
335
+ else if (!graceful) throw err;
336
+ }
337
+ }
338
+ return null;
339
+ }
340
+
341
+ export async function getLocalEmbeddingsBatch(texts, graceful = true, { ollamaHost, model } = {}) {
342
+ if (!texts || texts.length === 0) return [];
343
+ if (process.env.INDEXER_EMBEDDINGS === 'off') return null; // lexical-only mode
344
+ const host = _resolveOllamaHost(ollamaHost);
345
+ const embedModel = _resolveEmbedModel(model);
346
+ const MAX_RETRIES = 3;
347
+ const safeTexts = texts.map(t => "search_document: " + (t.length > 8000 ? t.slice(0, 8000) : t));
348
+
349
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
350
+ try {
351
+ const res = await fetch(`${host}/api/embed`, {
352
+ method: "POST", headers: { "Content-Type": "application/json" },
353
+ body: JSON.stringify({ model: embedModel, input: safeTexts }),
354
+ signal: AbortSignal.timeout(60000),
355
+ });
356
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
357
+ const data = await res.json();
358
+ return data.embeddings;
359
+ } catch (err) {
360
+ if (attempt < MAX_RETRIES) await new Promise(r => setTimeout(r, 500 * 2 ** attempt));
361
+ else if (!graceful) throw err;
362
+ }
363
+ }
364
+ return null;
365
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @file parse/interprocedural.mjs
3
+ * @description Bounded, deterministic INTER-procedural return-type fixpoint (opt-in).
4
+ *
5
+ * The intra-procedural inference in parse/metadata.mjs already resolves a
6
+ * call-site receiver one hop: `const r = makeRepo(); r.save()` records
7
+ * `recv_via_call = 'makeRepo'`, and classifyCallers reads makeRepo's recorded
8
+ * `return_type` at query time. That misses MULTI-hop factory chains and
9
+ * unannotated factories: `makeRepoIndirect()` returns `makeRepo()` returns
10
+ * `new OrderRepo()` — the indirect factory has no return type to read.
11
+ *
12
+ * This pass propagates return types along factory return-call edges with a
13
+ * monotone worklist fixpoint, so `makeRepoIndirect` resolves transitively to
14
+ * `OrderRepo`. It runs ONCE at index time over all chunks and writes the result
15
+ * into each call site as `recv_resolved_type`; because call_sites is serialized
16
+ * identically by both backends, the resolved data is parity-free. classifyCallers
17
+ * prefers `recv_resolved_type` and keeps the 1-hop `recv_via_call` fallback, so an
18
+ * index built without this pass (or a per-file daemon update) is never worse.
19
+ *
20
+ * Determinism: iteration is over SORTED symbol/edge keys and the lattice is
21
+ * monotone (each step only adds the single concrete type or transitions to an
22
+ * absorbing CONFLICT), so the result is independent of map insertion order — a
23
+ * hard requirement for memory↔sqlite parity.
24
+ * @author MaquinaTech <https://github.com/MaquinaTech>
25
+ * @copyright (c) 2026 MaquinaTech. All rights reserved.
26
+ * @license MIT
27
+ */
28
+
29
+ // Hard safety net against pathological/cyclic graphs. Each iteration propagates types one
30
+ // extra hop; real factory chains are 1–3 hops, so the `changed` flag normally converges well
31
+ // before this — the bound only guarantees termination, it is not the expected stop condition.
32
+ const MAX_ITERS = 8;
33
+
34
+ // Absorbing "two distinct concrete types" state — contributes nothing (conservative DROP,
35
+ // mirroring _inferLocalBindings' conflict handling in parse/metadata.mjs).
36
+ const CONFLICT = Symbol('conflict');
37
+
38
+ /**
39
+ * The single concrete type NAME a string denotes, or null when it is not exactly one
40
+ * type identifier (generics / unions / primitives are ambiguous → conservatively dropped).
41
+ * 'OrderRepo' → 'OrderRepo'; 'Promise<Order>' → null; 'number' → null.
42
+ */
43
+ function singleTypeName(str) {
44
+ if (!str) return null;
45
+ const ids = String(str).match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [];
46
+ const types = ids.filter(t => /^[A-Z]/.test(t)); // PascalCase = a type (matches _typeMatchTokens)
47
+ return types.length === 1 ? types[0] : null;
48
+ }
49
+
50
+ /**
51
+ * Resolve, per function symbol (lowercased name), the single concrete return TYPE it
52
+ * yields — directly (annotation or `return new X()`) or transitively through factory
53
+ * return-call edges. Symbols whose chain yields ≥2 distinct types (or stays unresolved)
54
+ * are omitted.
55
+ *
56
+ * @param {object[]} chunks Chunks carrying `name`, `return_type`, and the transient
57
+ * `_return_via` ({type}|{viaCall}) from extractReturnVia.
58
+ * @returns {Map<string,string>} nameLower → resolved type name.
59
+ */
60
+ export function resolveReturnTypes(chunks) {
61
+ const symRet = new Map(); // nameLower → typeName | CONFLICT
62
+ const edges = new Map(); // nameLower → Set<calleeLower> (only for symbols with no direct type)
63
+
64
+ const seed = (nm, type) => {
65
+ const prev = symRet.get(nm);
66
+ if (prev === CONFLICT) return;
67
+ if (prev === undefined) symRet.set(nm, type);
68
+ else if (prev !== type) symRet.set(nm, CONFLICT);
69
+ };
70
+
71
+ for (const c of chunks) {
72
+ if (!c.name || c.name === 'anonymous') continue;
73
+ const nm = c.name.toLowerCase();
74
+ const via = c._return_via;
75
+ const annot = singleTypeName(c.return_type);
76
+ // Direct type wins over the edge: annotation and/or `return new X()`.
77
+ let directType = annot || (via && via.type) || null;
78
+ if (annot && via && via.type && annot !== via.type) { symRet.set(nm, CONFLICT); directType = null; }
79
+ if (directType) {
80
+ seed(nm, directType);
81
+ } else if (via && via.viaCall) {
82
+ const callee = via.viaCall.toLowerCase();
83
+ if (!edges.has(nm)) edges.set(nm, new Set());
84
+ edges.get(nm).add(callee);
85
+ }
86
+ }
87
+
88
+ const sortedEdgeKeys = [...edges.keys()].sort();
89
+ for (let iter = 0; iter < MAX_ITERS; iter++) {
90
+ let changed = false;
91
+ for (const sym of sortedEdgeKeys) {
92
+ if (symRet.has(sym)) continue; // already resolved (or conflict)
93
+ let resolved = null, conflict = false;
94
+ for (const callee of [...edges.get(sym)].sort()) {
95
+ const ct = symRet.get(callee);
96
+ if (ct === undefined || ct === CONFLICT) continue;
97
+ if (resolved === null) resolved = ct;
98
+ else if (resolved !== ct) conflict = true;
99
+ }
100
+ if (conflict) { symRet.set(sym, CONFLICT); changed = true; }
101
+ else if (resolved !== null) { symRet.set(sym, resolved); changed = true; }
102
+ }
103
+ if (!changed) break;
104
+ }
105
+
106
+ const out = new Map();
107
+ for (const [nm, t] of symRet) if (t !== CONFLICT && typeof t === 'string') out.set(nm, t);
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * Run the fixpoint over a chunk list and annotate each factory-receiver call site with the
113
+ * resolved `recv_resolved_type`; strip the transient `_return_via` so it never serializes.
114
+ * Mutates chunks in place and returns them.
115
+ *
116
+ * @param {object[]} chunks
117
+ * @returns {object[]} the same array
118
+ */
119
+ export function applyInterprocedural(chunks) {
120
+ const symRet = resolveReturnTypes(chunks);
121
+ for (const c of chunks) {
122
+ if (Array.isArray(c.call_sites)) {
123
+ for (const s of c.call_sites) {
124
+ // Only fill in what intra-procedural inference left unresolved.
125
+ if (s && !s.recv_type && s.recv_via_call) {
126
+ const t = symRet.get(String(s.recv_via_call).toLowerCase());
127
+ if (t) s.recv_resolved_type = t;
128
+ }
129
+ }
130
+ }
131
+ if ('_return_via' in c) delete c._return_via;
132
+ }
133
+ return chunks;
134
+ }