graph-indexer 2.0.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.
package/mcp/topology.mjs CHANGED
@@ -339,27 +339,36 @@ export function classifyCallers(db, targetFunction, { targetClass = null } = {})
339
339
  let typeMatch = null;
340
340
  if (typeMatchSet.size) {
341
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.
342
346
  const toks = s.recv_type ? _typeMatchTokens(s.recv_type)
343
- : s.recv_via_call ? [...calleeReturnTokens(s.recv_via_call)] : null;
347
+ : s.recv_resolved_type ? _typeMatchTokens(s.recv_resolved_type)
348
+ : s.recv_via_call ? [...calleeReturnTokens(s.recv_via_call)] : null;
344
349
  const m = toks && toks.find(t => typeMatchSet.has(t));
345
350
  if (m) { typeMatch = m; break; }
346
351
  }
347
352
  }
348
353
 
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}()`;
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; }
357
366
 
358
367
  const recvHint = [...recvs].map(r =>
359
368
  r === '' ? `${targetFunction}()` : r === 'this' ? `this.${targetFunction}()` : `${r}.${targetFunction}()`
360
369
  ).join(', ');
361
370
 
362
- (reason ? high : nameOnly).push({ chunk: caller, reason, recvHint });
371
+ (reason ? high : nameOnly).push({ chunk: caller, reason, recvHint, proven: reason ? proven : false });
363
372
  }
364
373
 
365
374
  return { high, nameOnly, targetDefs, ambiguous, hasSiteData, classFiltered };
@@ -403,15 +412,40 @@ export function findReferences(db, symbol, { targetClass = null } = {}) {
403
412
  : imports ? 'imports definition'
404
413
  : sameFile ? 'same file' : '';
405
414
  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 });
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 });
408
419
  }
409
420
  const order = (a, b) => (a.confidence === b.confidence
410
421
  ? a.chunk.file_path.localeCompare(b.chunk.file_path)
411
422
  : a.confidence === 'high' ? -1 : 1);
412
423
  inherits.sort(order); types.sort(order);
413
424
 
414
- return { symbol, targetDefs, ambiguous, calls, inherits, types };
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 };
415
449
  }
416
450
 
417
451
  // ─── HTTP route → handler resolution ──────────────────────────────────────────────
@@ -549,3 +583,94 @@ export function buildSubgraph(db, seed, { maxNodes = 12, maxDepth = 2, tokenBudg
549
583
  }));
550
584
  return { seed, found: true, truncated, nodes: nodeList, edges };
551
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
+ }
package/mcp-server.mjs CHANGED
@@ -21,10 +21,22 @@ import { ensureDataDir, migrateLegacyLayout } from './layout.mjs';
21
21
  import { daemonStatus } from './daemon-lock.mjs';
22
22
  import { createStore } from './storage.mjs';
23
23
  import { registerTools } from './mcp/tools.mjs';
24
+ import { ensureLanguagesReady } from './parse/languages.mjs';
24
25
  import { createEmbedder, readEmbedMeta } from './embeddings.mjs';
25
26
  import { loadGitSignals } from './git-signals.mjs';
26
-
27
- const config = resolveConfig();
27
+ import { installEgressGuard } from './seal.mjs';
28
+
29
+ let config;
30
+ try {
31
+ config = resolveConfig();
32
+ } catch (err) {
33
+ if (err && err.name === 'SealViolation') { process.stderr.write(`🔒 ${err.message}\n`); process.exit(2); }
34
+ throw err;
35
+ }
36
+ // F1: install the deny-by-default egress guard before the store / embedder / providers load.
37
+ if (config.sealed !== 'off') {
38
+ installEgressGuard({ allow: config.sealed === 'local' ? ['loopback'] : [] });
39
+ }
28
40
  const PROJECT_ROOT = config.projectRoot;
29
41
  const PID_FILE = config.pidFile;
30
42
  const PACKAGE_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -32,6 +44,16 @@ const PACKAGE_DIR = path.dirname(fileURLToPath(import.meta.url));
32
44
  ensureDataDir(PROJECT_ROOT);
33
45
  migrateLegacyLayout(PROJECT_ROOT);
34
46
 
47
+ // get_file_skeleton parses on demand at query time — grammars must be resolved
48
+ // (and, outside sealed mode, installed if this server is starting before the
49
+ // first `idx-index` run) before the transport connects and starts serving tools.
50
+ await ensureLanguagesReady({
51
+ projectRoot: PROJECT_ROOT,
52
+ enabledLangs: config.languages,
53
+ autoInstall: config.sealed === 'off',
54
+ log: (msg) => process.stderr.write(msg + '\n'),
55
+ });
56
+
35
57
  function readPackageVersion() {
36
58
  try {
37
59
  return JSON.parse(fs.readFileSync(path.join(PACKAGE_DIR, 'package.json'), 'utf-8')).version || '0.0.0';
@@ -166,6 +188,8 @@ registerTools(server, db, {
166
188
  mlxLmHost: config.mlxLmHost,
167
189
  gitSignals,
168
190
  gitRankBoost: config.gitRankBoost,
191
+ sealed: config.sealed,
192
+ ranker: config.ranker,
169
193
  });
170
194
 
171
195
  const transport = new StdioServerTransport();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graph-indexer",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Air-gapped code-retrieval MCP server: AST chunking, hybrid RRF search and cross-language call graph, with an optional zero-dependency SQLite backend for monorepo scale and optional local-LLM semantic enrichment.",
5
5
  "type": "module",
6
6
  "main": "engine/memory.mjs",
@@ -43,14 +43,21 @@
43
43
  "test:mcp": "node test/mcp.mjs",
44
44
  "test:scale": "node test/scale.mjs",
45
45
  "test:callgraph": "node test/callgraph.mjs",
46
+ "test:edges": "node test/edges.mjs",
46
47
  "test:references": "node test/references.mjs",
47
48
  "test:routes": "node test/routes.mjs",
48
49
  "test:jsonout": "node test/json-output.mjs",
49
50
  "test:gitsignals": "node test/git-signals.mjs",
50
51
  "test:security": "node test/security.mjs",
52
+ "test:seal": "node test/seal.mjs",
53
+ "test:taint": "node test/taint.mjs",
54
+ "test:scip": "node test/scip.mjs",
55
+ "test:ranker": "node test/ranker.mjs",
56
+ "test:sparse": "node test/sparse.mjs",
57
+ "test:colbert": "node test/colbert.mjs",
51
58
  "test:embed": "node test/embeddings.mjs",
52
59
  "test:languages": "node test/languages.mjs",
53
- "test:all": "node test/unit.mjs && node test/sqlite.mjs && node test/enrichment.mjs && node test/mcp.mjs && node test/scale.mjs && node test/callgraph.mjs && node test/references.mjs && node test/routes.mjs && node test/json-output.mjs && node test/git-signals.mjs && node test/security.mjs && node test/embeddings.mjs && node test/languages.mjs",
60
+ "test:all": "node test/unit.mjs && node test/sqlite.mjs && node test/enrichment.mjs && node test/mcp.mjs && node test/scale.mjs && node test/callgraph.mjs && node test/edges.mjs && node test/references.mjs && node test/routes.mjs && node test/json-output.mjs && node test/git-signals.mjs && node test/security.mjs && node test/seal.mjs && node test/taint.mjs && node test/scip.mjs && node test/ranker.mjs && node test/sparse.mjs && node test/colbert.mjs && node test/embeddings.mjs && node test/languages.mjs",
54
61
  "test:eval": "node test/evaluate.mjs",
55
62
  "test:eval:embeddings": "OLLAMA_HOST=http://localhost:11434 node test/evaluate.mjs --embeddings",
56
63
  "test:setup": "node test/setup.mjs",
@@ -61,6 +68,7 @@
61
68
  "test:json": "node test/run.mjs --json",
62
69
  "test:reset": "rm -rf test/fixtures && node test/setup.mjs",
63
70
  "embed:setup:mlx": "node embedders/setup-mlx.mjs",
71
+ "embed:setup:local": "npm install @huggingface/transformers",
64
72
  "mcp:index": "idx-index --repo .",
65
73
  "mcp:index:sqlite": "node indexer.mjs --repo . --use-sqlite",
66
74
  "mcp:index:enriched": "node indexer.mjs --repo . --enrichment",
@@ -80,6 +88,25 @@
80
88
  "tree-sitter": "0.21.1",
81
89
  "zod": "^3.25.76"
82
90
  },
91
+ "devDependencies": {
92
+ "@huggingface/transformers": "3.8.1",
93
+ "hnswlib-node": "3.0.0",
94
+ "tree-sitter-bash": "0.23.3",
95
+ "tree-sitter-c": "0.21.4",
96
+ "tree-sitter-c-sharp": "0.21.3",
97
+ "tree-sitter-css": "0.21.1",
98
+ "tree-sitter-go": "0.21.0",
99
+ "tree-sitter-java": "0.21.0",
100
+ "tree-sitter-javascript": "0.21.4",
101
+ "tree-sitter-kotlin": "0.3.8",
102
+ "tree-sitter-php": "0.23.1",
103
+ "tree-sitter-python": "0.21.0",
104
+ "tree-sitter-ruby": "0.23.1",
105
+ "tree-sitter-rust": "0.21.0",
106
+ "tree-sitter-scss": "1.0.0",
107
+ "tree-sitter-swift": "0.5.0",
108
+ "tree-sitter-typescript": "0.21.2"
109
+ },
83
110
  "keywords": [
84
111
  "mcp",
85
112
  "model-context-protocol",
@@ -111,24 +138,5 @@
111
138
  },
112
139
  "publishConfig": {
113
140
  "access": "public"
114
- },
115
- "optionalDependencies": {
116
- "@huggingface/transformers": "3.8.1",
117
- "hnswlib-node": "3.0.0",
118
- "tree-sitter-bash": "0.23.3",
119
- "tree-sitter-c": "0.21.4",
120
- "tree-sitter-c-sharp": "0.21.3",
121
- "tree-sitter-css": "0.21.1",
122
- "tree-sitter-go": "0.21.0",
123
- "tree-sitter-java": "0.21.0",
124
- "tree-sitter-javascript": "0.21.4",
125
- "tree-sitter-kotlin": "0.3.8",
126
- "tree-sitter-php": "0.23.1",
127
- "tree-sitter-python": "0.21.0",
128
- "tree-sitter-ruby": "0.23.1",
129
- "tree-sitter-rust": "0.21.0",
130
- "tree-sitter-scss": "1.0.0",
131
- "tree-sitter-swift": "0.5.0",
132
- "tree-sitter-typescript": "0.21.2"
133
141
  }
134
142
  }
@@ -16,12 +16,14 @@ const { Query } = Parser;
16
16
  import { getParserForFile, LANGUAGE_MAP, EXTENSIONS } from './languages.mjs';
17
17
  import {
18
18
  extractParams, extractReturnType, extractClassContext,
19
- extractDecorators, extractHeritage, extractTypeAnnotations, extractCallSites,
19
+ extractDecorators, extractHeritage, extractTypeAnnotations, extractCallSites, extractReturnVia,
20
20
  } from './metadata.mjs';
21
21
 
22
22
  export const MAX_FILE_SIZE_BYTES = 500000;
23
23
 
24
- const _hasSCSS = Boolean(LANGUAGE_MAP['.scss']) && LANGUAGE_MAP['.scss'] !== LANGUAGE_MAP['.css'];
24
+ // Evaluated lazily (not at module load) — LANGUAGE_MAP populates asynchronously
25
+ // via ensureLanguagesReady(), which callers await before extractSemanticChunks runs.
26
+ function _hasSCSS() { return Boolean(LANGUAGE_MAP['.scss']) && LANGUAGE_MAP['.scss'] !== LANGUAGE_MAP['.css']; }
25
27
 
26
28
  const LANGUAGE_QUERIES = {
27
29
  ts: `
