feat(type-resolution): per-language cross-file binding tests + resolver fixes

Add cross-file binding propagation integration tests for 6 languages
(Python, JavaScript, Java, PHP, C#, Kotlin) with fixture repos and
HAS_METHOD edge assertions.

Fix 3 language resolver issues uncovered by tests:
- Kotlin: class methods now labeled Method (not Function) via shared
  isKotlinClassMethod() utility; non-aliased imports create NamedImportMap
  entries; 2-segment package-directory fallback for top-level function imports
- PHP: namespace-directory fallback for use-function imports; SuffixIndex
  preferred over linear scan; path traversal rejection; PSR-4 sort cached
- JVM: root-level package path matching; indexOf→lastIndexOf for correctness

Address code review findings:
- Extract runCrossFileBindingPropagation() from 810-line pipeline function
- Replace (importCtx as any) casts with typed dispose() method
- Remove Tarjan's SCC (dev-only YAGNI, ~85 lines)
- Remove dead PARALLEL_RE_RESOLUTION_THRESHOLD constant
- Fix constructor handling divergence in getLabelFromCaptures
- Optimize gap pre-scan with early exit once threshold exceeded
- Fix findEnclosingFunction any→SyntaxNode type
This commit is contained in:
Gergo Magyar 2026-03-20 15:38:27 +00:00
parent 0736bb23bc
commit 6c972079e0
34 changed files with 882 additions and 398 deletions

View file

@ -166,7 +166,7 @@ const TYPE_PRESERVING_METHODS = new Set([
* Returns null if the call is at module/file level (top-level code).
*/
const findEnclosingFunction = (
node: any,
node: SyntaxNode,
filePath: string,
ctx: ResolutionContext
): string | null => {

View file

@ -89,8 +89,10 @@ export interface ImportResolutionContext {
allFilePaths: Set<string>;
allFileList: string[];
normalizedFileList: string[];
suffixIndex: SuffixIndex;
suffixIndex: SuffixIndex | null;
resolveCache: Map<string, string | null>;
/** Release heavyweight fields (suffix index, file lists) to free memory after import resolution. */
dispose(): void;
}
export function buildImportResolutionContext(allPaths: string[]): ImportResolutionContext {
@ -98,7 +100,15 @@ export function buildImportResolutionContext(allPaths: string[]): ImportResoluti
const normalizedFileList = allFileList.map(p => p.replace(/\\/g, '/'));
const allFilePaths = new Set(allFileList);
const suffixIndex = buildSuffixIndex(normalizedFileList, allFileList);
return { allFilePaths, allFileList, normalizedFileList, suffixIndex, resolveCache: new Map() };
const ctx: ImportResolutionContext = {
allFilePaths, allFileList, normalizedFileList, suffixIndex, resolveCache: new Map(),
dispose() {
ctx.suffixIndex = null;
ctx.normalizedFileList = [];
ctx.resolveCache.clear();
},
};
return ctx;
}
// Config loaders extracted to ./language-config.ts (Phase 2 refactor)
@ -169,6 +179,23 @@ function resolveLanguageImport(
memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
}
if (memberResolved) return { kind: 'files', files: [memberResolved] };
// Kotlin: top-level function imports (e.g. import models.getUser) have only 2 segments,
// which resolveJvmMemberImport skips (requires ≥3). Fall back to package-directory scan
// for lowercase last segments (function/property imports). Uppercase last segments
// (class imports like models.User) fall through to standard suffix resolution.
if (language === SupportedLanguages.Kotlin) {
const segments = rawImportPath.split('.');
const lastSeg = segments[segments.length - 1];
if (segments.length >= 2 && lastSeg[0] && lastSeg[0] === lastSeg[0].toLowerCase()) {
const pkgWildcard = segments.slice(0, -1).join('.') + '.*';
let dirFiles = resolveJvmWildcard(pkgWildcard, normalizedFileList, allFileList, exts, index);
if (dirFiles.length === 0) {
dirFiles = resolveJvmWildcard(pkgWildcard, normalizedFileList, allFileList, ['.java'], index);
}
if (dirFiles.length > 0) return { kind: 'files', files: dirFiles };
}
}
// Fall through to standard resolution
}
}

View file

@ -201,13 +201,19 @@ export function extractKotlinNamedBindings(importNode: any): { local: string; ex
}
// Non-aliased: import com.example.User → local="User", exported="User"
// Also handles top-level function imports: import models.getUser → local="getUser"
// Skip wildcard imports (ending in *)
if (fullText.endsWith('.*') || fullText.endsWith('*')) return undefined;
// Skip lowercase last segments — those are member/function imports (e.g.,
// import util.OneArg.writeAudit), not class imports. Multiple member imports
// Skip class-member imports (e.g., import util.OneArg.writeAudit) where the
// second-to-last segment is PascalCase (a class name). Multiple member imports
// with the same function name would collide in NamedImportMap, breaking
// arity-based disambiguation.
if (exportedName[0] && exportedName[0] === exportedName[0].toLowerCase()) return undefined;
// arity-based disambiguation. Top-level function imports (import models.getUser)
// and class imports (import models.User) have package-only prefixes.
const segments = fullText.split('.');
if (segments.length >= 3) {
const parentSegment = segments[segments.length - 2];
if (parentSegment[0] && parentSegment[0] === parentSegment[0].toUpperCase()) return undefined;
}
return [{ local: exportedName, exported: exportedName }];
}

View file

@ -5,7 +5,7 @@ import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
import { generateId } from '../../lib/utils.js';
import { SymbolTable } from './symbol-table.js';
import { ASTCache } from './ast-cache.js';
import { getLanguageFromFilename, yieldToEventLoop, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature } from './utils.js';
import { getLanguageFromFilename, yieldToEventLoop, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature, isKotlinClassMethod } from './utils.js';
import { extractPropertyDeclaredType } from './type-extractors/shared.js';
import { isNodeExported } from './export-detection.js';
import { detectFrameworkFromAST } from './framework-detection.js';
@ -222,7 +222,13 @@ const processParsingSequential = async (
}
if (ancestor) return; // inside a class body — handled by @definition.method
}
nodeLabel = 'Function';
// Kotlin: function_declaration inside a class_body is a method, not a top-level function.
if (language === SupportedLanguages.Kotlin &&
isKotlinClassMethod(captureMap['definition.function'])) {
nodeLabel = 'Method';
}
if (nodeLabel !== 'Method') nodeLabel = 'Function';
}
else if (captureMap['definition.class']) nodeLabel = 'Class';
else if (captureMap['definition.interface']) nodeLabel = 'Interface';

View file

