feat: implement cross-file binding propagation for multiple languages

- Enhance C++ tree-sitter queries to support inline class method declarations and return types.
- Introduce `importedRawReturnTypes` in `BuildTypeEnvOptions` for cross-file raw return type handling.
- Add `FileTypeEnvBindings` interface to capture file-scope type bindings for exported symbols.
- Implement logic in `parse-worker.ts` to extract and serialize file-scope type bindings for cross-file type resolution.
- Create test fixtures for C++, Go, Ruby, and Rust to validate cross-file binding propagation.
- Update integration tests to verify correct resolution of method calls across files for C++, Go, Ruby, and Rust.
- Document Phase 14: Cross-File Binding Propagation in the type resolution roadmap and system documentation.
This commit is contained in:
Gergo Magyar 2026-03-21 07:47:04 +00:00
parent f1fbe643df
commit fb20a3c752
32 changed files with 746 additions and 86 deletions

View file

@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (2184 symbols, 5245 relationships, 167 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **GitNexus** (2227 symbols, 5390 relationships, 170 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View file

@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (2184 symbols, 5245 relationships, 167 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **GitNexus** (2227 symbols, 5390 relationships, 170 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View file

@ -60,6 +60,27 @@ export function buildImportedReturnTypes(
return result;
}
/** Build cross-file RAW return types for imported callables.
* Unlike buildImportedReturnTypes (which stores extractReturnTypeName output),
* this stores the raw declared return type string (e.g., 'User[]', 'List<User>').
* Used by lookupRawReturnType for for-loop element extraction via extractElementTypeFromString. */
export function buildImportedRawReturnTypes(
filePath: string,
namedImportMap: ReadonlyMap<string, ReadonlyMap<string, { sourcePath: string; exportedName: string }>>,
symbolTable: { lookupExactFull(filePath: string, name: string): { returnType?: string } | undefined },
): ReadonlyMap<string, string> {
const result = new Map<string, string>();
const fileImports = namedImportMap.get(filePath);
if (!fileImports) return result;
for (const [localName, binding] of fileImports) {
const def = symbolTable.lookupExactFull(binding.sourcePath, binding.exportedName);
if (!def?.returnType) continue;
result.set(localName, def.returnType);
}
return result;
}
/** Collect resolved type bindings for exported file-scope symbols.
* Uses graph node isExported flag does NOT require isExported on SymbolDefinition. */
function collectExportedBindings(
@ -262,6 +283,8 @@ export const processCalls = async (
/** Phase 14 E3: cross-file return types for imported callables. Keyed by filePath Map<calleeName, returnType>.
* Consulted ONLY when SymbolTable has no unambiguous match (local-first principle). */
importedReturnTypesMap?: ReadonlyMap<string, ReadonlyMap<string, string>>,
/** Phase 14 E3: cross-file RAW return types for for-loop element extraction. Keyed by filePath → Map<calleeName, rawReturnType>. */
importedRawReturnTypesMap?: ReadonlyMap<string, ReadonlyMap<string, string>>,
): Promise<ExtractedHeritage[]> => {
const parser = await loadParser();
const collectedHeritage: ExtractedHeritage[] = [];
@ -349,7 +372,8 @@ export const processCalls = async (
const importedBindings = importedBindingsMap?.get(file.path);
const importedReturnTypes = importedReturnTypesMap?.get(file.path);
const typeEnv = lang ? buildTypeEnv(tree, lang, { symbolTable: ctx.symbols, parentMap, importedBindings, importedReturnTypes }) : null;
const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path);
const typeEnv = lang ? buildTypeEnv(tree, lang, { symbolTable: ctx.symbols, parentMap, importedBindings, importedReturnTypes, importedRawReturnTypes }) : null;
if (typeEnv && exportedTypeMap) {
const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph);
if (fileExports) exportedTypeMap.set(file.path, fileExports);
@ -1094,7 +1118,6 @@ export const processCallsFromExtracted = async (
ctx: ResolutionContext,
onProgress?: (current: number, total: number) => void,
constructorBindings?: FileConstructorBindings[],
exportedTypeMap?: ExportedTypeMap,
) => {
// Scope-aware receiver types: keyed by filePath → "funcName\0varName" → typeName.
// The scope dimension prevents collisions when two functions in the same file

View file

@ -320,13 +320,22 @@ function applyImportResult(
addImportEdge(filePath, resolvedFile);
}
// Record named bindings for precise Tier 2a resolution
// Record named bindings for precise Tier 2a resolution.
// If the same local name is imported from multiple files (e.g., Java static imports
// of overloaded methods), remove the entry so resolution falls through to Tier 2a
// import-scoped which sees all candidates and can apply arity narrowing.
if (namedBindings && namedImportMap && files.length === 1) {
const resolvedFile = files[0];
if (!namedImportMap.has(filePath)) namedImportMap.set(filePath, new Map());
const fileBindings = namedImportMap.get(filePath)!;
for (const binding of namedBindings) {
fileBindings.set(binding.local, { sourcePath: resolvedFile, exportedName: binding.exported });
const existing = fileBindings.get(binding.local);
if (existing && existing.sourcePath !== resolvedFile) {
// Ambiguous: same name imported from different files — remove to fall through
fileBindings.delete(binding.local);
} else {
fileBindings.set(binding.local, { sourcePath: resolvedFile, exportedName: binding.exported });
}
}
}
}

View file

@ -89,6 +89,15 @@ export function extractNamedBindings(
if (language === SupportedLanguages.Java) {
return extractJavaNamedBindings(importNode);
}
// Languages below use whole-module import semantics — the import AST node does not
// name specific symbols. namedImportMap entries are synthesized post-parse by
// synthesizeWildcardImportBindings() in pipeline.ts, which expands ImportMap edges
// into per-symbol bindings using graph-exported symbols.
//
// Go: `import "pkg"` — all PascalCase symbols available as pkg.Symbol
// Ruby: `require 'file'` — all top-level classes/modules available
// C/C++: `#include "file.h"` — textual inclusion, all non-static declarations available
// Swift: `import Module` — entire module imported (Phase S blocked on tree-sitter-swift Node 22)
return undefined;
}
@ -337,23 +346,41 @@ export function extractPhpNamedBindings(importNode: any): { local: string; expor
}
export function extractCsharpNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
// using_directive with identifier (alias) + qualified_name (target)
// using_directive — three forms:
// using Alias = NS.Type; → aliasIdent + qualifiedName
// using static NS.Type; → static + qualifiedName (no alias)
// using NS; → qualifiedName only (namespace, not capturable)
if (importNode.type !== 'using_directive') return undefined;
let aliasIdent: any = null;
let qualifiedName: any = null;
let isStatic = false;
for (let i = 0; i < importNode.childCount; i++) {
const child = importNode.child(i);
if (child?.text === 'static') isStatic = true;
}
for (let i = 0; i < importNode.namedChildCount; i++) {
const child = importNode.namedChild(i);
if (child?.type === 'identifier' && !aliasIdent) aliasIdent = child;
else if (child?.type === 'qualified_name') qualifiedName = child;
}
if (!aliasIdent || !qualifiedName) return undefined;
// Form 1: using Alias = NS.Type;
if (aliasIdent && qualifiedName) {
const fullText = qualifiedName.text;
const exportedName = fullText.includes('.') ? fullText.split('.').pop()! : fullText;
return [{ local: aliasIdent.text, exported: exportedName }];
}
const fullText = qualifiedName.text;
const exportedName = fullText.includes('.') ? fullText.split('.').pop()! : fullText;
// Form 2: using static NS.Type; — last segment is the class name
if (isStatic && qualifiedName) {
const fullText = qualifiedName.text;
const lastSegment = fullText.includes('.') ? fullText.split('.').pop()! : fullText;
return [{ local: lastSegment, exported: lastSegment }];
}
return [{ local: aliasIdent.text, exported: exportedName }];
// Form 3: using NS; — namespace import, can't resolve to per-symbol bindings
return undefined;
}
export function extractJavaNamedBindings(importNode: any): { local: string; exported: string }[] | undefined {
@ -361,10 +388,12 @@ export function extractJavaNamedBindings(importNode: any): { local: string; expo
// Wildcard imports (.*) don't produce named bindings
if (importNode.type !== 'import_declaration') return undefined;
// Check for asterisk (wildcard import) — skip those
// Check for asterisk (wildcard import) and static modifier
let isStatic = false;
for (let i = 0; i < importNode.childCount; i++) {
const child = importNode.child(i);
if (child?.type === 'asterisk') return undefined;
if (child?.text === 'static') isStatic = true;
}
const scopedId = findChild(importNode, 'scoped_identifier');
@ -374,11 +403,12 @@ export function extractJavaNamedBindings(importNode: any): { local: string; expo
const lastDot = fullText.lastIndexOf('.');
if (lastDot === -1) return undefined;
const className = fullText.slice(lastDot + 1);
// Skip lowercase names — those are package imports, not class imports
if (className[0] && className[0] === className[0].toLowerCase()) return undefined;
const name = fullText.slice(lastDot + 1);
// Non-static: skip lowercase names — those are package imports, not class imports.
// Static: allow lowercase — `import static models.UserFactory.getUser` imports a method.
if (!isStatic && name[0] && name[0] === name[0].toLowerCase()) return undefined;
return [{ local: className, exported: className }];
return [{ local: name, exported: name }];
}
function findChild(node: any, type: string): any {

View file

@ -12,7 +12,7 @@ import { detectFrameworkFromAST } from './framework-detection.js';
import { typeConfigs } from './type-extractors/index.js';
import { SupportedLanguages } from '../../config/supported-languages.js';
import { WorkerPool } from './workers/worker-pool.js';
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings, FileTypeEnvBindings } from './workers/parse-worker.js';
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js';
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
@ -24,6 +24,7 @@ export interface WorkerExtractedData {
heritage: ExtractedHeritage[];
routes: ExtractedRoute[];
constructorBindings: FileConstructorBindings[];
typeEnvBindings: FileTypeEnvBindings[];
}
// isNodeExported imported from ./export-detection.js (shared module)
@ -49,7 +50,7 @@ const processParsingWithWorkers = async (
if (lang) parseableFiles.push({ path: file.path, content: file.content });
}
if (parseableFiles.length === 0) return { imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [] };
if (parseableFiles.length === 0) return { imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], typeEnvBindings: [] };
const total = files.length;
@ -68,6 +69,7 @@ const processParsingWithWorkers = async (
const allHeritage: ExtractedHeritage[] = [];
const allRoutes: ExtractedRoute[] = [];
const allConstructorBindings: FileConstructorBindings[] = [];
const allTypeEnvBindings: FileTypeEnvBindings[] = [];
for (const result of chunkResults) {
for (const node of result.nodes) {
graph.addNode({
@ -98,6 +100,7 @@ const processParsingWithWorkers = async (
allHeritage.push(...result.heritage);
allRoutes.push(...result.routes);
allConstructorBindings.push(...result.constructorBindings);
allTypeEnvBindings.push(...result.typeEnvBindings);
}
// Merge and log skipped languages from workers
@ -116,7 +119,7 @@ const processParsingWithWorkers = async (
// Final progress
onFileProgress?.(total, total, 'done');
return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, constructorBindings: allConstructorBindings };
return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, constructorBindings: allConstructorBindings, typeEnvBindings: allTypeEnvBindings };
};
// ============================================================================

View file

@ -7,7 +7,7 @@ import {
processImportsFromExtracted,
buildImportResolutionContext
} from './import-processor.js';
import { processCalls, processCallsFromExtracted, processAssignmentsFromExtracted, processRoutesFromExtracted, seedCrossFileReceiverTypes, buildImportedReturnTypes, type ExportedTypeMap, buildExportedTypeMapFromGraph } from './call-processor.js';
import { processCalls, processCallsFromExtracted, processAssignmentsFromExtracted, processRoutesFromExtracted, seedCrossFileReceiverTypes, buildImportedReturnTypes, buildImportedRawReturnTypes, type ExportedTypeMap, buildExportedTypeMapFromGraph } from './call-processor.js';
import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js';
import { computeMRO } from './mro-processor.js';
import { processCommunities } from './community-processor.js';
@ -18,6 +18,7 @@ import { PipelineProgress, PipelineResult } from '../../types/pipeline.js';
import { walkRepositoryPaths, readFileContents } from './filesystem-walker.js';
import { getLanguageFromFilename } from './utils.js';
import { isLanguageAvailable } from '../tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../config/supported-languages.js';
import { createWorkerPool, WorkerPool } from './workers/worker-pool.js';
import fs from 'node:fs';
import path from 'node:path';
@ -33,7 +34,7 @@ type IndependentFileGroup = readonly string[];
* Files in cycles are returned as a final group (no cross-cycle propagation). */
export function topologicalLevelSort(
importMap: ReadonlyMap<string, ReadonlySet<string>>,
): readonly IndependentFileGroup[] {
): { levels: readonly IndependentFileGroup[]; cycleCount: number } {
// Build in-degree map and reverse dependency map
const inDegree = new Map<string, number>();
const reverseDeps = new Map<string, string[]>();
@ -78,7 +79,7 @@ export function topologicalLevelSort(
levels.push(cycleFiles);
}
return levels;
return { levels, cycleCount: cycleFiles.length };
}
/** Max bytes of source content to load per parse chunk. Each chunk's source +
@ -95,6 +96,116 @@ const CROSS_FILE_SKIP_THRESHOLD = 0.03;
/** Hard cap on files re-processed during cross-file propagation. */
const MAX_CROSS_FILE_REPROCESS = 2000;
/** Node labels that represent top-level importable symbols.
* Excludes Method, Property, Constructor (accessed via receiver, not directly imported),
* and structural labels (File, Folder, Package, Module, Project, etc.). */
const IMPORTABLE_SYMBOL_LABELS = new Set([
'Function', 'Class', 'Interface', 'Struct', 'Enum', 'Trait',
'TypeAlias', 'Const', 'Static', 'Record', 'Union', 'Typedef', 'Macro',
]);
/** Max synthetic bindings per importing file prevents memory bloat for
* C/C++ files that include many large headers. */
const MAX_SYNTHETIC_BINDINGS_PER_FILE = 1000;
/** Languages with whole-module import semantics (no per-symbol named imports).
* For these languages, namedImportMap entries are synthesized from graph-exported
* symbols after parsing, enabling Phase 14 cross-file binding propagation. */
const WILDCARD_IMPORT_LANGUAGES = new Set([
SupportedLanguages.Go,
SupportedLanguages.Ruby,
SupportedLanguages.C,
SupportedLanguages.CPlusPlus,
SupportedLanguages.Swift,
]);
/** Synthesize namedImportMap entries for languages with whole-module imports.
* These languages (Go, Ruby, C/C++, Swift) import all exported symbols from a file,
* not specific named symbols. After parsing, we know which symbols each file exports
* (via graph isExported), so we can expand ImportMap edges into per-symbol bindings
* that Phase 14 can use for cross-file type propagation. */
function synthesizeWildcardImportBindings(
graph: ReturnType<typeof createKnowledgeGraph>,
ctx: ReturnType<typeof createResolutionContext>,
): number {
// Pre-compute exported symbols per file from graph (single pass)
const exportedSymbolsByFile = new Map<string, { name: string; filePath: string }[]>();
graph.forEachNode(node => {
if (!node.properties?.isExported) return;
if (!IMPORTABLE_SYMBOL_LABELS.has(node.label)) return;
const fp = node.properties.filePath;
const name = node.properties.name;
if (!fp || !name) return;
let symbols = exportedSymbolsByFile.get(fp);
if (!symbols) { symbols = []; exportedSymbolsByFile.set(fp, symbols); }
symbols.push({ name, filePath: fp });
});
if (exportedSymbolsByFile.size === 0) return 0;
// Build a merged import map: ctx.importMap has file-based imports (Ruby, C/C++),
// but Go/C# package imports use graph IMPORTS edges + PackageMap instead.
// Collect graph-level IMPORTS edges for wildcard languages missing from ctx.importMap.
const FILE_PREFIX = 'File:';
const graphImports = new Map<string, Set<string>>();
graph.forEachRelationship(rel => {
if (rel.type !== 'IMPORTS') return;
if (!rel.sourceId.startsWith(FILE_PREFIX) || !rel.targetId.startsWith(FILE_PREFIX)) return;
const srcFile = rel.sourceId.slice(FILE_PREFIX.length);
const tgtFile = rel.targetId.slice(FILE_PREFIX.length);
const lang = getLanguageFromFilename(srcFile);
if (!lang || !WILDCARD_IMPORT_LANGUAGES.has(lang)) return;
// Only add if not already in ctx.importMap (avoid duplicates)
if (ctx.importMap.get(srcFile)?.has(tgtFile)) return;
let set = graphImports.get(srcFile);
if (!set) { set = new Set(); graphImports.set(srcFile, set); }
set.add(tgtFile);
});
let totalSynthesized = 0;
// Helper: synthesize bindings for a file given its imported files
const synthesizeForFile = (filePath: string, importedFiles: Iterable<string>) => {
let fileBindings = ctx.namedImportMap.get(filePath);
let fileCount = fileBindings?.size ?? 0;
for (const importedFile of importedFiles) {
const exportedSymbols = exportedSymbolsByFile.get(importedFile);
if (!exportedSymbols) continue;
for (const sym of exportedSymbols) {
if (fileCount >= MAX_SYNTHETIC_BINDINGS_PER_FILE) return;
if (fileBindings?.has(sym.name)) continue;
if (!fileBindings) {
fileBindings = new Map();
ctx.namedImportMap.set(filePath, fileBindings);
}
fileBindings.set(sym.name, {
sourcePath: importedFile,
exportedName: sym.name,
});
fileCount++;
totalSynthesized++;
}
}
};
// Process files from ctx.importMap (Ruby, C/C++, Swift file-based imports)
for (const [filePath, importedFiles] of ctx.importMap) {
const lang = getLanguageFromFilename(filePath);
if (!lang || !WILDCARD_IMPORT_LANGUAGES.has(lang)) continue;
synthesizeForFile(filePath, importedFiles);
}
// Process files from graph IMPORTS edges (Go package imports)
for (const [filePath, importedFiles] of graphImports) {
synthesizeForFile(filePath, importedFiles);
}
return totalSynthesized;
}
/** Phase 14: Cross-file binding propagation.
* Seeds downstream files with resolved type bindings from upstream exports.
* Files are processed in topological import order so upstream bindings are
@ -119,14 +230,11 @@ async function runCrossFileBindingPropagation(
if (exportedTypeMap.size === 0 || ctx.namedImportMap.size === 0) return;
const allPathSet = new Set(allPaths);
const levels = topologicalLevelSort(ctx.importMap);
const { levels, cycleCount } = topologicalLevelSort(ctx.importMap);
// Cycle diagnostic: Kahn's dumps cycle files in the last level (positive in-degree)
if (isDev && levels.length > 0) {
const lastLevel = levels[levels.length - 1];
if (lastLevel.length > 1) {
console.log(`🔄 ${lastLevel.length} files in import cycles (skipped for cross-file propagation)`);
}
// Cycle diagnostic: only log when actual cycles detected (cycleCount from Kahn's BFS)
if (isDev && cycleCount > 0) {
console.log(`🔄 ${cycleCount} files in import cycles (skipped for cross-file propagation)`);
}
// Quick count of files with cross-file binding gaps (early exit once threshold exceeded)
@ -166,7 +274,7 @@ async function runCrossFileBindingPropagation(
let astCache = createASTCache(AST_CACHE_CAP);
for (const level of levels) {
const levelCandidates: { filePath: string; seeded: Map<string, string>; importedReturns: ReadonlyMap<string, string> }[] = [];
const levelCandidates: { filePath: string; seeded: Map<string, string>; importedReturns: ReadonlyMap<string, string>; importedRawReturns: ReadonlyMap<string, string> }[] = [];
for (const filePath of level) {
if (crossFileResolved + levelCandidates.length >= MAX_CROSS_FILE_REPROCESS) break;
const imports = ctx.namedImportMap.get(filePath);
@ -182,13 +290,14 @@ async function runCrossFileBindingPropagation(
}
const importedReturns = buildImportedReturnTypes(filePath, ctx.namedImportMap, ctx.symbols);
const importedRawReturns = buildImportedRawReturnTypes(filePath, ctx.namedImportMap, ctx.symbols);
if (seeded.size === 0 && importedReturns.size === 0) continue;
if (!allPathSet.has(filePath)) continue;
const lang = getLanguageFromFilename(filePath);
if (!lang || !isLanguageAvailable(lang)) continue;
levelCandidates.push({ filePath, seeded, importedReturns });
levelCandidates.push({ filePath, seeded, importedReturns, importedRawReturns });
}
if (levelCandidates.length === 0) continue;
@ -196,7 +305,7 @@ async function runCrossFileBindingPropagation(
const levelPaths = levelCandidates.map(c => c.filePath);
const contentMap = await readFileContents(repoPath, levelPaths);
for (const { filePath, seeded, importedReturns } of levelCandidates) {
for (const { filePath, seeded, importedReturns, importedRawReturns } of levelCandidates) {
const content = contentMap.get(filePath);
if (!content) continue;
@ -209,7 +318,12 @@ async function runCrossFileBindingPropagation(
importedReturnTypesMap.set(filePath, importedReturns);
}
await processCalls(graph, reFile, astCache, ctx, undefined, exportedTypeMap, bindings.size > 0 ? bindings : undefined, importedReturnTypesMap.size > 0 ? importedReturnTypesMap : undefined);
const importedRawReturnTypesMap = new Map<string, ReadonlyMap<string, string>>();
if (importedRawReturns.size > 0) {
importedRawReturnTypesMap.set(filePath, importedRawReturns);
}
await processCalls(graph, reFile, astCache, ctx, undefined, exportedTypeMap, bindings.size > 0 ? bindings : undefined, importedReturnTypesMap.size > 0 ? importedReturnTypesMap : undefined, importedRawReturnTypesMap.size > 0 ? importedRawReturnTypesMap : undefined);
crossFileResolved++;
}
@ -418,6 +532,8 @@ export const runPipelineFromRepo = async (
const sequentialChunkPaths: string[][] = [];
// Phase 14: Collect exported type bindings for cross-file propagation
const exportedTypeMap: ExportedTypeMap = new Map();
// Accumulate file-scope TypeEnv bindings from workers (closes worker/sequential quality gap)
const workerTypeEnvBindings: { filePath: string; bindings: [string, string][] }[] = [];
try {
for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) {
@ -461,6 +577,9 @@ export const runPipelineFromRepo = async (
}, repoPath, importCtx);
// Phase 14 E1: Seed cross-file receiver types from ExportedTypeMap
// before call resolution — eliminates re-parse for single-hop imported receivers.
// NOTE: In the worker path, exportedTypeMap is empty during chunk processing
// (populated later in runCrossFileBindingPropagation). This block is latent —
// it activates only if incremental export collection is added per-chunk.
if (exportedTypeMap.size > 0 && ctx.namedImportMap.size > 0) {
const { enrichedCount } = seedCrossFileReceiverTypes(
chunkWorkerData.calls, ctx.namedImportMap, exportedTypeMap,
@ -487,7 +606,6 @@ export const runPipelineFromRepo = async (
});
},
chunkWorkerData.constructorBindings,
exportedTypeMap,
),
processHeritageFromExtracted(
graph,
@ -522,6 +640,10 @@ export const runPipelineFromRepo = async (
if (chunkWorkerData.assignments?.length) {
processAssignmentsFromExtracted(graph, chunkWorkerData.assignments, ctx, chunkWorkerData.constructorBindings);
}
// Collect TypeEnv file-scope bindings for exported type enrichment
if (chunkWorkerData.typeEnvBindings?.length) {
workerTypeEnvBindings.push(...chunkWorkerData.typeEnvBindings);
}
} else {
await processImports(graph, chunkFiles, astCache, ctx, undefined, repoPath, allPaths);
sequentialChunkPaths.push(chunkPaths);
@ -560,6 +682,44 @@ export const runPipelineFromRepo = async (
console.log(`🔍 Resolution cache: ${rcStats.cacheHits} hits, ${rcStats.cacheMisses} misses (${hitRate}% hit rate)`);
}
// ── Worker path quality enrichment: merge TypeEnv file-scope bindings into ExportedTypeMap ──
// Workers return file-scope bindings from their TypeEnv fixpoint (includes inferred types
// like `const config = getConfig()` → Config). Filter by graph isExported to match
// the sequential path's collectExportedBindings behavior.
if (workerTypeEnvBindings.length > 0) {
let enriched = 0;
for (const { filePath, bindings } of workerTypeEnvBindings) {
for (const [name, type] of bindings) {
// Verify the symbol is exported via graph node
const nodeId = `Function:${filePath}:${name}`;
const varNodeId = `Variable:${filePath}:${name}`;
const constNodeId = `Const:${filePath}:${name}`;
const node = graph.getNode(nodeId) ?? graph.getNode(varNodeId) ?? graph.getNode(constNodeId);
if (!node?.properties?.isExported) continue;
let fileExports = exportedTypeMap.get(filePath);
if (!fileExports) { fileExports = new Map(); exportedTypeMap.set(filePath, fileExports); }
// Don't overwrite existing entries (Tier 0 from SymbolTable is authoritative)
if (!fileExports.has(name)) {
fileExports.set(name, type);
enriched++;
}
}
}
if (isDev && enriched > 0) {
console.log(`🔗 Worker TypeEnv enrichment: ${enriched} fixpoint-inferred exports added to ExportedTypeMap`);
}
}
// ── Phase 14 pre-pass: Synthesize namedImportMap for whole-module-import languages ──
// Go, Ruby, C/C++, Swift import all exported symbols from a file.
// Expand ImportMap edges into per-symbol namedImportMap entries so Phase 14 can
// propagate types cross-file for these languages.
const synthesized = synthesizeWildcardImportBindings(graph, ctx);
if (isDev && synthesized > 0) {
console.log(`🔗 Synthesized ${synthesized} wildcard import bindings (Go/Ruby/C++/Swift)`);
}
// ── Phase 14: Cross-file binding propagation ──────────────────────
await runCrossFileBindingPropagation(
graph, ctx, exportedTypeMap, allPaths, totalFiles, repoPath, pipelineStart, onProgress,

View file

@ -420,8 +420,15 @@ export const CPP_QUERIES = `
declarator: (reference_declarator
(field_identifier) @name)) @definition.property
; Inline class method declarations (inside class body, no body: void Foo();)
(field_declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.method
; Inline class method declarations (inside class body, no body: void save();)
; tree-sitter-cpp uses field_identifier (not identifier) for names inside class bodies
(field_declaration declarator: (function_declarator declarator: [(field_identifier) (identifier)] @name)) @definition.method
; Inline class method declarations returning a pointer (User* lookup();)
(field_declaration declarator: (pointer_declarator declarator: (function_declarator declarator: [(field_identifier) (identifier)] @name))) @definition.method
; Inline class method declarations returning a reference (User& lookup();)
(field_declaration declarator: (reference_declarator (function_declarator declarator: [(field_identifier) (identifier)] @name))) @definition.method
; Inline class method definitions (inside class body, with body: void Foo() { ... })
(field_declaration_list

View file

@ -655,6 +655,10 @@ export interface BuildTypeEnvOptions {
* Consulted ONLY when SymbolTable has no unambiguous match.
* Local definitions always take precedence (local-first principle). */
importedReturnTypes?: ReadonlyMap<string, string>;
/** Cross-file RAW return types for imported callables (Phase 14 E3).
* Stores raw declared return type strings (e.g., 'User[]', 'List<User>').
* Used by lookupRawReturnType for for-loop element extraction. */
importedRawReturnTypes?: ReadonlyMap<string, string>;
}
/** Seed cross-file type bindings into the file scope.
@ -718,8 +722,9 @@ export const buildTypeEnv = (
// Ambiguous (2+) → return undefined (conservative, no cross-file fallback)
if (callables.length > 1) return undefined;
}
// Cross-file return types are already processed — return as-is
return options?.importedReturnTypes?.get(callee);
// Cross-file fallback uses importedRawReturnTypes (raw declared types, e.g., 'User[]')
// NOT importedReturnTypes (which contains processed/simple types via extractReturnTypeName)
return options?.importedRawReturnTypes?.get(callee);
}
};

View file

@ -165,6 +165,13 @@ export interface FileConstructorBindings {
bindings: ConstructorBinding[];
}
/** File-scope type bindings from TypeEnv fixpoint — used for cross-file ExportedTypeMap. */
export interface FileTypeEnvBindings {
filePath: string;
/** [varName, typeName] pairs from file scope (scope = '') */
bindings: [string, string][];
}
export interface ParseWorkerResult {
nodes: ParsedNode[];
relationships: ParsedRelationship[];
@ -175,6 +182,8 @@ export interface ParseWorkerResult {
heritage: ExtractedHeritage[];
routes: ExtractedRoute[];
constructorBindings: FileConstructorBindings[];
/** File-scope type bindings from TypeEnv fixpoint for exported symbol collection. */
typeEnvBindings: FileTypeEnvBindings[];
skippedLanguages: Record<string, number>;
fileCount: number;
}
@ -303,6 +312,7 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n
heritage: [],
routes: [],
constructorBindings: [],
typeEnvBindings: [],
skippedLanguages: {},
fileCount: 0,
};
@ -936,6 +946,16 @@ const processFileGroup = (
result.constructorBindings.push({ filePath: file.path, bindings: [...typeEnv.constructorBindings] });
}
// Extract file-scope bindings for ExportedTypeMap (closes worker/sequential quality gap).
// Sequential path uses collectExportedBindings(typeEnv) directly; worker path serializes
// these bindings so the main thread can merge them into ExportedTypeMap.
const fileScope = typeEnv.env.get('');
if (fileScope && fileScope.size > 0) {
const bindings: [string, string][] = [];
for (const [name, type] of fileScope) bindings.push([name, type]);
result.typeEnvBindings.push({ filePath: file.path, bindings });
}
for (const match of matches) {
const captureMap: Record<string, any> = {};
for (const c of match.captures) {
@ -1300,7 +1320,7 @@ const processFileGroup = (
/** Accumulated result across sub-batches */
let accumulated: ParseWorkerResult = {
nodes: [], relationships: [], symbols: [],
imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0,
imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0,
};
let cumulativeProcessed = 0;
@ -1314,6 +1334,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
target.heritage.push(...src.heritage);
target.routes.push(...src.routes);
target.constructorBindings.push(...src.constructorBindings);
target.typeEnvBindings.push(...src.typeEnvBindings);
for (const [lang, count] of Object.entries(src.skippedLanguages)) {
target.skippedLanguages[lang] = (target.skippedLanguages[lang] || 0) + count;
}
@ -1338,7 +1359,7 @@ parentPort!.on('message', (msg: any) => {
if (msg && msg.type === 'flush') {
parentPort!.postMessage({ type: 'result', data: accumulated });
// Reset for potential reuse
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0 };
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0 };
cumulativeProcessed = 0;
return;
}

View file

@ -0,0 +1,7 @@
#include "../models/user_factory.h"
void process() {
User user = get_user();
user.save();
user.get_name();
}

View file

@ -0,0 +1,7 @@
#include "user.h"
void User::save() {}
std::string User::get_name() {
return "";
}

View file

@ -0,0 +1,9 @@
#pragma once
#include <string>
class User {
public:
void save();
std::string get_name();
};

View file

@ -0,0 +1,5 @@
#include "user_factory.h"
User get_user() {
return User();
}

View file

@ -0,0 +1,5 @@
#pragma once
#include "user.h"
User get_user();

View file

@ -0,0 +1,9 @@
package main
import "go-cross-file/models"
func main() {
user := models.GetUser()
user.Save()
user.GetName()
}

View file

@ -0,0 +1,3 @@
module go-cross-file
go 1.21

View file

@ -0,0 +1,5 @@
package models
func GetUser() User {
return User{}
}

View file

@ -0,0 +1,7 @@
package models
type User struct{}
func (u User) Save() {}
func (u User) GetName() string { return "" }

View file

@ -0,0 +1,7 @@
require_relative 'models/user_factory'
def process
user = UserFactory.get_user
user.save
user.get_name
end

View file

@ -0,0 +1,9 @@
class User
def save
puts "saving"
end
def get_name
"Alice"
end
end

View file

@ -0,0 +1,7 @@
require_relative 'user'
class UserFactory
def self.get_user
User.new
end
end

View file

@ -0,0 +1,5 @@
use crate::models::User;
pub fn get_user() -> User {
User
}

View file

@ -0,0 +1,10 @@
mod factory;
mod models;
use crate::factory::get_user;
pub fn process() {
let u = get_user();
u.save();
u.get_name();
}

View file

@ -0,0 +1,9 @@
pub struct User;
impl User {
pub fn save(&self) {}
pub fn get_name(&self) -> String {
String::new()
}
}

View file

@ -4,7 +4,7 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet,
FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet,
runPipelineFromRepo, type PipelineResult,
} from './helpers.js';
@ -49,9 +49,12 @@ describe('C++ diamond inheritance', () => {
]);
});
it('captures 1 Method node from duck.cpp (speak)', () => {
it('captures speak as Method nodes (declaration in headers + definition in .cpp)', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toEqual(['speak']);
expect(methods).toContain('speak');
// speak appears in animal.h (virtual declaration), duck.h (override declaration),
// and duck.cpp (out-of-line definition) — all captured as Method nodes
expect(methods.filter(m => m === 'speak').length).toBeGreaterThanOrEqual(1);
});
it('no OVERRIDES edges target Property nodes', () => {
@ -1135,3 +1138,69 @@ describe('C++ default parameter arity resolution', () => {
expect(greetCalls.length).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Phase 14: Cross-file binding propagation (via synthesized wildcard imports)
// models/user.h declares User class with save() and get_name() methods
// models/user_factory.h declares User get_user() free function
// app/main.cpp includes user_factory.h, calls get_user().save()
// → user is typed User via cross-file return type propagation
// ---------------------------------------------------------------------------
describe('C++ cross-file binding propagation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(CROSS_FILE_FIXTURES, 'cpp-cross-file'),
() => {},
);
}, 60000);
it('detects User class with save and get_name methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('save');
expect(getNodesByLabel(result, 'Method')).toContain('get_name');
});
it('detects get_user factory function and process consumer', () => {
expect(getNodesByLabel(result, 'Function')).toContain('get_user');
expect(getNodesByLabel(result, 'Function')).toContain('process');
});
it('emits IMPORTS edge from main.cpp to headers', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(e =>
e.sourceFilePath.includes('main') && e.targetFilePath.includes('models'),
);
expect(edge).toBeDefined();
});
it('resolves user.save() in process() to User#save via cross-file propagation', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' &&
c.source === 'process' &&
c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
it('resolves user.get_name() in process() to User#get_name via cross-file propagation', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCall = calls.find(c =>
c.target === 'get_name' &&
c.source === 'process' &&
c.targetFilePath.includes('models'),
);
expect(getNameCall).toBeDefined();
});
it('emits HAS_METHOD edges linking save and get_name to User (via header declarations)', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'save');
const getNameEdge = hasMethod.find(e => e.source === 'User' && e.target === 'get_name');
expect(saveEdge).toBeDefined();
expect(getNameEdge).toBeDefined();
});
});

View file

@ -4,7 +4,7 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES, getRelationships, getNodesByLabel, edgeSet,
FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, edgeSet,
runPipelineFromRepo, type PipelineResult,
} from './helpers.js';
@ -1182,3 +1182,68 @@ describe('Go inc/dec write access tracking (Phase B)', () => {
expect(countDec).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase 14: Cross-file binding propagation (via synthesized wildcard imports)
// models/user.go exports User struct with Save() and GetName() methods
// models/factory.go exports GetUser() -> User
// app/main.go imports models package, calls models.GetUser().Save()
// → user is typed User via cross-file return type propagation
// ---------------------------------------------------------------------------
describe('Go cross-file binding propagation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(CROSS_FILE_FIXTURES, 'go-cross-file'),
() => {},
);
}, 60000);
it('detects User struct with Save and GetName methods', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('Save');
expect(getNodesByLabel(result, 'Method')).toContain('GetName');
});
it('detects GetUser factory function', () => {
expect(getNodesByLabel(result, 'Function')).toContain('GetUser');
});
it('emits IMPORTS edge from main.go to models package files', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(e =>
e.sourceFilePath.includes('main') && e.targetFilePath.includes('models'),
);
expect(edge).toBeDefined();
});
it('resolves user.Save() in main() to User#Save via cross-file propagation', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'Save' &&
c.source === 'main' &&
c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
it('resolves user.GetName() in main() to User#GetName via cross-file propagation', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCall = calls.find(c =>
c.target === 'GetName' &&
c.source === 'main' &&
c.targetFilePath.includes('models'),
);
expect(getNameCall).toBeDefined();
});
it('emits HAS_METHOD edges linking Save and GetName to User', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'Save');
const getNameEdge = hasMethod.find(e => e.source === 'User' && e.target === 'GetName');
expect(saveEdge).toBeDefined();
expect(getNameEdge).toBeDefined();
});
});

