From 6c972079e073d21db9067b31a4e5ae9b9fb805bf Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Fri, 20 Mar 2026 15:38:27 +0000 Subject: [PATCH] feat(type-resolution): per-language cross-file binding tests + resolver fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- gitnexus/src/core/ingestion/call-processor.ts | 2 +- .../src/core/ingestion/import-processor.ts | 31 +- .../ingestion/named-binding-extraction.ts | 14 +- .../src/core/ingestion/parsing-processor.ts | 10 +- gitnexus/src/core/ingestion/pipeline.ts | 397 +++++++----------- gitnexus/src/core/ingestion/resolvers/jvm.ts | 31 +- gitnexus/src/core/ingestion/resolvers/php.ts | 53 ++- gitnexus/src/core/ingestion/utils.ts | 18 + .../core/ingestion/workers/parse-worker.ts | 12 +- .../csharp-cross-file/App/Program.cs | 14 + .../csharp-cross-file/CrossFile.csproj | 5 + .../csharp-cross-file/Models/User.cs | 8 + .../csharp-cross-file/Models/UserFactory.cs | 7 + .../java-cross-file/app/App.java | 11 + .../java-cross-file/models/User.java | 6 + .../java-cross-file/models/UserFactory.java | 7 + .../js-cross-file/src/app.js | 7 + .../js-cross-file/src/models.js | 8 + .../kotlin-cross-file/app/App.kt | 11 + .../kotlin-cross-file/models/User.kt | 8 + .../php-cross-file/app/Main.php | 13 + .../php-cross-file/app/Models/User.php | 8 + .../php-cross-file/app/Models/UserFactory.php | 7 + .../php-cross-file/composer.json | 7 + .../py-cross-file/src/app.py | 6 + .../py-cross-file/src/models.py | 9 + .../test/integration/resolvers/csharp.test.ts | 72 +++- .../test/integration/resolvers/helpers.ts | 1 + .../test/integration/resolvers/java.test.ts | 72 +++- .../integration/resolvers/javascript.test.ts | 67 ++- .../test/integration/resolvers/kotlin.test.ts | 144 +++++-- .../test/integration/resolvers/php.test.ts | 68 ++- .../test/integration/resolvers/python.test.ts | 67 ++- gitnexus/test/unit/topological-sort.test.ts | 79 +--- 34 files changed, 882 insertions(+), 398 deletions(-) create mode 100644 gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/App/Program.cs create mode 100644 gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/CrossFile.csproj create mode 100644 gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/Models/User.cs create mode 100644 gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/Models/UserFactory.cs create mode 100644 gitnexus/test/fixtures/cross-file-binding/java-cross-file/app/App.java create mode 100644 gitnexus/test/fixtures/cross-file-binding/java-cross-file/models/User.java create mode 100644 gitnexus/test/fixtures/cross-file-binding/java-cross-file/models/UserFactory.java create mode 100644 gitnexus/test/fixtures/cross-file-binding/js-cross-file/src/app.js create mode 100644 gitnexus/test/fixtures/cross-file-binding/js-cross-file/src/models.js create mode 100644 gitnexus/test/fixtures/cross-file-binding/kotlin-cross-file/app/App.kt create mode 100644 gitnexus/test/fixtures/cross-file-binding/kotlin-cross-file/models/User.kt create mode 100644 gitnexus/test/fixtures/cross-file-binding/php-cross-file/app/Main.php create mode 100644 gitnexus/test/fixtures/cross-file-binding/php-cross-file/app/Models/User.php create mode 100644 gitnexus/test/fixtures/cross-file-binding/php-cross-file/app/Models/UserFactory.php create mode 100644 gitnexus/test/fixtures/cross-file-binding/php-cross-file/composer.json create mode 100644 gitnexus/test/fixtures/cross-file-binding/py-cross-file/src/app.py create mode 100644 gitnexus/test/fixtures/cross-file-binding/py-cross-file/src/models.py diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index adb63916a..7117e3476 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -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 => { diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 8a1e3ca41..f69017060 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -89,8 +89,10 @@ export interface ImportResolutionContext { allFilePaths: Set; allFileList: string[]; normalizedFileList: string[]; - suffixIndex: SuffixIndex; + suffixIndex: SuffixIndex | null; resolveCache: Map; + /** 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 } } diff --git a/gitnexus/src/core/ingestion/named-binding-extraction.ts b/gitnexus/src/core/ingestion/named-binding-extraction.ts index a4a56fb27..f311bb3ec 100644 --- a/gitnexus/src/core/ingestion/named-binding-extraction.ts +++ b/gitnexus/src/core/ingestion/named-binding-extraction.ts @@ -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 }]; } diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 0541f8e69..eca10bdde 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 71888192b..a595a2d64 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -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>, - cycleNodes: readonly string[], -): ImportCycleInfo { - // Build subgraph of only cycle nodes - const cycleSet = new Set(cycleNodes); - const subgraph = new Map(); - 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(); - 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, + ctx: ReturnType, + exportedTypeMap: ExportedTypeMap, + allPaths: string[], + totalFiles: number, + repoPath: string, + pipelineStart: number, + onProgress: (progress: PipelineProgress) => void, +): Promise { + // 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; importedReturns: ReadonlyMap }[] = []; + 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(); + 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>(); + if (seeded.size > 0) bindings.set(filePath, seeded); + + const importedReturnTypesMap = new Map>(); + 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> 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; importedReturns: ReadonlyMap }[] = []; - 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(); - 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>(); - if (seeded.size > 0) bindings.set(filePath, seeded); - - const importedReturnTypesMap = new Map>(); - 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> | undefined; let processResult: Awaited> | undefined; diff --git a/gitnexus/src/core/ingestion/resolvers/jvm.ts b/gitnexus/src/core/ingestion/resolvers/jvm.ts index 5b532e47e..1b86b0e62 100644 --- a/gitnexus/src/core/ingestion/resolvers/jvm.ts +++ b/gitnexus/src/core/ingestion/resolvers/jvm.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/resolvers/php.ts b/gitnexus/src/core/ingestion/resolvers/php.ts index c05ef4e6d..a893cb485 100644 --- a/gitnexus/src/core/ingestion/resolvers/php.ts +++ b/gitnexus/src/core/ingestion/resolvers/php.ts @@ -10,11 +10,34 @@ import { suffixResolve } from './utils.js'; export interface ComposerConfig { /** Map of namespace prefix -> directory (e.g., "App\\" -> "app/") */ psr4: Map; + /** 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; + } + } } } } diff --git a/gitnexus/src/core/ingestion/utils.ts b/gitnexus/src/core/ingestion/utils.ts index f3cd3a5d2..08a282a06 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index ef29447b3..61882bf25 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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): 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; diff --git a/gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/App/Program.cs b/gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/App/Program.cs new file mode 100644 index 000000000..ad073b105 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/App/Program.cs @@ -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(); + } + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/CrossFile.csproj b/gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/CrossFile.csproj new file mode 100644 index 000000000..fad9151a4 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/CrossFile.csproj @@ -0,0 +1,5 @@ + + + CrossFile + + diff --git a/gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/Models/User.cs b/gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/Models/User.cs new file mode 100644 index 000000000..d33f57891 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/Models/User.cs @@ -0,0 +1,8 @@ +namespace CrossFile.Models +{ + public class User + { + public void Save() {} + public string GetName() { return ""; } + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/Models/UserFactory.cs b/gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/Models/UserFactory.cs new file mode 100644 index 000000000..f25181f73 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/csharp-cross-file/Models/UserFactory.cs @@ -0,0 +1,7 @@ +namespace CrossFile.Models +{ + public static class UserFactory + { + public static User GetUser() { return new User(); } + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/java-cross-file/app/App.java b/gitnexus/test/fixtures/cross-file-binding/java-cross-file/app/App.java new file mode 100644 index 000000000..89bec4993 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/java-cross-file/app/App.java @@ -0,0 +1,11 @@ +package app; + +import static models.UserFactory.getUser; + +public class App { + public void run() { + var user = getUser(); + user.save(); + user.getName(); + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/java-cross-file/models/User.java b/gitnexus/test/fixtures/cross-file-binding/java-cross-file/models/User.java new file mode 100644 index 000000000..ea2e8d1fe --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/java-cross-file/models/User.java @@ -0,0 +1,6 @@ +package models; + +public class User { + public void save() {} + public String getName() { return ""; } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/java-cross-file/models/UserFactory.java b/gitnexus/test/fixtures/cross-file-binding/java-cross-file/models/UserFactory.java new file mode 100644 index 000000000..b584cb62a --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/java-cross-file/models/UserFactory.java @@ -0,0 +1,7 @@ +package models; + +public class UserFactory { + public static User getUser() { + return new User(); + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/js-cross-file/src/app.js b/gitnexus/test/fixtures/cross-file-binding/js-cross-file/src/app.js new file mode 100644 index 000000000..3ab06c455 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/js-cross-file/src/app.js @@ -0,0 +1,7 @@ +import { getUser } from './models'; + +export function run() { + const u = getUser(); + u.save(); + u.getName(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/js-cross-file/src/models.js b/gitnexus/test/fixtures/cross-file-binding/js-cross-file/src/models.js new file mode 100644 index 000000000..5d33e9be4 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/js-cross-file/src/models.js @@ -0,0 +1,8 @@ +export class User { + save() {} + getName() { return ''; } +} + +export function getUser() { + return new User(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/kotlin-cross-file/app/App.kt b/gitnexus/test/fixtures/cross-file-binding/kotlin-cross-file/app/App.kt new file mode 100644 index 000000000..071cf836e --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/kotlin-cross-file/app/App.kt @@ -0,0 +1,11 @@ +package app + +import models.getUser + +class App { + fun run() { + val u = getUser() + u.save() + u.getName() + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/kotlin-cross-file/models/User.kt b/gitnexus/test/fixtures/cross-file-binding/kotlin-cross-file/models/User.kt new file mode 100644 index 000000000..6cda54f94 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/kotlin-cross-file/models/User.kt @@ -0,0 +1,8 @@ +package models + +class User { + fun save() {} + fun getName(): String = "" +} + +fun getUser(): User = User() diff --git a/gitnexus/test/fixtures/cross-file-binding/php-cross-file/app/Main.php b/gitnexus/test/fixtures/cross-file-binding/php-cross-file/app/Main.php new file mode 100644 index 000000000..29255e966 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/php-cross-file/app/Main.php @@ -0,0 +1,13 @@ +save(); + $u->getName(); + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/php-cross-file/app/Models/User.php b/gitnexus/test/fixtures/cross-file-binding/php-cross-file/app/Models/User.php new file mode 100644 index 000000000..4b452bf67 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/php-cross-file/app/Models/User.php @@ -0,0 +1,8 @@ + str: + return '' + +def get_user() -> User: + return User() diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index d12e3a01e..5f8d9d1be 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 3a243aa15..18dfceff1 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -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; diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 2ba237e97..89a8d89fd 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/javascript.test.ts b/gitnexus/test/integration/resolvers/javascript.test.ts index 7ba0328c6..15f00d8c6 100644 --- a/gitnexus/test/integration/resolvers/javascript.test.ts +++ b/gitnexus/test/integration/resolvers/javascript.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index b115b03c9..9c8486fbc 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index bc634bc09..4eca7a0af 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 272ac6e3d..316b45610 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/unit/topological-sort.test.ts b/gitnexus/test/unit/topological-sort.test.ts index 17fbe5b25..f5d4d8175 100644 --- a/gitnexus/test/unit/topological-sort.test.ts +++ b/gitnexus/test/unit/topological-sort.test.ts @@ -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>(); - 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>([ - ['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>([ - ['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>([ - ['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>([ - ['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>([ - ['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); - }); -});