@ -80,92 +80,6 @@ export function topologicalLevelSort(
return levels;
}
/** Cycle decomposition from Tarjan's SCC algorithm. */
export interface ImportCycleInfo {
/** Strongly connected components, each containing ≥2 files */
sccs: readonly (readonly string[])[];
/** Files in cycles (flattened sccs — for backward compat) */
cycleFiles: readonly string[];
}
interface TarjanFrame {
node: string;
neighborIdx: number;
neighbors: readonly string[];
}
/** Decompose cycle files into strongly connected components using iterative Tarjan's.
* Runs on the cycle subgraph only (cycle nodes from Kahn's output), not the full graph.
* Iterative to avoid stack overflow on large repos (V8 stack limit ~10K-15K frames). */
export function computeImportCycleSCCs(
importMap: ReadonlyMap<string, ReadonlySet<string>>,
cycleNodes: readonly string[],
): ImportCycleInfo {
// Build subgraph of only cycle nodes
const cycleSet = new Set(cycleNodes);
const subgraph = new Map<string, readonly string[]>();
for (const node of cycleNodes) {
const deps = importMap.get(node);
subgraph.set(node, deps ? [...deps].filter(d => cycleSet.has(d)) : []);
}
// Iterative Tarjan's on subgraph
const state = new Map<string, { index: number; lowlink: number; onStack: boolean }>();
const stack: string[] = [];
const sccs: string[][] = [];
let nextIndex = 0;
for (const startNode of subgraph.keys()) {
if (state.has(startNode)) continue;
const s = { index: nextIndex, lowlink: nextIndex, onStack: true };
nextIndex++;
state.set(startNode, s);
stack.push(startNode);
const workStack: TarjanFrame[] = [
{ node: startNode, neighborIdx: 0, neighbors: subgraph.get(startNode)! },
];
while (workStack.length > 0) {
const frame = workStack[workStack.length - 1];
if (frame.neighborIdx < frame.neighbors.length) {
const w = frame.neighbors[frame.neighborIdx];
frame.neighborIdx++;
const wState = state.get(w);
if (!wState) {
const ws = { index: nextIndex, lowlink: nextIndex, onStack: true };
nextIndex++;
state.set(w, ws);
stack.push(w);
workStack.push({ node: w, neighborIdx: 0, neighbors: subgraph.get(w)! });
} else if (wState.onStack) {
state.get(frame.node)!.lowlink = Math.min(
state.get(frame.node)!.lowlink, wState.index,
);
}
} else {
workStack.pop();
const vState = state.get(frame.node)!;
if (workStack.length > 0) {
const pState = state.get(workStack[workStack.length - 1].node)!;
pState.lowlink = Math.min(pState.lowlink, vState.lowlink);
}
if (vState.lowlink === vState.index) {
const component: string[] = [];
let w: string;
do { w = stack.pop()!; state.get(w)!.onStack = false; component.push(w); }
while (w !== frame.node);
if (component.length >= 2) sccs.push(component);
}
}
}
}
return {
sccs,
cycleFiles: sccs.flat(),
};
}
/** Max bytes of source content to load per parse chunk. Each chunk's source +
* parsed ASTs + extracted records + worker serialization overhead all live in
* memory simultaneously, so this must be conservative. 20MB source 200-400MB
@ -175,10 +89,147 @@ const CHUNK_BYTE_BUDGET = 20 * 1024 * 1024; // 20MB
/** Max AST trees to keep in LRU cache */
const AST_CACHE_CAP = 50;
/** Threshold for parallel re-resolution within topological levels.
* When more files need re-resolution than this threshold, process in parallel via workers.
* Set to Infinity to disable (current default enable when metrics justify). */
const PARALLEL_RE_RESOLUTION_THRESHOLD = Infinity;
/** Minimum percentage of files that must benefit from cross-file seeding to justify the re-resolution pass. */
const CROSS_FILE_SKIP_THRESHOLD = 0.03;
/** Hard cap on files re-processed during cross-file propagation. */
const MAX_CROSS_FILE_REPROCESS = 2000;
/** 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
* available when downstream files are re-resolved. */
async function runCrossFileBindingPropagation(
graph: ReturnType<typeof createKnowledgeGraph>,
ctx: ReturnType<typeof createResolutionContext>,
exportedTypeMap: ExportedTypeMap,
allPaths: string[],
totalFiles: number,
repoPath: string,
pipelineStart: number,
onProgress: (progress: PipelineProgress) => void,
): Promise<void> {
// For the worker path, buildTypeEnv runs inside workers without SymbolTable,
// so exported bindings must be collected from graph + SymbolTable in main thread.
if (exportedTypeMap.size === 0 && graph.nodeCount > 0) {
const graphExports = buildExportedTypeMapFromGraph(graph, ctx.symbols);
for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports);
}
if (exportedTypeMap.size === 0 || ctx.namedImportMap.size === 0) return;
const allPathSet = new Set(allPaths);
const levels = 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)`);
}
}
// Quick count of files with cross-file binding gaps (early exit once threshold exceeded)
let filesWithGaps = 0;
const gapThreshold = Math.max(1, Math.ceil(totalFiles * CROSS_FILE_SKIP_THRESHOLD));
outer: for (const level of levels) {
for (const filePath of level) {
const imports = ctx.namedImportMap.get(filePath);
if (!imports) continue;
for (const [, binding] of imports) {
const upstream = exportedTypeMap.get(binding.sourcePath);
if (upstream?.has(binding.exportedName)) { filesWithGaps++; break; }
const def = ctx.symbols.lookupExactFull(binding.sourcePath, binding.exportedName);
if (def?.returnType) { filesWithGaps++; break; }
}
if (filesWithGaps >= gapThreshold) break outer;
}
}
const gapRatio = totalFiles > 0 ? filesWithGaps / totalFiles : 0;
if (gapRatio < CROSS_FILE_SKIP_THRESHOLD && filesWithGaps < gapThreshold) {
if (isDev) {
console.log(`⏭️ Cross-file re-resolution skipped (${filesWithGaps}/${totalFiles} files, ${(gapRatio * 100).toFixed(1)}% < ${CROSS_FILE_SKIP_THRESHOLD * 100}% threshold)`);
}
return;
}
onProgress({
phase: 'parsing',
percent: 82,
message: `Cross-file type propagation (${filesWithGaps}+ files)...`,
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
});
let crossFileResolved = 0;
const crossFileStart = Date.now();
let astCache = createASTCache(AST_CACHE_CAP);
for (const level of levels) {
const levelCandidates: { filePath: string; seeded: Map<string, string>; importedReturns: ReadonlyMap<string, string> }[] = [];
for (const filePath of level) {
if (crossFileResolved + levelCandidates.length >= MAX_CROSS_FILE_REPROCESS) break;
const imports = ctx.namedImportMap.get(filePath);
if (!imports) continue;
const seeded = new Map<string, string>();
for (const [localName, binding] of imports) {
const upstream = exportedTypeMap.get(binding.sourcePath);
if (upstream) {
const type = upstream.get(binding.exportedName);
if (type) seeded.set(localName, type);
}
}
const importedReturns = buildImportedReturnTypes(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 });
}
if (levelCandidates.length === 0) continue;
const levelPaths = levelCandidates.map(c => c.filePath);
const contentMap = await readFileContents(repoPath, levelPaths);
for (const { filePath, seeded, importedReturns } of levelCandidates) {
const content = contentMap.get(filePath);
if (!content) continue;
const reFile = [{ path: filePath, content }];
const bindings = new Map<string, ReadonlyMap<string, string>>();
if (seeded.size > 0) bindings.set(filePath, seeded);
const importedReturnTypesMap = new Map<string, ReadonlyMap<string, string>>();
if (importedReturns.size > 0) {
importedReturnTypesMap.set(filePath, importedReturns);
}
await processCalls(graph, reFile, astCache, ctx, undefined, exportedTypeMap, bindings.size > 0 ? bindings : undefined, importedReturnTypesMap.size > 0 ? importedReturnTypesMap : undefined);
crossFileResolved++;
}
if (crossFileResolved >= MAX_CROSS_FILE_REPROCESS) {
if (isDev) console.log(`⚠️ Cross-file re-resolution capped at ${MAX_CROSS_FILE_REPROCESS} files`);
break;
}
}
astCache.clear();
if (isDev) {
const elapsed = Date.now() - crossFileStart;
const totalElapsed = Date.now() - pipelineStart;
const reResolutionPct = totalElapsed > 0 ? ((elapsed / totalElapsed) * 100).toFixed(1) : '0';
console.log(
`🔗 Cross-file re-resolution: ${crossFileResolved} candidates re-processed` +
` in ${elapsed}ms (${reResolutionPct}% of total ingestion time so far)`,
);
}
}
export interface PipelineOptions {
/** Skip MRO, community detection, and process extraction for faster test runs. */
@ -494,173 +545,15 @@ export const runPipelineFromRepo = async (
}
// ── Phase 14: Cross-file binding propagation ──────────────────────
// Seed downstream files with resolved type bindings from upstream files.
// Uses namedImportMap (populated during import processing) to determine
// which exported bindings each file needs. Files processed in topological
// import order so upstream bindings are available when downstream runs.
// For the worker path, buildTypeEnv runs inside workers without SymbolTable,
// so exported bindings must be collected from graph + SymbolTable in main thread.
if (exportedTypeMap.size === 0 && graph.nodeCount > 0) {
const graphExports = buildExportedTypeMapFromGraph(graph, ctx.symbols);
for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports);
}
if (exportedTypeMap.size > 0 && ctx.namedImportMap.size > 0) {
const allPathSet = new Set(allPaths);
const levels = topologicalLevelSort(ctx.importMap);
// E2: SCC diagnostics for import cycles (dev-mode only)
if (isDev && levels.length > 0) {
const lastLevel = levels[levels.length - 1];
// Kahn's dumps cycle files in the last level — detect via positive in-degree check
// If there are cycle files, decompose into SCCs for diagnostics
if (lastLevel.length > 1) {
const cycleInfo = computeImportCycleSCCs(ctx.importMap, lastLevel);
if (cycleInfo.sccs.length > 0) {
console.log(`🔄 Detected ${cycleInfo.sccs.length} import cycle(s) (${cycleInfo.cycleFiles.length} files):`);
for (const scc of cycleInfo.sccs.slice(0, 5)) {
console.log(` Cycle (${scc.length} files): ${scc.slice(0, 3).join(', ')}${scc.length > 3 ? '...' : ''}`);
}
if (cycleInfo.sccs.length > 5) {
console.log(` ... and ${cycleInfo.sccs.length - 5} more cycle(s)`);
}
}
}
}
// Count files that would benefit from cross-file seeding
let filesWithGaps = 0;
for (const level of levels) {
for (const filePath of level) {
const imports = ctx.namedImportMap.get(filePath);
if (!imports) continue;
let hasGap = false;
for (const [, binding] of imports) {
// E1/E2: upstream file has a matching exported binding for this specific import
const upstream = exportedTypeMap.get(binding.sourcePath);
if (upstream?.has(binding.exportedName)) { hasGap = true; break; }
// E3: upstream callable has a known return type in SymbolTable
const def = ctx.symbols.lookupExactFull(binding.sourcePath, binding.exportedName);
if (def?.returnType) { hasGap = true; break; }
}
if (hasGap) filesWithGaps++;
}
}
const CROSS_FILE_SKIP_THRESHOLD = 0.03;
const gapRatio = totalFiles > 0 ? filesWithGaps / totalFiles : 0;
if (gapRatio < CROSS_FILE_SKIP_THRESHOLD) {
if (isDev) {
console.log(`⏭️ Cross-file re-resolution skipped (${filesWithGaps}/${totalFiles} files, ${(gapRatio * 100).toFixed(1)}% < ${CROSS_FILE_SKIP_THRESHOLD * 100}% threshold)`);
}
} else {
onProgress({
phase: 'parsing',
percent: 82,
message: `Cross-file type propagation (${filesWithGaps} files)...`,
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
});
let crossFileResolved = 0;
const crossFileStart = Date.now();
const MAX_CROSS_FILE_REPROCESS = 2000;
astCache = createASTCache(AST_CACHE_CAP);
// ── Worker parallelization design (Phase 14 E4, deferred) ──────────
// Files within the same topological level have no mutual dependencies
// and could be processed in parallel. When PARALLEL_RE_RESOLUTION_THRESHOLD
// is set below Infinity, partition level candidates into worker batches:
// 1. Serialize ExportedTypeMap snapshot + per-file seeded bindings
// 2. Ship to workers via structured clone (Map<string, Map<string, string>> serializes efficiently)
// 3. Workers return updated exports → merge into ExportedTypeMap before next level
// Gating: implement when re-resolution pass exceeds 20% of total ingestion time.
for (const level of levels) {
// Batch: collect files needing re-resolution in this level, then read all at once
const levelCandidates: { filePath: string; seeded: Map<string, string>; importedReturns: ReadonlyMap<string, string> }[] = [];
for (const filePath of level) {
if (crossFileResolved + levelCandidates.length >= MAX_CROSS_FILE_REPROCESS) break;
const imports = ctx.namedImportMap.get(filePath);
if (!imports) continue;
// Build seeded bindings from upstream ExportedTypeMap
const seeded = new Map<string, string>();
for (const [localName, binding] of imports) {
const upstream = exportedTypeMap.get(binding.sourcePath);
if (upstream) {
const type = upstream.get(binding.exportedName);
if (type) seeded.set(localName, type);
}
}
// E3: Build cross-file return types for imported callables
const importedReturns = buildImportedReturnTypes(filePath, ctx.namedImportMap, ctx.symbols);
// Skip if neither variable bindings nor callable return types are available
if (seeded.size === 0 && importedReturns.size === 0) continue;
// Validate path before re-reading (defense-in-depth)
if (!allPathSet.has(filePath)) continue;
const lang = getLanguageFromFilename(filePath);
if (!lang || !isLanguageAvailable(lang)) continue;
levelCandidates.push({ filePath, seeded, importedReturns });
}
if (levelCandidates.length === 0) continue;
// Batch read all files in this level at once (avoids per-file I/O overhead)
const levelPaths = levelCandidates.map(c => c.filePath);
const contentMap = await readFileContents(repoPath, levelPaths);
for (const { filePath, seeded, importedReturns } of levelCandidates) {
const content = contentMap.get(filePath);
if (!content) continue;
// Re-parse and re-resolve calls with cross-file seeded type environment
// Reuse the level-scoped AST cache (LRU eviction handles capacity)
const reFile = [{ path: filePath, content }];
const bindings = new Map<string, ReadonlyMap<string, string>>();
if (seeded.size > 0) bindings.set(filePath, seeded);
const importedReturnTypesMap = new Map<string, ReadonlyMap<string, string>>();
if (importedReturns.size > 0) {
importedReturnTypesMap.set(filePath, importedReturns);
}
await processCalls(graph, reFile, astCache, ctx, undefined, exportedTypeMap, bindings.size > 0 ? bindings : undefined, importedReturnTypesMap.size > 0 ? importedReturnTypesMap : undefined);
crossFileResolved++;
}
if (crossFileResolved >= MAX_CROSS_FILE_REPROCESS) {
if (isDev) console.log(`⚠️ Cross-file re-resolution capped at ${MAX_CROSS_FILE_REPROCESS} files`);
break;
}
}
// Clear AST cache after re-resolution pass completes
astCache.clear();
if (isDev) {
const elapsed = Date.now() - crossFileStart;
const totalElapsed = Date.now() - pipelineStart;
const reResolutionPct = totalElapsed > 0 ? ((elapsed / totalElapsed) * 100).toFixed(1) : '0';
console.log(
`🔗 Cross-file re-resolution: ${crossFileResolved}/${filesWithGaps} candidates re-processed` +
` in ${elapsed}ms (${reResolutionPct}% of total ingestion time so far)`,
);
}
}
}
await runCrossFileBindingPropagation(
graph, ctx, exportedTypeMap, allPaths, totalFiles, repoPath, pipelineStart, onProgress,
);
// Free import resolution context — suffix index + resolve cache no longer needed
// (allPathObjects and importCtx hold ~94MB+ for large repos)
allPathObjects.length = 0;
importCtx.resolveCache.clear();
(importCtx as any).suffixIndex = null;
(importCtx as any).normalizedFileList = null;
importCtx.dispose();
let communityResult: Awaited<ReturnType<typeof processCommunities>> | undefined;
let processResult: Awaited<ReturnType<typeof processProcesses>> | undefined;

View file

@ -39,26 +39,39 @@ export function resolveJvmWildcard(
const candidates = extensions.flatMap(ext => index.getFilesInDir(packagePath, ext));
// Filter to only direct children (no subdirectories)
const packageSuffix = '/' + packagePath + '/';
const packagePrefix = packagePath + '/';
return candidates.filter(f => {
const normalized = f.replace(/\\/g, '/');
const idx = normalized.indexOf(packageSuffix);
if (idx < 0) return false;
const afterPkg = normalized.substring(idx + packageSuffix.length);
// Match both nested (src/models/User.kt) and root-level (models/User.kt) packages
let afterPkg: string;
const idx = normalized.lastIndexOf(packageSuffix);
if (idx >= 0) {
afterPkg = normalized.substring(idx + packageSuffix.length);
} else if (normalized.startsWith(packagePrefix)) {
afterPkg = normalized.substring(packagePrefix.length);
} else {
return false;
}
return !afterPkg.includes('/');
});
}
// Fallback: linear scan
const packageSuffix = '/' + packagePath + '/';
const packagePrefix = packagePath + '/';
const matches: string[] = [];
for (let i = 0; i < normalizedFileList.length; i++) {
const normalized = normalizedFileList[i];
if (normalized.includes(packageSuffix) &&
extensions.some(ext => normalized.endsWith(ext))) {
const afterPackage = normalized.substring(normalized.indexOf(packageSuffix) + packageSuffix.length);
if (!afterPackage.includes('/')) {
matches.push(allFileList[i]);
}
if (!extensions.some(ext => normalized.endsWith(ext))) continue;
// Match both nested (src/models/User.kt) and root-level (models/User.kt) packages
let afterPackage: string | null = null;
if (normalized.includes(packageSuffix)) {
afterPackage = normalized.substring(normalized.lastIndexOf(packageSuffix) + packageSuffix.length);
} else if (normalized.startsWith(packagePrefix)) {
afterPackage = normalized.substring(packagePrefix.length);
}
if (afterPackage !== null && !afterPackage.includes('/')) {
matches.push(allFileList[i]);
}
}
return matches;

View file

@ -10,11 +10,34 @@ import { suffixResolve } from './utils.js';
export interface ComposerConfig {
/** Map of namespace prefix -> directory (e.g., "App\\" -> "app/") */
psr4: Map<string, string>;
/** PSR-4 entries sorted by namespace length descending (longest match wins).
* Cached once at config load time to avoid re-sorting on every import. */
psr4Sorted?: readonly [string, string][];
}
/** Get or compute the sorted PSR-4 entries (cached after first call). */
function getSortedPsr4(config: ComposerConfig): readonly [string, string][] {
if (!config.psr4Sorted) {
const sorted = [...config.psr4.entries()].sort((a, b) => b[0].length - a[0].length);
config.psr4Sorted = sorted;
}
return config.psr4Sorted;
}
/**
* Resolve a PHP use-statement import path using PSR-4 mappings.
* e.g. "App\Http\Controllers\UserController" -> "app/Http/Controllers/UserController.php"
*
* For function/constant imports (use function App\Models\getUser), the last
* segment is the symbol name, not a class name, so it may not map directly to
* a file. When PSR-4 class-style resolution fails, we fall back to scanning
* .php files in the namespace directory.
*
* NOTE: The function-import fallback returns the first matching .php file in the
* namespace directory. When multiple files exist in the same namespace directory,
* resolution is non-deterministic (depends on Set/index iteration order). This is
* a known limitation PHP function imports cannot be resolved to a specific file
* without parsing all candidate files.
*/
export function resolvePhpImport(
importPath: string,
@ -27,20 +50,44 @@ export function resolvePhpImport(
// Normalize: replace backslashes with forward slashes
const normalized = importPath.replace(/\\/g, '/');
// Try PSR-4 resolution if composer.json was found
// Reject path traversal attempts (defense-in-depth — walker whitelist also prevents this)
if (normalized.includes('..')) return null;
if (composerConfig) {
// Sort namespaces by length descending (longest match wins)
const sorted = [...composerConfig.psr4.entries()].sort((a, b) => b[0].length - a[0].length);
const sorted = getSortedPsr4(composerConfig);
for (const [nsPrefix, dirPrefix] of sorted) {
const nsPrefixSlash = nsPrefix.replace(/\\/g, '/');
if (normalized.startsWith(nsPrefixSlash + '/') || normalized === nsPrefixSlash) {
const remainder = normalized.slice(nsPrefixSlash.length).replace(/^\//, '');
// 1. Try class-style PSR-4: full path → file (e.g. App\Models\User → app/Models/User.php)
const filePath = dirPrefix + (remainder ? '/' + remainder : '') + '.php';
if (allFiles.has(filePath)) return filePath;
if (index) {
const result = index.getInsensitive(filePath);
if (result) return result;
}
// 2. Function/constant fallback: strip last segment (symbol name), scan namespace directory.
// e.g. App\Models\getUser → directory app/Models/, find first .php file in that dir.
const lastSlash = remainder.lastIndexOf('/');
const nsDir = lastSlash >= 0
? dirPrefix + '/' + remainder.slice(0, lastSlash)
: dirPrefix;
// Prefer SuffixIndex directory lookup (O(log n + matches)) over linear scan
if (index) {
const candidates = index.getFilesInDir(nsDir, '.php');
if (candidates.length > 0) return candidates[0];
}
// Fallback: linear scan (only when SuffixIndex unavailable)
const nsDirPrefix = nsDir.endsWith('/') ? nsDir : nsDir + '/';
for (const f of allFiles) {
if (f.startsWith(nsDirPrefix) && f.endsWith('.php') && !f.slice(nsDirPrefix.length).includes('/')) {
return f;
}
}
}
}
}

View file

@ -260,6 +260,18 @@ export const BUILT_IN_NAMES = new Set([
/** Check if a name is a built-in function or common noise that should be filtered out */
export const isBuiltInOrNoise = (name: string): boolean => BUILT_IN_NAMES.has(name);
/** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method).
* Kotlin grammar uses function_declaration for both top-level functions and class methods.
* Returns true when the captured definition node has a class_body ancestor. */
export function isKotlinClassMethod(captureNode: { parent?: any } | null | undefined): boolean {
let ancestor = captureNode?.parent;
while (ancestor) {
if (ancestor.type === 'class_body') return true;
ancestor = ancestor.parent;
}
return false;
}
/** AST node types that represent a class-like container (for HAS_METHOD edge extraction) */
export const CLASS_CONTAINER_TYPES = new Set([
'class_declaration', 'abstract_class_declaration',
@ -461,6 +473,12 @@ export const extractFunctionName = (node: SyntaxNode): { funcName: string | null
}
}
funcName = nameNode?.text;
// Kotlin: function_declaration inside a class_body is a method, not a top-level function.
// Must match the label assigned in parse-worker.ts for consistent generateId() output.
if (funcName && node.type === 'function_declaration' && isKotlinClassMethod(node)) {
label = 'Method';
}
}
} else if (node.type === 'impl_item') {
let funcItem: SyntaxNode | null = null;

View file

@ -31,6 +31,7 @@ import {
isBuiltInOrNoise,
getDefinitionNodeFromCaptures,
findEnclosingClassId,
isKotlinClassMethod,
extractMethodSignature,
countCallArguments,
inferCallForm,
@ -256,7 +257,8 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
const getLabelFromCaptures = (captureMap: Record<string, any>): NodeLabel | null => {
// Skip imports (handled separately) and calls
if (captureMap['import'] || captureMap['call']) return null;
if (!captureMap['name']) return null;
// Allow constructors without explicit @name capture (e.g. Swift init) — name synthesized downstream
if (!captureMap['name'] && !captureMap['definition.constructor']) return null;
if (captureMap['definition.function']) return 'Function';
if (captureMap['definition.class']) return 'Class';
@ -1144,7 +1146,7 @@ const processFileGroup = (
}
}
const nodeLabel = getLabelFromCaptures(captureMap);
let nodeLabel = getLabelFromCaptures(captureMap);
if (!nodeLabel) continue;
// C/C++: @definition.function is broad and also matches inline class methods (inside
@ -1164,6 +1166,12 @@ const processFileGroup = (
if (ancestor) continue; // found a class/struct ancestor → skip
}
// Kotlin: function_declaration inside a class_body is a method, not a top-level function.
if (language === SupportedLanguages.Kotlin && nodeLabel === 'Function' &&
isKotlinClassMethod(captureMap['definition.function'])) {
nodeLabel = 'Method';
}
const nameNode = captureMap['name'];
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
if (!nameNode && nodeLabel !== 'Constructor') continue;

View file

@ -0,0 +1,14 @@
using static CrossFile.Models.UserFactory;
namespace CrossFile.App
{
public class Program
{
public void Run()
{
var u = GetUser();
u.Save();
u.GetName();
}
}
}

View file

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>CrossFile</RootNamespace>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,8 @@
namespace CrossFile.Models
{
public class User
{
public void Save() {}
public string GetName() { return ""; }
}
}

View file

@ -0,0 +1,7 @@
namespace CrossFile.Models
{
public static class UserFactory
{
public static User GetUser() { return new User(); }
}
}

View file

@ -0,0 +1,11 @@
package app;
import static models.UserFactory.getUser;
public class App {
public void run() {
var user = getUser();
user.save();
user.getName();
}
}

View file

@ -0,0 +1,6 @@
package models;
public class User {
public void save() {}
public String getName() { return ""; }
}

View file

@ -0,0 +1,7 @@
package models;
public class UserFactory {
public static User getUser() {
return new User();
}
}

View file

@ -0,0 +1,7 @@
import { getUser } from './models';
export function run() {
const u = getUser();
u.save();
u.getName();
}

View file

@ -0,0 +1,8 @@
export class User {
save() {}
getName() { return ''; }
}
export function getUser() {
return new User();
}

View file

@ -0,0 +1,11 @@
package app
import models.getUser
class App {
fun run() {
val u = getUser()
u.save()
u.getName()
}
}

View file

@ -0,0 +1,8 @@
package models
class User {
fun save() {}
fun getName(): String = ""
}
fun getUser(): User = User()

View file

@ -0,0 +1,13 @@
<?php
namespace App;
use function App\Models\getUser;
class Main {
public function run(): void {
$u = getUser();
$u->save();
$u->getName();
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Models;
class User {
public function save(): void {}
public function getName(): string { return ''; }
}

View file

@ -0,0 +1,7 @@
<?php
namespace App\Models;
function getUser(): User {
return new User();
}

View file

@ -0,0 +1,7 @@
{
"autoload": {
"psr-4": {
"App\\": "app/"
}
}
}

View file

@ -0,0 +1,6 @@
from models import get_user
def run():
u = get_user()
u.save()
u.get_name()

View file

@ -0,0 +1,9 @@
class User:
def save(self):
pass
def get_name(self) -> str:
return ''
def get_user() -> User:
return User()

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';
@ -1517,3 +1517,73 @@ describe('C# optional parameter arity resolution', () => {
expect(greetCalls.length).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Phase 14: Cross-file binding propagation
// Models/UserFactory.cs exports static GetUser() returning User
// App/Program.cs uses static import, calls var u = GetUser(); u.Save()
// → u 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, 'csharp-cross-file'),
() => {},
);
}, 60000);
it('detects User class with Save and GetName methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('Save');
expect(getNodesByLabel(result, 'Method')).toContain('GetName');
});
it('detects UserFactory class with GetUser method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('UserFactory');
expect(getNodesByLabel(result, 'Method')).toContain('GetUser');
});
it('detects Program class with Run method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Program');
expect(getNodesByLabel(result, 'Method')).toContain('Run');
});
it('emits IMPORTS edge from Program.cs to UserFactory.cs', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(e =>
e.sourceFilePath.includes('Program') && e.targetFilePath.includes('UserFactory'),
);
expect(edge).toBeDefined();
});
it('resolves u.Save() in Run() to User#Save via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'Save' &&
c.source === 'Run' &&
c.targetFilePath.includes('User.cs'),
);
expect(saveCall).toBeDefined();
});
it('resolves u.GetName() in Run() to User#GetName via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCall = calls.find(c =>
c.target === 'GetName' &&
c.source === 'Run' &&
c.targetFilePath.includes('User.cs'),
);
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

@ -8,6 +8,7 @@ import type { PipelineResult } from '../../../src/types/pipeline.js';
import type { GraphRelationship } from '../../../src/core/graph/types.js';
export const FIXTURES = path.resolve(__dirname, '..', '..', 'fixtures', 'lang-resolution');
export const CROSS_FILE_FIXTURES = path.resolve(__dirname, '..', '..', 'fixtures', 'cross-file-binding');
export type RelEdge = {
source: string;

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';
@ -1396,3 +1396,73 @@ describe('Java virtual dispatch via constructor type (same-file)', () => {
expect(fetchCalls.length).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Phase 14: Cross-file binding propagation
// models/UserFactory.java exports static getUser() returning User
// app/App.java static-imports getUser, calls var user = getUser(); user.save()
// → user is typed User via cross-file return type propagation
// ---------------------------------------------------------------------------
describe('Java cross-file binding propagation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(CROSS_FILE_FIXTURES, 'java-cross-file'),
() => {},
);
}, 60000);
it('detects User class with save and getName methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('save');
expect(getNodesByLabel(result, 'Method')).toContain('getName');
});
it('detects UserFactory class with getUser method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('UserFactory');
expect(getNodesByLabel(result, 'Method')).toContain('getUser');
});
it('detects App class with run method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('App');
expect(getNodesByLabel(result, 'Method')).toContain('run');
});
it('emits IMPORTS edge from App.java to UserFactory.java', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(e =>
e.sourceFilePath.includes('App') && e.targetFilePath.includes('UserFactory'),
);
expect(edge).toBeDefined();
});
it('resolves user.save() in run() to User#save via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' &&
c.source === 'run' &&
c.targetFilePath.includes('User.java'),
);
expect(saveCall).toBeDefined();
});
it('resolves user.getName() in run() to User#getName via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCall = calls.find(c =>
c.target === 'getName' &&
c.source === 'run' &&
c.targetFilePath.includes('User.java'),
);
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

@ -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';
@ -354,3 +354,68 @@ describe('JavaScript post-fixpoint for-loop replay (Phase A ex-9B)', () => {
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase 14: Cross-file binding propagation
// models.js exports getUser() returning User
// app.js imports getUser, calls const u = getUser(); u.save(); u.getName()
// → u is typed User via cross-file return type propagation
// ---------------------------------------------------------------------------
describe('JavaScript cross-file binding propagation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(CROSS_FILE_FIXTURES, 'js-cross-file'),
() => {},
);
}, 60000);
it('detects User class with save and getName methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('save');
expect(getNodesByLabel(result, 'Method')).toContain('getName');
});
it('detects getUser and run functions', () => {
expect(getNodesByLabel(result, 'Function')).toContain('getUser');
expect(getNodesByLabel(result, 'Function')).toContain('run');
});
it('emits IMPORTS edge from app.js to models.js', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(e =>
e.sourceFilePath.includes('app') && e.targetFilePath.includes('models'),
);
expect(edge).toBeDefined();
});
it('resolves u.save() in run() to User#save via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' &&
c.source === 'run' &&
c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
it('resolves u.getName() in run() to User#getName via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCall = calls.find(c =>
c.target === 'getName' &&
c.source === 'run' &&
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

@ -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';
@ -27,8 +27,9 @@ describe('Kotlin heritage resolution', () => {
expect(getNodesByLabel(result, 'Interface')).toEqual(['Serializable', 'Validatable']);
});
it('detects 6 functions (interface declarations + implementations + service)', () => {
expect(getNodesByLabel(result, 'Function')).toEqual([
it('detects 6 class/interface methods (processUser is inside UserService)', () => {
expect(getNodesByLabel(result, 'Function')).toEqual([]);
expect(getNodesByLabel(result, 'Method')).toEqual([
'processUser', 'save', 'serialize', 'serialize', 'validate', 'validate',
]);
});
@ -199,10 +200,9 @@ describe('Kotlin member-call resolution', () => {
expect(saveCall!.targetFilePath).toBe('models/User.kt');
});
it('detects User class and save function (Kotlin fns are Function nodes)', () => {
it('detects User class and save method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
// Kotlin tree-sitter captures all function_declaration as Function, including class methods
expect(getNodesByLabel(result, 'Function')).toContain('save');
expect(getNodesByLabel(result, 'Method')).toContain('save');
});
});
@ -220,12 +220,11 @@ describe('Kotlin receiver-constrained resolution', () => {
);
}, 60000);
it('detects User and Repo classes, both with save functions', () => {
it('detects User and Repo classes, both with save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
// Kotlin tree-sitter captures all function_declaration as Function
const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save');
expect(saveFns.length).toBe(2);
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
expect(saveMethods.length).toBe(2);
});
it('resolves user.save() to User.save and repo.save() to Repo.save via receiver typing', () => {
@ -259,9 +258,8 @@ describe('Kotlin alias import resolution', () => {
it('detects User and Repo classes with their methods', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']);
// Kotlin tree-sitter captures all function_declaration as Function, including class methods
expect(getNodesByLabel(result, 'Function')).toContain('save');
expect(getNodesByLabel(result, 'Function')).toContain('persist');
expect(getNodesByLabel(result, 'Method')).toContain('save');
expect(getNodesByLabel(result, 'Method')).toContain('persist');
});
it('resolves u.save() to models/Models.kt and r.persist() to models/Models.kt via alias', () => {
@ -295,7 +293,7 @@ describe('Kotlin constructor-call resolution', () => {
it('detects User class with save method and main function', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Function')).toContain('save');
expect(getNodesByLabel(result, 'Method')).toContain('save');
expect(getNodesByLabel(result, 'Function')).toContain('main');
});
@ -395,11 +393,11 @@ describe('Kotlin constructor-inferred type resolution', () => {
);
}, 60000);
it('detects User and Repo classes, both with save functions', () => {
it('detects User and Repo classes, both with save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save');
expect(saveFns.length).toBe(2);
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
expect(saveMethods.length).toBe(2);
});
it('resolves user.save() to models/User.kt via constructor-inferred type', () => {
@ -477,8 +475,8 @@ describe('Kotlin return type inference', () => {
it('detects User and Repo classes with competing save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save');
expect(saveFns.length).toBe(2);
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
expect(saveMethods.length).toBe(2);
});
it('resolves user.save() to User#save via return type inference', () => {
@ -590,11 +588,11 @@ describe('Kotlin for-each loop type resolution', () => {
);
}, 60000);
it('detects User and Repo classes, both with save functions', () => {
it('detects User and Repo classes, both with save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save');
expect(saveFns.length).toBe(2);
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
expect(saveMethods.length).toBe(2);
});
it('resolves user.save() inside for-each to models/User.kt', () => {
@ -673,7 +671,7 @@ describe('Kotlin nullable receiver resolution (safe calls)', () => {
it('detects User and Repo classes with competing save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveMethods = getNodesByLabel(result, 'Function').filter((m: string) => m === 'save');
const saveMethods = getNodesByLabel(result, 'Method').filter((m: string) => m === 'save');
expect(saveMethods.length).toBe(2);
});
@ -717,11 +715,11 @@ describe('Kotlin assignment chain propagation', () => {
);
}, 60000);
it('detects User and Repo classes each with a save function', () => {
it('detects User and Repo classes each with a save method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save');
expect(saveFns.length).toBe(2);
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
expect(saveMethods.length).toBe(2);
});
it('resolves alias.save() to User#save via assignment chain', () => {
@ -780,11 +778,11 @@ describe('Kotlin assignment chain inside class method', () => {
);
}, 60000);
it('detects User and Repo classes each with a save function', () => {
it('detects User and Repo classes each with a save method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save');
expect(saveFns.length).toBe(2);
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
expect(saveMethods.length).toBe(2);
});
it('resolves alias.save() to User#save via chain inside function', () => {
@ -843,10 +841,10 @@ describe('Kotlin chained method call resolution (Phase 5 review fix)', () => {
expect(classes).toContain('UserService');
});
it('detects getUser and save functions', () => {
const fns = getNodesByLabel(result, 'Function');
expect(fns).toContain('getUser');
expect(fns).toContain('save');
it('detects getUser and save methods', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('getUser');
expect(methods).toContain('save');
});
it('resolves svc.getUser().save() to User#save via chain resolution', () => {
@ -928,11 +926,11 @@ describe('Kotlin when/is pattern binding', () => {
);
}, 60000);
it('detects User and Repo classes, both with save functions', () => {
it('detects User and Repo classes, both with save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save');
expect(saveFns.length).toBe(2);
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
expect(saveMethods.length).toBe(2);
});
it('resolves obj.save() in when/is User arm to models/User.kt', () => {
@ -1182,8 +1180,8 @@ describe('Kotlin for-loop call_expression iterable resolution (Phase 7.3)', () =
it('detects User and Repo classes with competing save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save');
expect(saveFns.length).toBe(2);
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
expect(saveMethods.length).toBe(2);
});
it('resolves user.save() in for-loop over getUsers() to User#save', () => {
@ -1545,8 +1543,8 @@ describe('Kotlin overload disambiguation by parameter types', () => {
);
}, 60000);
it('detects lookup function with parameterTypes on graph node', () => {
const nodes = getNodesByLabelFull(result, 'Function');
it('detects lookup method with parameterTypes on graph node', () => {
const nodes = getNodesByLabelFull(result, 'Method');
const lookupNodes = nodes.filter(m => m.name === 'lookup');
expect(lookupNodes.length).toBe(1);
expect(lookupNodes[0].properties.parameterTypes).toEqual(['Int']);
@ -1604,3 +1602,69 @@ describe('Kotlin default parameter arity resolution', () => {
expect(greetCalls.length).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Phase 14: Cross-file binding propagation
// models/User.kt exports top-level getUser(): User
// app/App.kt imports getUser, calls val u = getUser(); u.save(); u.getName()
// → u is typed User via cross-file return type propagation
// ---------------------------------------------------------------------------
describe('Kotlin cross-file binding propagation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(CROSS_FILE_FIXTURES, 'kotlin-cross-file'),
() => {},
);
}, 60000);
it('detects User class with save and getName methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('save');
expect(getNodesByLabel(result, 'Method')).toContain('getName');
});
it('detects getUser and App class with run method', () => {
expect(getNodesByLabel(result, 'Function')).toContain('getUser');
expect(getNodesByLabel(result, 'Class')).toContain('App');
expect(getNodesByLabel(result, 'Method')).toContain('run');
});
it('emits IMPORTS edge from App.kt to User.kt', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(e =>
e.sourceFilePath.includes('App') && e.targetFilePath.includes('User'),
);
expect(edge).toBeDefined();
});
it('resolves u.save() in run() to User#save via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' &&
c.source === 'run' &&
c.targetFilePath.includes('User'),
);
expect(saveCall).toBeDefined();
});
it('resolves u.getName() in run() to User#getName via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCall = calls.find(c =>
c.target === 'getName' &&
c.source === 'run' &&
c.targetFilePath.includes('User'),
);
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

@ -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';
@ -1449,3 +1449,69 @@ describe('PHP grandparent method resolution via MRO (Phase B)', () => {
expect(greetCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase 14: Cross-file binding propagation
// Models/UserFactory.php exports function getUser(): User
// Main.php imports getUser via use function, calls $u = getUser(); $u->save()
// → $u is typed User via cross-file return type propagation
// ---------------------------------------------------------------------------
describe('PHP cross-file binding propagation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(CROSS_FILE_FIXTURES, 'php-cross-file'),
() => {},
);
}, 60000);
it('detects User class with save and getName methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('save');
expect(getNodesByLabel(result, 'Method')).toContain('getName');
});
it('detects getUser function and Main class with run method', () => {
expect(getNodesByLabel(result, 'Function')).toContain('getUser');
expect(getNodesByLabel(result, 'Class')).toContain('Main');
expect(getNodesByLabel(result, 'Method')).toContain('run');
});
it('emits IMPORTS edge from Main.php to UserFactory.php', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(e =>
e.sourceFilePath.includes('Main') && e.targetFilePath.includes('UserFactory'),
);
expect(edge).toBeDefined();
});
it('resolves $u->save() in run() to User#save via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' &&
c.source === 'run' &&
c.targetFilePath.includes('User.php'),
);
expect(saveCall).toBeDefined();
});
it('resolves $u->getName() in run() to User#getName via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCall = calls.find(c =>
c.target === 'getName' &&
c.source === 'run' &&
c.targetFilePath.includes('User.php'),
);
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

@ -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';
@ -1499,3 +1499,68 @@ describe('Python default parameter arity resolution', () => {
expect(searchCalls.length).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Phase 14: Cross-file binding propagation
// models.py exports get_user() -> User
// app.py imports get_user, calls u = get_user(); u.save(); u.get_name()
// → u is typed User via cross-file return type propagation
// ---------------------------------------------------------------------------
describe('Python cross-file binding propagation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(CROSS_FILE_FIXTURES, 'py-cross-file'),
() => {},
);
}, 60000);
it('detects User class with save and get_name methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Function')).toContain('save');
expect(getNodesByLabel(result, 'Function')).toContain('get_name');
});
it('detects get_user and run functions', () => {
expect(getNodesByLabel(result, 'Function')).toContain('get_user');
expect(getNodesByLabel(result, 'Function')).toContain('run');
});
it('emits IMPORTS edge from app.py to models.py', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(e =>
e.sourceFilePath.includes('app') && e.targetFilePath.includes('models'),
);
expect(edge).toBeDefined();
});
it('resolves u.save() in run() to User#save via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' &&
c.source === 'run' &&
c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
it('resolves u.get_name() in run() 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 === 'run' &&
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

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { topologicalLevelSort, computeImportCycleSCCs } from '../../src/core/ingestion/pipeline.js';
import { topologicalLevelSort } from '../../src/core/ingestion/pipeline.js';
describe('topologicalLevelSort', () => {
it('returns empty levels for empty graph', () => {
@ -148,80 +148,3 @@ describe('topologicalLevelSort', () => {
expect(uniqueFiles.size).toBe(4);
});
});
describe('computeImportCycleSCCs', () => {
it('returns no SCCs for empty cycle nodes', () => {
const importMap = new Map<string, Set<string>>();
const result = computeImportCycleSCCs(importMap, []);
expect(result.sccs).toHaveLength(0);
expect(result.cycleFiles).toHaveLength(0);
});
it('detects a simple two-node cycle A→B→A as one SCC', () => {
const importMap = new Map<string, Set<string>>([
['a.ts', new Set(['b.ts'])],
['b.ts', new Set(['a.ts'])],
]);
const result = computeImportCycleSCCs(importMap, ['a.ts', 'b.ts']);
expect(result.sccs).toHaveLength(1);
expect(result.sccs[0]).toHaveLength(2);
expect(result.sccs[0]).toContain('a.ts');
expect(result.sccs[0]).toContain('b.ts');
expect(result.cycleFiles).toContain('a.ts');
expect(result.cycleFiles).toContain('b.ts');
});
it('detects two independent cycles as two SCCs', () => {
// A→B→A and C→D→C are separate cycles
const importMap = new Map<string, Set<string>>([
['a.ts', new Set(['b.ts'])],
['b.ts', new Set(['a.ts'])],
['c.ts', new Set(['d.ts'])],
['d.ts', new Set(['c.ts'])],
]);
const result = computeImportCycleSCCs(importMap, ['a.ts', 'b.ts', 'c.ts', 'd.ts']);
expect(result.sccs).toHaveLength(2);
const allInSccs = result.sccs.flat();
expect(allInSccs).toContain('a.ts');
expect(allInSccs).toContain('b.ts');
expect(allInSccs).toContain('c.ts');
expect(allInSccs).toContain('d.ts');
});
it('filters out single-node components (not true cycles)', () => {
// A single node with no self-loop is not an SCC of size ≥2
const importMap = new Map<string, Set<string>>([
['a.ts', new Set()],
]);
const result = computeImportCycleSCCs(importMap, ['a.ts']);
expect(result.sccs).toHaveLength(0);
expect(result.cycleFiles).toHaveLength(0);
});
it('detects a three-node cycle A→B→C→A as one SCC alongside an independent two-node cycle', () => {
const importMap = new Map<string, Set<string>>([
['a.ts', new Set(['b.ts'])],
['b.ts', new Set(['c.ts'])],
['c.ts', new Set(['a.ts'])],
['d.ts', new Set(['e.ts'])],
['e.ts', new Set(['d.ts'])],
]);
const result = computeImportCycleSCCs(importMap, ['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts']);
expect(result.sccs).toHaveLength(2);
const sizes = result.sccs.map(s => s.length).sort((x, y) => x - y);
expect(sizes).toEqual([2, 3]);
});
it('cycleFiles is the union of all SCC members', () => {
const importMap = new Map<string, Set<string>>([
['a.ts', new Set(['b.ts'])],
['b.ts', new Set(['a.ts'])],
['c.ts', new Set(['d.ts'])],
['d.ts', new Set(['c.ts'])],
]);
const cycleNodes = ['a.ts', 'b.ts', 'c.ts', 'd.ts'];
const result = computeImportCycleSCCs(importMap, cycleNodes);
expect(new Set(result.cycleFiles)).toEqual(new Set(result.sccs.flat()));
expect(result.cycleFiles).toHaveLength(4);
});
});