View file

@ -6,7 +6,7 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES, getRelationships, getNodesByLabel, edgeSet,
FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, edgeSet,
runPipelineFromRepo, type PipelineResult,
} from './helpers.js';
@ -1079,3 +1079,69 @@ describe('Ruby default parameter arity resolution', () => {
expect(greetCalls.length).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Phase 14: Cross-file binding propagation (via synthesized wildcard imports)
// models/user.rb exports User class with save and get_name methods
// models/user_factory.rb exports UserFactory with self.get_user -> User.new
// app.rb requires both, calls UserFactory.get_user then .save / .get_name
// → user is typed User via cross-file return type propagation
// ---------------------------------------------------------------------------
describe('Ruby cross-file binding propagation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(CROSS_FILE_FIXTURES, 'rb-cross-file'),
() => {},
);
}, 60000);
it('detects User class with save and get_name methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('save');
expect(getNodesByLabel(result, 'Method')).toContain('get_name');
});
it('detects UserFactory class and get_user method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('UserFactory');
expect(getNodesByLabel(result, 'Method')).toContain('get_user');
});
it('emits IMPORTS edge from app.rb to models', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(e =>
e.sourceFilePath.includes('app') && e.targetFilePath.includes('models'),
);
expect(edge).toBeDefined();
});
it('resolves user.save in process to User#save via cross-file propagation', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' &&
c.source === 'process' &&
c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
it('resolves user.get_name in process to User#get_name via cross-file propagation', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCall = calls.find(c =>
c.target === 'get_name' &&
c.source === 'process' &&
c.targetFilePath.includes('models'),
);
expect(getNameCall).toBeDefined();
});
it('emits HAS_METHOD edges linking save and get_name to User', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'save');
const getNameEdge = hasMethod.find(e => e.source === 'User' && e.target === 'get_name');
expect(saveEdge).toBeDefined();
expect(getNameEdge).toBeDefined();
});
});