@@ -330,7 +332,7 @@ function _cDeclaratorName(node) {
330
332
  return null;
331
333
  }
332
334
 
333
- export function extractSemanticChunks(rootNode, relPath, sourceCode, ext) {
335
+ export function extractSemanticChunks(rootNode, relPath, sourceCode, ext, { interprocedural = false } = {}) {
334
336
  const chunks = [];
335
337
  const parser = getParserForFile(ext);
336
338
  if (!parser) return chunks;
@@ -342,7 +344,7 @@ export function extractSemanticChunks(rootNode, relPath, sourceCode, ext) {
342
344
  // .scss uses the SCSS grammar+query when installed; otherwise it parses
343
345
  // with the CSS grammar, so it MUST use the css query (the scss query
344
346
  // references node types the CSS grammar doesn't have).
345
- '.scss': _hasSCSS ? 'scss' : 'css',
347
+ '.scss': _hasSCSS() ? 'scss' : 'css',
346
348
  // C headers share the C grammar/query; .sh and .bash share the bash query.
347
349
  // (.c and .swift resolve via the ext.slice(1) fallback below.)
348
350
  '.h': 'c', '.sh': 'bash', '.bash': 'bash',
@@ -567,14 +569,23 @@ export function extractSemanticChunks(rootNode, relPath, sourceCode, ext) {
567
569
  .update(`${relPath}::${chunkNode.startPosition.row}::${chunkNode.startPosition.column}`)
568
570
  .digest('hex').slice(0, 24);
569
571
 
570
- chunks.push({
572
+ const chunk = {
571
573
  id, file_path: relPath, node_type: chunkNode.type, name: nameText,
572
574
  docstring: docstring, code_snippet: snippet, content_hash: hash,
573
575
  start_line: chunkNode.startPosition.row + 1, end_line: chunkNode.endPosition.row + 1,
574
576
  calls: outgoingCalls, call_sites: callSites,
575
577
  params, return_type: returnType, class_context: classContext,
576
578
  type_refs: typeRefs, decorators, extends: heritage,
577
- });
579
+ };
580
+ // Opt-in inter-procedural fixpoint: capture what this function returns so the
581
+ // indexer can propagate return types along factory chains. Transient — stripped
582
+ // before serialization (parse/interprocedural.mjs::applyInterprocedural). Off by
583
+ // default → the chunk is byte-identical to before.
584
+ if (interprocedural) {
585
+ const rv = extractReturnVia(chunkNode);
586
+ if (rv) chunk._return_via = rv;
587
+ }
588
+ chunks.push(chunk);
578
589
  }
579
590
  } catch (e) {
580
591
  process.stderr.write(`\n[parse/extractor] 💥 Query Error in ${relPath}: ${e.message}\n`);
@@ -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
+ }