View file

@ -4,7 +4,7 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES, getRelationships, getNodesByLabel, edgeSet,
FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, edgeSet,
runPipelineFromRepo, type PipelineResult,
} from './helpers.js';
@ -1512,3 +1512,69 @@ describe('Rust struct_pattern destructuring resolution (Phase A)', () => {
expect(saveCalls.length).toBeGreaterThanOrEqual(1);
});
});
// ---------------------------------------------------------------------------
// Phase 14: Cross-file binding propagation
// src/models.rs exports User struct with save() and get_name() methods
// src/factory.rs exports get_user() -> User (uses crate::models::User)
// src/main.rs uses crate::factory::get_user, calls u.save() / u.get_name()
// → u is typed User via cross-file return type propagation
// ---------------------------------------------------------------------------
describe('Rust cross-file binding propagation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(CROSS_FILE_FIXTURES, 'rs-cross-file'),
() => {},
);
}, 60000);
it('detects User struct with save and get_name methods', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Function')).toContain('save');
expect(getNodesByLabel(result, 'Function')).toContain('get_name');
});
it('detects get_user and process functions', () => {
expect(getNodesByLabel(result, 'Function')).toContain('get_user');
expect(getNodesByLabel(result, 'Function')).toContain('process');
});
it('emits IMPORTS edge from main.rs to factory.rs', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(e =>
e.sourceFilePath.includes('main') && e.targetFilePath.includes('factory'),
);
expect(edge).toBeDefined();
});
it('resolves u.save() in process() to User#save via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' &&
c.source === 'process' &&
c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
it('resolves u.get_name() in process() to User#get_name via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCall = calls.find(c =>
c.target === 'get_name' &&
c.source === 'process' &&
c.targetFilePath.includes('models'),
);
expect(getNameCall).toBeDefined();
});
it('emits HAS_METHOD edges linking save and get_name to User', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'save');
const getNameEdge = hasMethod.find(e => e.source === 'User' && e.target === 'get_name');
expect(saveEdge).toBeDefined();
expect(getNameEdge).toBeDefined();
});
});

View file

@ -4,8 +4,9 @@ import { topologicalLevelSort } from '../../src/core/ingestion/pipeline.js';
describe('topologicalLevelSort', () => {
it('returns empty levels for empty graph', () => {
const importMap = new Map<string, Set<string>>();
const levels = topologicalLevelSort(importMap);
const { levels, cycleCount } = topologicalLevelSort(importMap);
expect(levels).toEqual([]);
expect(cycleCount).toBe(0);
});
it('returns single level for files with no imports', () => {
@ -13,7 +14,7 @@ describe('topologicalLevelSort', () => {
['a.ts', new Set()],
['b.ts', new Set()],
]);
const levels = topologicalLevelSort(importMap);
const { levels } = topologicalLevelSort(importMap);
expect(levels).toHaveLength(1);
expect(levels[0]).toContain('a.ts');
expect(levels[0]).toContain('b.ts');
@ -26,7 +27,7 @@ describe('topologicalLevelSort', () => {
['b.ts', new Set(['a.ts'])],
['c.ts', new Set(['b.ts'])],
]);
const levels = topologicalLevelSort(importMap);
const { levels } = topologicalLevelSort(importMap);
expect(levels).toHaveLength(3);
expect(levels[0]).toContain('a.ts');
expect(levels[1]).toContain('b.ts');
@ -40,7 +41,7 @@ describe('topologicalLevelSort', () => {
['b.ts', new Set(['a.ts'])],
['c.ts', new Set(['a.ts'])],
]);
const levels = topologicalLevelSort(importMap);
const { levels } = topologicalLevelSort(importMap);
expect(levels).toHaveLength(2);
expect(levels[0]).toContain('a.ts');
expect(levels[1]).toContain('b.ts');
@ -53,11 +54,12 @@ describe('topologicalLevelSort', () => {
['a.ts', new Set(['b.ts'])],
['b.ts', new Set(['a.ts'])],
]);
const levels = topologicalLevelSort(importMap);
const { levels, cycleCount } = topologicalLevelSort(importMap);
// Both should appear (in a cycle group)
const allFiles = levels.flat();
expect(allFiles).toContain('a.ts');
expect(allFiles).toContain('b.ts');
expect(cycleCount).toBe(2);
});
it('handles disconnected components', () => {
@ -68,7 +70,7 @@ describe('topologicalLevelSort', () => {
['x.ts', new Set()],
['y.ts', new Set(['x.ts'])],
]);
const levels = topologicalLevelSort(importMap);
const { levels } = topologicalLevelSort(importMap);
// Level 0 has both roots, level 1 has both dependents
expect(levels[0]).toContain('a.ts');
expect(levels[0]).toContain('x.ts');
@ -84,7 +86,7 @@ describe('topologicalLevelSort', () => {
['c.ts', new Set(['a.ts'])],
['d.ts', new Set(['b.ts', 'c.ts'])],
]);
const levels = topologicalLevelSort(importMap);
const { levels } = topologicalLevelSort(importMap);
expect(levels).toHaveLength(3);
expect(levels[0]).toContain('a.ts');
expect(levels[1]).toContain('b.ts');
@ -96,7 +98,7 @@ describe('topologicalLevelSort', () => {
const importMap = new Map<string, Set<string>>([
['only.ts', new Set()],
]);
const levels = topologicalLevelSort(importMap);
const { levels } = topologicalLevelSort(importMap);
expect(levels).toHaveLength(1);
expect(levels[0]).toContain('only.ts');
});
@ -106,7 +108,7 @@ describe('topologicalLevelSort', () => {
const importMap = new Map<string, Set<string>>([
['b.ts', new Set(['external.ts'])],
]);
const levels = topologicalLevelSort(importMap);
const { levels } = topologicalLevelSort(importMap);
// external.ts has in-degree 0 (no one depends on it as a key), appears first
// b.ts depends on external.ts so appears after
const allFiles = levels.flat();
@ -125,7 +127,7 @@ describe('topologicalLevelSort', () => {
['b.ts', new Set(['a.ts'])],
['c.ts', new Set(['b.ts'])],
]);
const levels = topologicalLevelSort(importMap);
const { levels } = topologicalLevelSort(importMap);
// No file has in-degree 0 to start: a needs b, b needs a, c needs b
// All end up in the cycle group
const allFiles = levels.flat();
@ -141,7 +143,7 @@ describe('topologicalLevelSort', () => {
['c.ts', new Set(['a.ts'])],
['d.ts', new Set(['b.ts', 'c.ts'])],
]);
const levels = topologicalLevelSort(importMap);
const { levels } = topologicalLevelSort(importMap);
const allFiles = levels.flat();
const uniqueFiles = new Set(allFiles);
expect(uniqueFiles.size).toBe(allFiles.length);

View file

@ -125,35 +125,51 @@ Languages benefiting: Java, Kotlin, C#, C++, TypeScript (overloading). All OOP l
---
### Phase 14: Cross-File Binding Propagation
### Phase 14: Cross-File Binding Propagation
**Problem:** `buildTypeEnv` is per-file. Inferred types don't cross file boundaries.
**Shipped in** `feat/phase14-cross-file-binding-propagation`.
```typescript
// file-a.ts — fixpoint resolves: config → Config
export const config = getConfig();
Three enrichment mechanisms:
- **E1:** `seedCrossFileReceiverTypes` — pre-seeds `receiverTypeName` for single-hop imported receivers (zero re-parse)
- **E2:** `ExportedTypeMap` seeded into `importedBindings` for re-resolution pass
- **E3:** `buildImportedReturnTypes` — cross-file return types for imported callables (local-first, SymbolTable takes precedence)
// file-b.ts — config has no type
import { config } from './file-a';
config.validate(); // missed
```
Architecture:
- Topological import ordering via Kahn's BFS (`topologicalLevelSort`, returns `{ levels, cycleCount }`)
- Cycle-safe: files in cycles grouped in final level, no cross-cycle propagation
- `runCrossFileBindingPropagation()` extracted as standalone pipeline phase
- `synthesizeWildcardImportBindings()` expands whole-module imports (Go/Ruby/C/C++/Swift) into per-symbol namedImportMap entries from graph-exported symbols — runs before Phase 14
- Worker path: `buildExportedTypeMapFromGraph` collects Tier 0 (annotated) exports only
- Sequential path: `collectExportedBindings` captures full fixpoint-inferred exports
**Approach: Export-type index.** After each file's fixpoint, export resolved bindings for exported symbols into `ExportedBindings: Map<filePath, Map<symbolName, typeName>>`. Subsequent files seed scopeEnv from this index for imported symbols.
**Per-language Phase 14 coverage:**
| Language | namedImportMap | ExportedTypeMap (E1/E2) | E3 (importedReturnTypes) | Benefit |
|----------|:-:|:-:|:-:|---|
| TypeScript | Full (named imports) | File-scope vars | Full | **High** |
| JavaScript | Full (named imports) | File-scope vars | Full | **High** |
| Python | from-imports | File-scope vars | Full | **High** |
| Kotlin | Top-level fns | Top-level props | Full | **High** |
| Rust | use clauses | Limited | Full | **High** |
| Go | Synthesized¹ | Exported symbols | Full | **Medium** |
| Ruby | Synthesized¹ | Exported symbols | Full | **Medium** |
| C/C++ | Synthesized¹ | Exported symbols | Full | **Medium** |
| Swift | Synthesized¹ | Exported symbols | Full | **Low** (Phase S blocked) |
| PHP | use classes | Inert (class-scope) | Inert (no fn imports) | **Marginal** |
| Java | Classes + static methods | Inert (no file-scope) | Via SymbolTable | **Medium** |
| C# | Alias + `using static` | Inert (no file-scope) | Via SymbolTable | **Medium** |
**Details:**
- Process files in topological import order (import-processor already builds the dependency graph)
- Re-exports: follow import chain transitively in `ExportedBindings`
- Barrel files (`index.ts`): chain of re-exports — same mechanism
- Default exports: keyed as `"default"` in the map, mapped to local name at import site
- Dynamic imports (`import()`, conditional `require()`): excluded — runtime-only edges
- Circular imports: files in a cycle processed in arbitrary order within the cycle; cross-cycle bindings don't propagate (conservative)
- Parallelism preserved within topological levels
¹ Whole-module import languages: namedImportMap entries synthesized from graph-exported symbols via `synthesizeWildcardImportBindings()` (capped at 1000 per file)
**Why this is last:** Every earlier phase makes the per-file fixpoint stronger, reducing cases where cross-file propagation is needed. This is also the highest-risk architectural change.
**Named binding extraction details:**
- Java: `import static X.Y.method` now captured (static modifier detection). Ambiguous static imports (same method from multiple classes) fall through to Tier 2a for arity narrowing.
- C#: `using static NS.Type;` now captured (last segment as class binding). Non-alias `using NS;` remains unsupported (namespace import requires type inference).
**Risks:** Topological ordering correctness (mitigated by reusing import-processor's existing graph). Re-export chain depth (bounded by import depth, typically 2-3). Memory for `ExportedBindings` (~100K entries for 10K-file monorepo — negligible).
**Resolved limitations (this PR):**
- ~~Worker path vs sequential path quality split~~ — workers now return file-scope TypeEnv bindings; main thread merges fixpoint-inferred exports into ExportedTypeMap (filtered by graph `isExported`)
- ~~`lookupRawReturnType` no cross-file fallback~~ — separate `importedRawReturnTypes` map stores raw declared types (e.g., `User[]`) for for-loop element extraction via `extractElementTypeFromString`
- ~~C++ header method declarations~~ — tree-sitter query fix: `field_identifier` added to declaration pattern alongside `identifier`, plus pointer/reference return type variants
**Impact: High | Effort: High**
**Impact: High | Effort: High** — delivered
---
@ -161,14 +177,14 @@ config.validate(); // missed
```
Milestone D (Phases A, B, C) ✅ ──┐
├──→ Phase 14 (cross-file)
├──→ Phase 14 (cross-file)
Phase P (polymorphism) ───────────┤
Phase S (Swift parity) ───────────┘
Phase P.1P.4 are delivered. P.5 (covariant return types) remains open.
Phase P and Phase S are independent of each other and Phase 14.
Phase 14 benefits from Phase P (better per-file resolution = fewer cross-file gaps).
Phase 14 is delivered. Remaining open: Phase P.5, Phase S.
```
---
@ -184,7 +200,7 @@ Phase 14 benefits from Phase P (better per-file resolution = fewer cross-file ga
- ~~Virtual dispatch: `Dog()` uses `call_expression` (no `new` keyword)~~**RESOLVED** via `detectConstructorType` hook
### All languages
- Cross-file binding propagation → Phase 14
- ~~Cross-file binding propagation → Phase 14~~ — **DELIVERED** for all 13 languages via two mechanisms: (1) named import extraction (TS/JS/Python/Kotlin/Rust/PHP/Java/C#), (2) wildcard import synthesis from graph-exported symbols (Go/Ruby/C/C++/Swift). Remaining gap: C# non-alias `using NS;` (namespace import, requires type inference).
---
@ -206,9 +222,9 @@ Unified fixpoint loop, call-result binding, field access binding, method-call-re
Consolidated Phases 1013 into 3 balanced phases. Loop-fixpoint bridge, MRO-aware inheritance walking, `this`/`self` resolution, object/struct destructuring, null-check narrowing. Kotlin null-check bug fix. Full 11-language integration test coverage.
### Milestone E — Cross-Boundary **next** (Phase 14)
### Milestone E — Cross-Boundary (Phase 14)
Export-type index, cross-file binding propagation.
Export-type index, cross-file binding propagation. Full coverage for TS/JS/Python/Kotlin. Marginal for PHP. Inert for Java/C#/Go/Ruby/C/C++ (relies on Phase 9 SymbolTable).
### Milestone P — Polymorphism & Overloading (Phase P)

View file

@ -9,7 +9,7 @@ This system is designed to be:
- **Conservative** — it prefers missing a binding over introducing a misleading one
- **Walk + fixpoint** — bindings are collected during a single AST walk, then a unified fixpoint loop iterates over pending assignments (copy, callResult, fieldAccess, methodCallResult) until no new bindings are produced
- **Scope-aware** — function-local bindings are isolated from file-level bindings
- **Per-file** — the environment is built for one file at a time, though it may consult the global `SymbolTable` for validation in specific cases
- **Per-file with cross-file seeding** — the environment is built for one file at a time, but Phase 14 seeds upstream bindings (imported types, return types) into the file scope before the fixpoint for all 13 languages
It is **not** a full compiler type checker. Its job is to recover enough type information to improve call-edge accuracy during ingestion.
@ -120,10 +120,16 @@ It does:
It does not:
- perform full semantic type checking
- run fixpoint inference
- propagate inferred bindings across files as ordinary environment entries
- guarantee resolution for every ambiguous construct
It now does (Phase 14):
- run a unified fixpoint loop per file for copy/callResult/fieldAccess/methodCallResult chains
- propagate inferred bindings across files for all 13 supported languages:
- **Named import extraction** (TS/JS/Python/Kotlin/Rust/PHP/Java/C#): per-symbol bindings extracted from import AST nodes
- **Wildcard import synthesis** (Go/Ruby/C/C++/Swift): namedImportMap entries synthesized from graph-exported symbols via `synthesizeWildcardImportBindings()`, enabling cross-file propagation for whole-module-import languages
- seed imported bindings into file scope after walk, before fixpoint (local declarations always win)
---
## TypeEnvironment Model
@ -389,6 +395,7 @@ So return-type-aware receiver inference already exists in a constrained downstre
| Method overload disambiguation | Yes** | No | Yes | Yes | Yes | No | No | No | No | No | No | Yes | No |
| Constructor-visible virtual dispatch | Yes | No | Yes | Yes‡‡ | Yes | No | No | No | No | No | No | Yes§§ | No |
| Optional parameter arity resolution | Yes | No | No | Yes | Yes | No | No | Yes | Yes | Yes | No | Yes | No |
| Cross-file binding propagation | Yes | Yes | Yes‖‖ | Yes | Yes¶¶ | Yes*** | Yes | Yes | Partial | Yes*** | Yes*** | Yes*** | Yes*** |
\* Python class-level annotated attributes (`address: Address`) now resolve `declaredType` correctly. The `self.x` instance attribute pattern is not yet supported.
@ -412,6 +419,12 @@ So return-type-aware receiver inference already exists in a constrained downstre
§§ C++ smart pointer virtual dispatch supported for `make_shared<T>()`/`make_unique<T>()` factory patterns. Raw pointer `new` also supported.
‖‖ Java: `import static X.Y.method` now captured. Ambiguous static imports (same name from multiple classes) fall through to Tier 2a for arity narrowing. Non-static lowercase imports still skipped (package imports).
¶¶ C#: `using static NS.Type;` now captured (last segment as class binding). Non-alias `using NS;` still unsupported — namespace imports can't be reduced to per-symbol bindings without type inference.
\*\*\* Whole-module-import languages (Go, Ruby, C/C++, Swift): namedImportMap entries synthesized from graph-exported symbols via `synthesizeWildcardImportBindings()`. Not from import AST node extraction.
---
## Current Strengths
@ -432,6 +445,7 @@ The current system provides strong value for call resolution because it combines
- method overload disambiguation via argument literal types (Java, Kotlin, C#, C++)
- constructor-visible virtual dispatch for same-file subclasses (Java, C#, TypeScript, C++, Kotlin)
- optional/default parameter arity resolution — calls with omitted optional args still resolve (TS, Python, Kotlin, C#, C++, PHP, Ruby)
- cross-file binding propagation across all 13 languages — named import extraction for languages with per-symbol imports, wildcard import synthesis for whole-module-import languages (Go, Ruby, C/C++, Swift)
This is enough to materially improve call-edge precision even without implementing a full static type system.