diff --git a/AGENTS.md b/AGENTS.md index c93ee1b23..81b39cf34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (2184 symbols, 5245 relationships, 167 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (2227 symbols, 5390 relationships, 170 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index c93ee1b23..81b39cf34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (2184 symbols, 5245 relationships, 167 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (2227 symbols, 5390 relationships, 170 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 285bc7f79..ec095023d 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1,6 +1,6 @@ import { KnowledgeGraph } from '../graph/types.js'; import { ASTCache } from './ast-cache.js'; -import type { SymbolDefinition } from './symbol-table.js'; +import type { SymbolDefinition, SymbolTable } from './symbol-table.js'; import Parser from 'tree-sitter'; import type { ResolutionContext } from './resolution-context.js'; import { TIER_CONFIDENCE, type ResolutionTier } from './resolution-context.js'; @@ -33,6 +33,145 @@ import { typeConfigs } from './type-extractors/index.js'; import type { LiteralTypeInferrer } from './type-extractors/types.js'; import type { SyntaxNode } from './utils.js'; +/** Per-file resolved type bindings for exported symbols. + * Populated during call processing, consumed by Phase 14 re-resolution pass. */ +export type ExportedTypeMap = Map>; + +const MAX_EXPORTS_PER_FILE = 500; +const MAX_TYPE_NAME_LENGTH = 256; + +/** Build a map of imported callee names → return types for cross-file call-result binding. + * Consulted ONLY when SymbolTable has no unambiguous local match (local-first principle). */ +export function buildImportedReturnTypes( + filePath: string, + namedImportMap: ReadonlyMap>, + symbolTable: { lookupExactFull(filePath: string, name: string): { returnType?: string } | undefined }, +): ReadonlyMap { + const result = new Map(); + const fileImports = namedImportMap.get(filePath); + if (!fileImports) return result; + + for (const [localName, binding] of fileImports) { + const def = symbolTable.lookupExactFull(binding.sourcePath, binding.exportedName); + if (!def?.returnType) continue; + const simpleReturn = extractReturnTypeName(def.returnType); + if (simpleReturn) result.set(localName, simpleReturn); + } + return result; +} + +/** Build cross-file RAW return types for imported callables. + * Unlike buildImportedReturnTypes (which stores extractReturnTypeName output), + * this stores the raw declared return type string (e.g., 'User[]', 'List'). + * Used by lookupRawReturnType for for-loop element extraction via extractElementTypeFromString. */ +export function buildImportedRawReturnTypes( + filePath: string, + namedImportMap: ReadonlyMap>, + symbolTable: { lookupExactFull(filePath: string, name: string): { returnType?: string } | undefined }, +): ReadonlyMap { + const result = new Map(); + const fileImports = namedImportMap.get(filePath); + if (!fileImports) return result; + + for (const [localName, binding] of fileImports) { + const def = symbolTable.lookupExactFull(binding.sourcePath, binding.exportedName); + if (!def?.returnType) continue; + result.set(localName, def.returnType); + } + return result; +} + +/** Collect resolved type bindings for exported file-scope symbols. + * Uses graph node isExported flag — does NOT require isExported on SymbolDefinition. */ +function collectExportedBindings( + typeEnv: { readonly env: ReadonlyMap> }, + filePath: string, + symbolTable: { lookupExact(filePath: string, name: string): string | undefined }, + graph: { getNode(id: string): { properties?: { isExported?: boolean } } | undefined }, +): Map | null { + const fileScope = typeEnv.env.get(''); + if (!fileScope || fileScope.size === 0) return null; + + const exported = new Map(); + for (const [varName, typeName] of fileScope) { + if (exported.size >= MAX_EXPORTS_PER_FILE) break; + if (!typeName || typeName.length > MAX_TYPE_NAME_LENGTH) continue; + const nodeId = symbolTable.lookupExact(filePath, varName); + if (!nodeId) continue; + const node = graph.getNode(nodeId); + if (node?.properties?.isExported) { + exported.set(varName, typeName); + } + } + return exported.size > 0 ? exported : null; +} + +/** Build ExportedTypeMap from graph nodes — used for worker path where TypeEnv + * is not available in the main thread. Collects returnType/declaredType from + * exported symbols that have callables with known return types. */ +export function buildExportedTypeMapFromGraph( + graph: KnowledgeGraph, + symbolTable: SymbolTable, +): ExportedTypeMap { + const result: ExportedTypeMap = new Map(); + graph.forEachNode(node => { + if (!node.properties?.isExported) return; + if (!node.properties?.filePath || !node.properties?.name) return; + const filePath = node.properties.filePath as string; + const name = node.properties.name as string; + if (!name || name.length > MAX_TYPE_NAME_LENGTH) return; + // For callable symbols, use returnType; for properties/variables, use declaredType + const def = symbolTable.lookupExactFull(filePath, name); + if (!def) return; + const typeName = def.returnType ?? def.declaredType; + if (!typeName || typeName.length > MAX_TYPE_NAME_LENGTH) return; + // Extract simple type name (strip Promise<>, etc.) — reuse shared utility + const simpleType = extractReturnTypeName(typeName) ?? typeName; + if (!simpleType) return; + let fileExports = result.get(filePath); + if (!fileExports) { fileExports = new Map(); result.set(filePath, fileExports); } + if (fileExports.size < MAX_EXPORTS_PER_FILE) { + fileExports.set(name, simpleType); + } + }); + return result; +} + +/** Seed cross-file receiver types into pre-extracted call records. + * Fills missing receiverTypeName for single-hop imported variables + * using ExportedTypeMap + namedImportMap — zero disk I/O, zero AST re-parsing. + * Mutates calls in-place. Runs BEFORE processCallsFromExtracted. */ +export function seedCrossFileReceiverTypes( + calls: ExtractedCall[], + namedImportMap: ReadonlyMap>, + exportedTypeMap: ReadonlyMap>, +): { enrichedCount: number } { + if (namedImportMap.size === 0 || exportedTypeMap.size === 0) { + return { enrichedCount: 0 }; + } + let enrichedCount = 0; + for (const call of calls) { + if (call.receiverTypeName || !call.receiverName) continue; + if (call.callForm !== 'member') continue; + + const fileImports = namedImportMap.get(call.filePath); + if (!fileImports) continue; + + const binding = fileImports.get(call.receiverName); + if (!binding) continue; + + const upstream = exportedTypeMap.get(binding.sourcePath); + if (!upstream) continue; + + const type = upstream.get(binding.exportedName); + if (type) { + call.receiverTypeName = type; + enrichedCount++; + } + } + return { enrichedCount }; +} + // Stdlib methods that preserve the receiver's type identity. When TypeEnv already // strips nullable wrappers (Option → User), these chain steps are no-ops // for type resolution — the current type passes through unchanged. @@ -48,7 +187,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 => { @@ -138,6 +277,14 @@ export const processCalls = async ( astCache: ASTCache, ctx: ResolutionContext, onProgress?: (current: number, total: number) => void, + exportedTypeMap?: ExportedTypeMap, + /** Phase 14: pre-resolved cross-file bindings to seed into buildTypeEnv. Keyed by filePath → Map. */ + importedBindingsMap?: ReadonlyMap>, + /** Phase 14 E3: cross-file return types for imported callables. Keyed by filePath → Map. + * Consulted ONLY when SymbolTable has no unambiguous match (local-first principle). */ + importedReturnTypesMap?: ReadonlyMap>, + /** Phase 14 E3: cross-file RAW return types for for-loop element extraction. Keyed by filePath → Map. */ + importedRawReturnTypesMap?: ReadonlyMap>, ): Promise => { const parser = await loadParser(); const collectedHeritage: ExtractedHeritage[] = []; @@ -223,7 +370,14 @@ export const processCalls = async ( } } - const typeEnv = lang ? buildTypeEnv(tree, lang, { symbolTable: ctx.symbols, parentMap }) : null; + const importedBindings = importedBindingsMap?.get(file.path); + const importedReturnTypes = importedReturnTypesMap?.get(file.path); + const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path); + const typeEnv = lang ? buildTypeEnv(tree, lang, { symbolTable: ctx.symbols, parentMap, importedBindings, importedReturnTypes, importedRawReturnTypes }) : null; + if (typeEnv && exportedTypeMap) { + const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph); + if (fileExports) exportedTypeMap.set(file.path, fileExports); + } const callRouter = callRouters[language]; const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0 diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 8a1e3ca41..95dfe176c 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 } } @@ -293,13 +320,22 @@ function applyImportResult( addImportEdge(filePath, resolvedFile); } - // Record named bindings for precise Tier 2a resolution + // Record named bindings for precise Tier 2a resolution. + // If the same local name is imported from multiple files (e.g., Java static imports + // of overloaded methods), remove the entry so resolution falls through to Tier 2a + // import-scoped which sees all candidates and can apply arity narrowing. if (namedBindings && namedImportMap && files.length === 1) { const resolvedFile = files[0]; if (!namedImportMap.has(filePath)) namedImportMap.set(filePath, new Map()); const fileBindings = namedImportMap.get(filePath)!; for (const binding of namedBindings) { - fileBindings.set(binding.local, { sourcePath: resolvedFile, exportedName: binding.exported }); + const existing = fileBindings.get(binding.local); + if (existing && existing.sourcePath !== resolvedFile) { + // Ambiguous: same name imported from different files — remove to fall through + fileBindings.delete(binding.local); + } else { + fileBindings.set(binding.local, { sourcePath: resolvedFile, exportedName: binding.exported }); + } } } } diff --git a/gitnexus/src/core/ingestion/named-binding-extraction.ts b/gitnexus/src/core/ingestion/named-binding-extraction.ts index a4a56fb27..5cb762259 100644 --- a/gitnexus/src/core/ingestion/named-binding-extraction.ts +++ b/gitnexus/src/core/ingestion/named-binding-extraction.ts @@ -89,6 +89,15 @@ export function extractNamedBindings( if (language === SupportedLanguages.Java) { return extractJavaNamedBindings(importNode); } + // Languages below use whole-module import semantics — the import AST node does not + // name specific symbols. namedImportMap entries are synthesized post-parse by + // synthesizeWildcardImportBindings() in pipeline.ts, which expands ImportMap edges + // into per-symbol bindings using graph-exported symbols. + // + // Go: `import "pkg"` — all PascalCase symbols available as pkg.Symbol + // Ruby: `require 'file'` — all top-level classes/modules available + // C/C++: `#include "file.h"` — textual inclusion, all non-static declarations available + // Swift: `import Module` — entire module imported (Phase S blocked on tree-sitter-swift Node 22) return undefined; } @@ -201,13 +210,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 }]; } @@ -331,23 +346,41 @@ export function extractPhpNamedBindings(importNode: any): { local: string; expor } export function extractCsharpNamedBindings(importNode: any): { local: string; exported: string }[] | undefined { - // using_directive with identifier (alias) + qualified_name (target) + // using_directive — three forms: + // using Alias = NS.Type; → aliasIdent + qualifiedName + // using static NS.Type; → static + qualifiedName (no alias) + // using NS; → qualifiedName only (namespace, not capturable) if (importNode.type !== 'using_directive') return undefined; let aliasIdent: any = null; let qualifiedName: any = null; + let isStatic = false; + for (let i = 0; i < importNode.childCount; i++) { + const child = importNode.child(i); + if (child?.text === 'static') isStatic = true; + } for (let i = 0; i < importNode.namedChildCount; i++) { const child = importNode.namedChild(i); if (child?.type === 'identifier' && !aliasIdent) aliasIdent = child; else if (child?.type === 'qualified_name') qualifiedName = child; } - if (!aliasIdent || !qualifiedName) return undefined; + // Form 1: using Alias = NS.Type; + if (aliasIdent && qualifiedName) { + const fullText = qualifiedName.text; + const exportedName = fullText.includes('.') ? fullText.split('.').pop()! : fullText; + return [{ local: aliasIdent.text, exported: exportedName }]; + } - const fullText = qualifiedName.text; - const exportedName = fullText.includes('.') ? fullText.split('.').pop()! : fullText; + // Form 2: using static NS.Type; — last segment is the class name + if (isStatic && qualifiedName) { + const fullText = qualifiedName.text; + const lastSegment = fullText.includes('.') ? fullText.split('.').pop()! : fullText; + return [{ local: lastSegment, exported: lastSegment }]; + } - return [{ local: aliasIdent.text, exported: exportedName }]; + // Form 3: using NS; — namespace import, can't resolve to per-symbol bindings + return undefined; } export function extractJavaNamedBindings(importNode: any): { local: string; exported: string }[] | undefined { @@ -355,10 +388,12 @@ export function extractJavaNamedBindings(importNode: any): { local: string; expo // Wildcard imports (.*) don't produce named bindings if (importNode.type !== 'import_declaration') return undefined; - // Check for asterisk (wildcard import) — skip those + // Check for asterisk (wildcard import) and static modifier + let isStatic = false; for (let i = 0; i < importNode.childCount; i++) { const child = importNode.child(i); if (child?.type === 'asterisk') return undefined; + if (child?.text === 'static') isStatic = true; } const scopedId = findChild(importNode, 'scoped_identifier'); @@ -368,11 +403,12 @@ export function extractJavaNamedBindings(importNode: any): { local: string; expo const lastDot = fullText.lastIndexOf('.'); if (lastDot === -1) return undefined; - const className = fullText.slice(lastDot + 1); - // Skip lowercase names — those are package imports, not class imports - if (className[0] && className[0] === className[0].toLowerCase()) return undefined; + const name = fullText.slice(lastDot + 1); + // Non-static: skip lowercase names — those are package imports, not class imports. + // Static: allow lowercase — `import static models.UserFactory.getUser` imports a method. + if (!isStatic && name[0] && name[0] === name[0].toLowerCase()) return undefined; - return [{ local: className, exported: className }]; + return [{ local: name, exported: name }]; } function findChild(node: any, type: string): any { diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 0541f8e69..ed7263cea 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -5,14 +5,14 @@ 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'; import { typeConfigs } from './type-extractors/index.js'; import { SupportedLanguages } from '../../config/supported-languages.js'; import { WorkerPool } from './workers/worker-pool.js'; -import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js'; +import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings, FileTypeEnvBindings } from './workers/parse-worker.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js'; export type FileProgressCallback = (current: number, total: number, filePath: string) => void; @@ -24,6 +24,7 @@ export interface WorkerExtractedData { heritage: ExtractedHeritage[]; routes: ExtractedRoute[]; constructorBindings: FileConstructorBindings[]; + typeEnvBindings: FileTypeEnvBindings[]; } // isNodeExported imported from ./export-detection.js (shared module) @@ -49,7 +50,7 @@ const processParsingWithWorkers = async ( if (lang) parseableFiles.push({ path: file.path, content: file.content }); } - if (parseableFiles.length === 0) return { imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [] }; + if (parseableFiles.length === 0) return { imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], typeEnvBindings: [] }; const total = files.length; @@ -68,6 +69,7 @@ const processParsingWithWorkers = async ( const allHeritage: ExtractedHeritage[] = []; const allRoutes: ExtractedRoute[] = []; const allConstructorBindings: FileConstructorBindings[] = []; + const allTypeEnvBindings: FileTypeEnvBindings[] = []; for (const result of chunkResults) { for (const node of result.nodes) { graph.addNode({ @@ -98,6 +100,7 @@ const processParsingWithWorkers = async ( allHeritage.push(...result.heritage); allRoutes.push(...result.routes); allConstructorBindings.push(...result.constructorBindings); + allTypeEnvBindings.push(...result.typeEnvBindings); } // Merge and log skipped languages from workers @@ -116,7 +119,7 @@ const processParsingWithWorkers = async ( // Final progress onFileProgress?.(total, total, 'done'); - return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, constructorBindings: allConstructorBindings }; + return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, constructorBindings: allConstructorBindings, typeEnvBindings: allTypeEnvBindings }; }; // ============================================================================ @@ -222,7 +225,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 535da40f4..762816e97 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -7,7 +7,7 @@ import { processImportsFromExtracted, buildImportResolutionContext } from './import-processor.js'; -import { processCalls, processCallsFromExtracted, processAssignmentsFromExtracted, processRoutesFromExtracted } from './call-processor.js'; +import { processCalls, processCallsFromExtracted, processAssignmentsFromExtracted, processRoutesFromExtracted, seedCrossFileReceiverTypes, buildImportedReturnTypes, buildImportedRawReturnTypes, type ExportedTypeMap, buildExportedTypeMapFromGraph } from './call-processor.js'; import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js'; import { computeMRO } from './mro-processor.js'; import { processCommunities } from './community-processor.js'; @@ -18,6 +18,7 @@ import { PipelineProgress, PipelineResult } from '../../types/pipeline.js'; import { walkRepositoryPaths, readFileContents } from './filesystem-walker.js'; import { getLanguageFromFilename } from './utils.js'; import { isLanguageAvailable } from '../tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../config/supported-languages.js'; import { createWorkerPool, WorkerPool } from './workers/worker-pool.js'; import fs from 'node:fs'; import path from 'node:path'; @@ -25,6 +26,62 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; const isDev = process.env.NODE_ENV === 'development'; +/** A group of files with no mutual dependencies, safe to process in parallel. */ +type IndependentFileGroup = readonly string[]; + +/** Kahn's algorithm: returns files grouped by topological level. + * Files in the same level have no mutual dependencies — safe to process in parallel. + * Files in cycles are returned as a final group (no cross-cycle propagation). */ +export function topologicalLevelSort( + importMap: ReadonlyMap>, +): { levels: readonly IndependentFileGroup[]; cycleCount: number } { + // Build in-degree map and reverse dependency map + const inDegree = new Map(); + const reverseDeps = new Map(); + + for (const [file, deps] of importMap) { + if (!inDegree.has(file)) inDegree.set(file, 0); + for (const dep of deps) { + if (!inDegree.has(dep)) inDegree.set(dep, 0); + // file imports dep, so dep must be processed before file + // In Kahn's terms: dep → file (dep is a prerequisite of file) + inDegree.set(file, (inDegree.get(file) ?? 0) + 1); + let rev = reverseDeps.get(dep); + if (!rev) { rev = []; reverseDeps.set(dep, rev); } + rev.push(file); + } + } + + // BFS from zero-in-degree nodes, grouping by level + const levels: string[][] = []; + let currentLevel = [...inDegree.entries()] + .filter(([, d]) => d === 0) + .map(([f]) => f); + + while (currentLevel.length > 0) { + levels.push(currentLevel); + const nextLevel: string[] = []; + for (const file of currentLevel) { + for (const dependent of reverseDeps.get(file) ?? []) { + const newDeg = (inDegree.get(dependent) ?? 1) - 1; + inDegree.set(dependent, newDeg); + if (newDeg === 0) nextLevel.push(dependent); + } + } + currentLevel = nextLevel; + } + + // Files still with positive in-degree are in cycles — add as final group + const cycleFiles = [...inDegree.entries()] + .filter(([, d]) => d > 0) + .map(([f]) => f); + if (cycleFiles.length > 0) { + levels.push(cycleFiles); + } + + return { levels, cycleCount: cycleFiles.length }; +} + /** 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 @@ -34,6 +91,261 @@ const CHUNK_BYTE_BUDGET = 20 * 1024 * 1024; // 20MB /** Max AST trees to keep in LRU cache */ const AST_CACHE_CAP = 50; +/** 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; + +/** Node labels that represent top-level importable symbols. + * Excludes Method, Property, Constructor (accessed via receiver, not directly imported), + * and structural labels (File, Folder, Package, Module, Project, etc.). */ +const IMPORTABLE_SYMBOL_LABELS = new Set([ + 'Function', 'Class', 'Interface', 'Struct', 'Enum', 'Trait', + 'TypeAlias', 'Const', 'Static', 'Record', 'Union', 'Typedef', 'Macro', +]); + +/** Max synthetic bindings per importing file — prevents memory bloat for + * C/C++ files that include many large headers. */ +const MAX_SYNTHETIC_BINDINGS_PER_FILE = 1000; + +/** Languages with whole-module import semantics (no per-symbol named imports). + * For these languages, namedImportMap entries are synthesized from graph-exported + * symbols after parsing, enabling Phase 14 cross-file binding propagation. */ +const WILDCARD_IMPORT_LANGUAGES = new Set([ + SupportedLanguages.Go, + SupportedLanguages.Ruby, + SupportedLanguages.C, + SupportedLanguages.CPlusPlus, + SupportedLanguages.Swift, +]); + +/** Synthesize namedImportMap entries for languages with whole-module imports. + * These languages (Go, Ruby, C/C++, Swift) import all exported symbols from a file, + * not specific named symbols. After parsing, we know which symbols each file exports + * (via graph isExported), so we can expand ImportMap edges into per-symbol bindings + * that Phase 14 can use for cross-file type propagation. */ +function synthesizeWildcardImportBindings( + graph: ReturnType, + ctx: ReturnType, +): number { + // Pre-compute exported symbols per file from graph (single pass) + const exportedSymbolsByFile = new Map(); + graph.forEachNode(node => { + if (!node.properties?.isExported) return; + if (!IMPORTABLE_SYMBOL_LABELS.has(node.label)) return; + const fp = node.properties.filePath; + const name = node.properties.name; + if (!fp || !name) return; + let symbols = exportedSymbolsByFile.get(fp); + if (!symbols) { symbols = []; exportedSymbolsByFile.set(fp, symbols); } + symbols.push({ name, filePath: fp }); + }); + + if (exportedSymbolsByFile.size === 0) return 0; + + // Build a merged import map: ctx.importMap has file-based imports (Ruby, C/C++), + // but Go/C# package imports use graph IMPORTS edges + PackageMap instead. + // Collect graph-level IMPORTS edges for wildcard languages missing from ctx.importMap. + const FILE_PREFIX = 'File:'; + const graphImports = new Map>(); + graph.forEachRelationship(rel => { + if (rel.type !== 'IMPORTS') return; + if (!rel.sourceId.startsWith(FILE_PREFIX) || !rel.targetId.startsWith(FILE_PREFIX)) return; + const srcFile = rel.sourceId.slice(FILE_PREFIX.length); + const tgtFile = rel.targetId.slice(FILE_PREFIX.length); + const lang = getLanguageFromFilename(srcFile); + if (!lang || !WILDCARD_IMPORT_LANGUAGES.has(lang)) return; + // Only add if not already in ctx.importMap (avoid duplicates) + if (ctx.importMap.get(srcFile)?.has(tgtFile)) return; + let set = graphImports.get(srcFile); + if (!set) { set = new Set(); graphImports.set(srcFile, set); } + set.add(tgtFile); + }); + + let totalSynthesized = 0; + + // Helper: synthesize bindings for a file given its imported files + const synthesizeForFile = (filePath: string, importedFiles: Iterable) => { + let fileBindings = ctx.namedImportMap.get(filePath); + let fileCount = fileBindings?.size ?? 0; + + for (const importedFile of importedFiles) { + const exportedSymbols = exportedSymbolsByFile.get(importedFile); + if (!exportedSymbols) continue; + + for (const sym of exportedSymbols) { + if (fileCount >= MAX_SYNTHETIC_BINDINGS_PER_FILE) return; + if (fileBindings?.has(sym.name)) continue; + + if (!fileBindings) { + fileBindings = new Map(); + ctx.namedImportMap.set(filePath, fileBindings); + } + fileBindings.set(sym.name, { + sourcePath: importedFile, + exportedName: sym.name, + }); + fileCount++; + totalSynthesized++; + } + } + }; + + // Process files from ctx.importMap (Ruby, C/C++, Swift file-based imports) + for (const [filePath, importedFiles] of ctx.importMap) { + const lang = getLanguageFromFilename(filePath); + if (!lang || !WILDCARD_IMPORT_LANGUAGES.has(lang)) continue; + synthesizeForFile(filePath, importedFiles); + } + + // Process files from graph IMPORTS edges (Go package imports) + for (const [filePath, importedFiles] of graphImports) { + synthesizeForFile(filePath, importedFiles); + } + + return totalSynthesized; +} + +/** Phase 14: Cross-file binding propagation. + * Seeds downstream files with resolved type bindings from upstream exports. + * Files are processed in topological import order so upstream bindings are + * 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, cycleCount } = topologicalLevelSort(ctx.importMap); + + // Cycle diagnostic: only log when actual cycles detected (cycleCount from Kahn's BFS) + if (isDev && cycleCount > 0) { + console.log(`🔄 ${cycleCount} files in import cycles (skipped for cross-file propagation)`); + } + + // Quick count of files with cross-file binding gaps (early exit once threshold exceeded) + 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; importedRawReturns: 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); + const importedRawReturns = buildImportedRawReturnTypes(filePath, ctx.namedImportMap, ctx.symbols); + if (seeded.size === 0 && importedReturns.size === 0) continue; + if (!allPathSet.has(filePath)) continue; + + const lang = getLanguageFromFilename(filePath); + if (!lang || !isLanguageAvailable(lang)) continue; + + levelCandidates.push({ filePath, seeded, importedReturns, importedRawReturns }); + } + + if (levelCandidates.length === 0) continue; + + const levelPaths = levelCandidates.map(c => c.filePath); + const contentMap = await readFileContents(repoPath, levelPaths); + + for (const { filePath, seeded, importedReturns, importedRawReturns } 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); + } + + const importedRawReturnTypesMap = new Map>(); + if (importedRawReturns.size > 0) { + importedRawReturnTypesMap.set(filePath, importedRawReturns); + } + + await processCalls(graph, reFile, astCache, ctx, undefined, exportedTypeMap, bindings.size > 0 ? bindings : undefined, importedReturnTypesMap.size > 0 ? importedReturnTypesMap : undefined, importedRawReturnTypesMap.size > 0 ? importedRawReturnTypesMap : undefined); + crossFileResolved++; + } + + 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. */ skipGraphPhases?: boolean; @@ -48,6 +360,7 @@ export const runPipelineFromRepo = async ( const ctx = createResolutionContext(); const symbolTable = ctx.symbols; let astCache = createASTCache(AST_CACHE_CAP); + const pipelineStart = Date.now(); const cleanup = () => { astCache.clear(); @@ -217,6 +530,10 @@ export const runPipelineFromRepo = async ( // are already registered). This trades ~5% cross-chunk resolution accuracy for // 200-400MB less memory — critical for Linux-kernel-scale repos. const sequentialChunkPaths: string[][] = []; + // Phase 14: Collect exported type bindings for cross-file propagation + const exportedTypeMap: ExportedTypeMap = new Map(); + // Accumulate file-scope TypeEnv bindings from workers (closes worker/sequential quality gap) + const workerTypeEnvBindings: { filePath: string; bindings: [string, string][] }[] = []; try { for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) { @@ -258,6 +575,19 @@ export const runPipelineFromRepo = async ( stats: { filesProcessed: filesParsedSoFar, totalFiles: totalParseable, nodesCreated: graph.nodeCount }, }); }, repoPath, importCtx); + // Phase 14 E1: Seed cross-file receiver types from ExportedTypeMap + // before call resolution — eliminates re-parse for single-hop imported receivers. + // NOTE: In the worker path, exportedTypeMap is empty during chunk processing + // (populated later in runCrossFileBindingPropagation). This block is latent — + // it activates only if incremental export collection is added per-chunk. + if (exportedTypeMap.size > 0 && ctx.namedImportMap.size > 0) { + const { enrichedCount } = seedCrossFileReceiverTypes( + chunkWorkerData.calls, ctx.namedImportMap, exportedTypeMap, + ); + if (isDev && enrichedCount > 0) { + console.log(`🔗 E1: Seeded ${enrichedCount} cross-file receiver types (chunk ${chunkIdx + 1})`); + } + } // Calls + Heritage + Routes — resolve in parallel (no shared mutable state between them) // This is safe because each writes disjoint relationship types into idempotent id-keyed Maps, // and the single-threaded event loop prevents races between synchronous addRelationship calls. @@ -310,6 +640,10 @@ export const runPipelineFromRepo = async ( if (chunkWorkerData.assignments?.length) { processAssignmentsFromExtracted(graph, chunkWorkerData.assignments, ctx, chunkWorkerData.constructorBindings); } + // Collect TypeEnv file-scope bindings for exported type enrichment + if (chunkWorkerData.typeEnvBindings?.length) { + workerTypeEnvBindings.push(...chunkWorkerData.typeEnvBindings); + } } else { await processImports(graph, chunkFiles, astCache, ctx, undefined, repoPath, allPaths); sequentialChunkPaths.push(chunkPaths); @@ -332,7 +666,7 @@ export const runPipelineFromRepo = async ( .filter(p => chunkContents.has(p)) .map(p => ({ path: p, content: chunkContents.get(p)! })); astCache = createASTCache(chunkFiles.length); - const rubyHeritage = await processCalls(graph, chunkFiles, astCache, ctx); + const rubyHeritage = await processCalls(graph, chunkFiles, astCache, ctx, undefined, exportedTypeMap); await processHeritage(graph, chunkFiles, astCache, ctx); if (rubyHeritage.length > 0) { await processHeritageFromExtracted(graph, rubyHeritage, ctx); @@ -348,12 +682,54 @@ export const runPipelineFromRepo = async ( console.log(`🔍 Resolution cache: ${rcStats.cacheHits} hits, ${rcStats.cacheMisses} misses (${hitRate}% hit rate)`); } + // ── Worker path quality enrichment: merge TypeEnv file-scope bindings into ExportedTypeMap ── + // Workers return file-scope bindings from their TypeEnv fixpoint (includes inferred types + // like `const config = getConfig()` → Config). Filter by graph isExported to match + // the sequential path's collectExportedBindings behavior. + if (workerTypeEnvBindings.length > 0) { + let enriched = 0; + for (const { filePath, bindings } of workerTypeEnvBindings) { + for (const [name, type] of bindings) { + // Verify the symbol is exported via graph node + const nodeId = `Function:${filePath}:${name}`; + const varNodeId = `Variable:${filePath}:${name}`; + const constNodeId = `Const:${filePath}:${name}`; + const node = graph.getNode(nodeId) ?? graph.getNode(varNodeId) ?? graph.getNode(constNodeId); + if (!node?.properties?.isExported) continue; + + let fileExports = exportedTypeMap.get(filePath); + if (!fileExports) { fileExports = new Map(); exportedTypeMap.set(filePath, fileExports); } + // Don't overwrite existing entries (Tier 0 from SymbolTable is authoritative) + if (!fileExports.has(name)) { + fileExports.set(name, type); + enriched++; + } + } + } + if (isDev && enriched > 0) { + console.log(`🔗 Worker TypeEnv enrichment: ${enriched} fixpoint-inferred exports added to ExportedTypeMap`); + } + } + + // ── Phase 14 pre-pass: Synthesize namedImportMap for whole-module-import languages ── + // Go, Ruby, C/C++, Swift import all exported symbols from a file. + // Expand ImportMap edges into per-symbol namedImportMap entries so Phase 14 can + // propagate types cross-file for these languages. + const synthesized = synthesizeWildcardImportBindings(graph, ctx); + if (isDev && synthesized > 0) { + console.log(`🔗 Synthesized ${synthesized} wildcard import bindings (Go/Ruby/C++/Swift)`); + } + + // ── Phase 14: Cross-file binding propagation ────────────────────── + await runCrossFileBindingPropagation( + graph, ctx, exportedTypeMap, allPaths, totalFiles, repoPath, pipelineStart, onProgress, + ); + // 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/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 70d26116c..79b032462 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -420,8 +420,15 @@ export const CPP_QUERIES = ` declarator: (reference_declarator (field_identifier) @name)) @definition.property -; Inline class method declarations (inside class body, no body: void Foo();) -(field_declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.method +; Inline class method declarations (inside class body, no body: void save();) +; tree-sitter-cpp uses field_identifier (not identifier) for names inside class bodies +(field_declaration declarator: (function_declarator declarator: [(field_identifier) (identifier)] @name)) @definition.method + +; Inline class method declarations returning a pointer (User* lookup();) +(field_declaration declarator: (pointer_declarator declarator: (function_declarator declarator: [(field_identifier) (identifier)] @name))) @definition.method + +; Inline class method declarations returning a reference (User& lookup();) +(field_declaration declarator: (reference_declarator (function_declarator declarator: [(field_identifier) (identifier)] @name))) @definition.method ; Inline class method definitions (inside class body, with body: void Foo() { ... }) (field_declaration_list diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 9cda7390f..a1099fc46 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -647,6 +647,34 @@ const resolveFixpointBindings = ( export interface BuildTypeEnvOptions { symbolTable?: SymbolTable; parentMap?: ReadonlyMap; + /** Pre-resolved bindings from upstream files (Phase 14). + * Seeded into FILE_SCOPE after walk() for names with no local binding. + * Local declarations always take precedence (first-writer-wins). */ + importedBindings?: ReadonlyMap; + /** Cross-file return type fallback for imported callables (Phase 14 E3). + * Consulted ONLY when SymbolTable has no unambiguous match. + * Local definitions always take precedence (local-first principle). */ + importedReturnTypes?: ReadonlyMap; + /** Cross-file RAW return types for imported callables (Phase 14 E3). + * Stores raw declared return type strings (e.g., 'User[]', 'List'). + * Used by lookupRawReturnType for for-loop element extraction. */ + importedRawReturnTypes?: ReadonlyMap; +} + +/** Seed cross-file type bindings into the file scope. + * MUST be called AFTER walk() completes so that local declarations + * (Tier 0/1) always take precedence over imported bindings (first-writer-wins). */ +function seedImportedBindings( + env: TypeEnv, + importedBindings: ReadonlyMap, +): void { + let fileEnv = env.get(FILE_SCOPE); + if (!fileEnv) { fileEnv = new Map(); env.set(FILE_SCOPE, fileEnv); } + for (const [name, type] of importedBindings) { + if (!fileEnv.has(name)) { + fileEnv.set(name, type); + } + } } export const buildTypeEnv = ( @@ -667,24 +695,36 @@ export const buildTypeEnv = ( const config = typeConfigs[language]; const bindings: ConstructorBinding[] = []; - // Build ReturnTypeLookup from optional SymbolTable. - // Conservative: returns undefined when callee is ambiguous (0 or 2+ matches). + // Build ReturnTypeLookup: SymbolTable is authoritative when it has an unambiguous match. + // Cross-file importedReturnTypes are consulted ONLY when SymbolTable has 0 matches. + // Ambiguous (2+) → undefined, no cross-file fallback (conservative, local-first principle). const returnTypeLookup: ReturnTypeLookup = { lookupReturnType(callee: string): string | undefined { - if (!symbolTable) return undefined; - if (isBuiltInOrNoise(callee)) return undefined; - const callables = symbolTable.lookupFuzzyCallable(callee); - if (callables.length !== 1) return undefined; - const rawReturn = callables[0].returnType; - if (!rawReturn) return undefined; - return extractReturnTypeName(rawReturn); + // SymbolTable is authoritative when it has an unambiguous match + if (symbolTable) { + if (isBuiltInOrNoise(callee)) return undefined; + const callables = symbolTable.lookupFuzzyCallable(callee); + if (callables.length === 1) { + const rawReturn = callables[0].returnType; + if (rawReturn) return extractReturnTypeName(rawReturn); + } + // Ambiguous (2+) → return undefined (conservative, no cross-file fallback) + if (callables.length > 1) return undefined; + } + // No match (0 results or no symbolTable) → fall back to cross-file + return options?.importedReturnTypes?.get(callee); }, lookupRawReturnType(callee: string): string | undefined { - if (!symbolTable) return undefined; - if (isBuiltInOrNoise(callee)) return undefined; - const callables = symbolTable.lookupFuzzyCallable(callee); - if (callables.length !== 1) return undefined; - return callables[0].returnType; + if (symbolTable) { + if (isBuiltInOrNoise(callee)) return undefined; + const callables = symbolTable.lookupFuzzyCallable(callee); + if (callables.length === 1) return callables[0].returnType; + // Ambiguous (2+) → return undefined (conservative, no cross-file fallback) + if (callables.length > 1) return undefined; + } + // Cross-file fallback uses importedRawReturnTypes (raw declared types, e.g., 'User[]') + // NOT importedReturnTypes (which contains processed/simple types via extractReturnTypeName) + return options?.importedRawReturnTypes?.get(callee); } }; @@ -996,6 +1036,12 @@ export const buildTypeEnv = ( walk(tree.rootNode, FILE_SCOPE); + // Phase 14: Seed cross-file bindings from upstream files AFTER walk + // (local declarations from walk() take precedence — first-writer-wins) + if (options?.importedBindings && options.importedBindings.size > 0) { + seedImportedBindings(env, options.importedBindings); + } + resolveFixpointBindings(pendingItems, env, returnTypeLookup, symbolTable, parentMap); // Post-fixpoint for-loop replay (Phase 10 / ex-9B loop-fixpoint bridge): 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..6f5e02ecc 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, @@ -164,6 +165,13 @@ export interface FileConstructorBindings { bindings: ConstructorBinding[]; } +/** File-scope type bindings from TypeEnv fixpoint — used for cross-file ExportedTypeMap. */ +export interface FileTypeEnvBindings { + filePath: string; + /** [varName, typeName] pairs from file scope (scope = '') */ + bindings: [string, string][]; +} + export interface ParseWorkerResult { nodes: ParsedNode[]; relationships: ParsedRelationship[]; @@ -174,6 +182,8 @@ export interface ParseWorkerResult { heritage: ExtractedHeritage[]; routes: ExtractedRoute[]; constructorBindings: FileConstructorBindings[]; + /** File-scope type bindings from TypeEnv fixpoint for exported symbol collection. */ + typeEnvBindings: FileTypeEnvBindings[]; skippedLanguages: Record; fileCount: number; } @@ -256,7 +266,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'; @@ -301,6 +312,7 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n heritage: [], routes: [], constructorBindings: [], + typeEnvBindings: [], skippedLanguages: {}, fileCount: 0, }; @@ -934,6 +946,16 @@ const processFileGroup = ( result.constructorBindings.push({ filePath: file.path, bindings: [...typeEnv.constructorBindings] }); } + // Extract file-scope bindings for ExportedTypeMap (closes worker/sequential quality gap). + // Sequential path uses collectExportedBindings(typeEnv) directly; worker path serializes + // these bindings so the main thread can merge them into ExportedTypeMap. + const fileScope = typeEnv.env.get(''); + if (fileScope && fileScope.size > 0) { + const bindings: [string, string][] = []; + for (const [name, type] of fileScope) bindings.push([name, type]); + result.typeEnvBindings.push({ filePath: file.path, bindings }); + } + for (const match of matches) { const captureMap: Record = {}; for (const c of match.captures) { @@ -1144,7 +1166,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 +1186,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; @@ -1292,7 +1320,7 @@ const processFileGroup = ( /** Accumulated result across sub-batches */ let accumulated: ParseWorkerResult = { nodes: [], relationships: [], symbols: [], - imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0, + imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0, }; let cumulativeProcessed = 0; @@ -1306,6 +1334,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => { target.heritage.push(...src.heritage); target.routes.push(...src.routes); target.constructorBindings.push(...src.constructorBindings); + target.typeEnvBindings.push(...src.typeEnvBindings); for (const [lang, count] of Object.entries(src.skippedLanguages)) { target.skippedLanguages[lang] = (target.skippedLanguages[lang] || 0) + count; } @@ -1330,7 +1359,7 @@ parentPort!.on('message', (msg: any) => { if (msg && msg.type === 'flush') { parentPort!.postMessage({ type: 'result', data: accumulated }); // Reset for potential reuse - accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0 }; + accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], typeEnvBindings: [], skippedLanguages: {}, fileCount: 0 }; cumulativeProcessed = 0; return; } diff --git a/gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/app/main.cpp b/gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/app/main.cpp new file mode 100644 index 000000000..b8820bed5 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/app/main.cpp @@ -0,0 +1,7 @@ +#include "../models/user_factory.h" + +void process() { + User user = get_user(); + user.save(); + user.get_name(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user.cpp b/gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user.cpp new file mode 100644 index 000000000..7e1d2f646 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user.cpp @@ -0,0 +1,7 @@ +#include "user.h" + +void User::save() {} + +std::string User::get_name() { + return ""; +} diff --git a/gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user.h b/gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user.h new file mode 100644 index 000000000..2dc4e1a99 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +class User { +public: + void save(); + std::string get_name(); +}; diff --git a/gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user_factory.cpp b/gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user_factory.cpp new file mode 100644 index 000000000..8f25718ca --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user_factory.cpp @@ -0,0 +1,5 @@ +#include "user_factory.h" + +User get_user() { + return User(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user_factory.h b/gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user_factory.h new file mode 100644 index 000000000..5eff92de6 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/cpp-cross-file/models/user_factory.h @@ -0,0 +1,5 @@ +#pragma once + +#include "user.h" + +User get_user(); 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/go-cross-file/app/main.go b/gitnexus/test/fixtures/cross-file-binding/go-cross-file/app/main.go new file mode 100644 index 000000000..6b8efa12b --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/go-cross-file/app/main.go @@ -0,0 +1,9 @@ +package main + +import "go-cross-file/models" + +func main() { + user := models.GetUser() + user.Save() + user.GetName() +} diff --git a/gitnexus/test/fixtures/cross-file-binding/go-cross-file/go.mod b/gitnexus/test/fixtures/cross-file-binding/go-cross-file/go.mod new file mode 100644 index 000000000..478cb8e64 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/go-cross-file/go.mod @@ -0,0 +1,3 @@ +module go-cross-file + +go 1.21 diff --git a/gitnexus/test/fixtures/cross-file-binding/go-cross-file/models/factory.go b/gitnexus/test/fixtures/cross-file-binding/go-cross-file/models/factory.go new file mode 100644 index 000000000..2354cafae --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/go-cross-file/models/factory.go @@ -0,0 +1,5 @@ +package models + +func GetUser() User { + return User{} +} diff --git a/gitnexus/test/fixtures/cross-file-binding/go-cross-file/models/user.go b/gitnexus/test/fixtures/cross-file-binding/go-cross-file/models/user.go new file mode 100644 index 000000000..d1d70905a --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/go-cross-file/models/user.go @@ -0,0 +1,7 @@ +package models + +type User struct{} + +func (u User) Save() {} + +func (u User) GetName() string { return "" } 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/fixtures/cross-file-binding/rb-cross-file/app.rb b/gitnexus/test/fixtures/cross-file-binding/rb-cross-file/app.rb new file mode 100644 index 000000000..7127c06dc --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rb-cross-file/app.rb @@ -0,0 +1,7 @@ +require_relative 'models/user_factory' + +def process + user = UserFactory.get_user + user.save + user.get_name +end diff --git a/gitnexus/test/fixtures/cross-file-binding/rb-cross-file/models/user.rb b/gitnexus/test/fixtures/cross-file-binding/rb-cross-file/models/user.rb new file mode 100644 index 000000000..c5d2b4d9f --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rb-cross-file/models/user.rb @@ -0,0 +1,9 @@ +class User + def save + puts "saving" + end + + def get_name + "Alice" + end +end diff --git a/gitnexus/test/fixtures/cross-file-binding/rb-cross-file/models/user_factory.rb b/gitnexus/test/fixtures/cross-file-binding/rb-cross-file/models/user_factory.rb new file mode 100644 index 000000000..17119d6ee --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rb-cross-file/models/user_factory.rb @@ -0,0 +1,7 @@ +require_relative 'user' + +class UserFactory + def self.get_user + User.new + end +end diff --git a/gitnexus/test/fixtures/cross-file-binding/rs-cross-file/src/factory.rs b/gitnexus/test/fixtures/cross-file-binding/rs-cross-file/src/factory.rs new file mode 100644 index 000000000..0ceeb44dc --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rs-cross-file/src/factory.rs @@ -0,0 +1,5 @@ +use crate::models::User; + +pub fn get_user() -> User { + User +} diff --git a/gitnexus/test/fixtures/cross-file-binding/rs-cross-file/src/main.rs b/gitnexus/test/fixtures/cross-file-binding/rs-cross-file/src/main.rs new file mode 100644 index 000000000..796c3b80c --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rs-cross-file/src/main.rs @@ -0,0 +1,10 @@ +mod factory; +mod models; + +use crate::factory::get_user; + +pub fn process() { + let u = get_user(); + u.save(); + u.get_name(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/rs-cross-file/src/models.rs b/gitnexus/test/fixtures/cross-file-binding/rs-cross-file/src/models.rs new file mode 100644 index 000000000..d749a03f4 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rs-cross-file/src/models.rs @@ -0,0 +1,9 @@ +pub struct User; + +impl User { + pub fn save(&self) {} + + pub fn get_name(&self) -> String { + String::new() + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-circular/src/a.ts b/gitnexus/test/fixtures/cross-file-binding/ts-circular/src/a.ts new file mode 100644 index 000000000..54adda1f2 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-circular/src/a.ts @@ -0,0 +1,8 @@ +import { getB } from './b'; +export class A { + doA(): void {} +} +export function processA() { + const b = getB(); + b.doB(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-circular/src/b.ts b/gitnexus/test/fixtures/cross-file-binding/ts-circular/src/b.ts new file mode 100644 index 000000000..f15cfe792 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-circular/src/b.ts @@ -0,0 +1,7 @@ +import { A } from './a'; +export class B { + doB(): void {} +} +export function getB(): B { + return new B(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/app.ts b/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/app.ts new file mode 100644 index 000000000..97b48527f --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/app.ts @@ -0,0 +1,5 @@ +import { getConfig } from './index'; +export function init() { + const config = getConfig(); + config.validate(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/core.ts b/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/core.ts new file mode 100644 index 000000000..93000e716 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/core.ts @@ -0,0 +1,6 @@ +export class Config { + validate(): boolean { return true; } +} +export function getConfig(): Config { + return new Config(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/index.ts b/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/index.ts new file mode 100644 index 000000000..c7d471009 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/index.ts @@ -0,0 +1 @@ +export { getConfig } from './core'; diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-return-type/src/api.ts b/gitnexus/test/fixtures/cross-file-binding/ts-return-type/src/api.ts new file mode 100644 index 000000000..93000e716 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-return-type/src/api.ts @@ -0,0 +1,6 @@ +export class Config { + validate(): boolean { return true; } +} +export function getConfig(): Config { + return new Config(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-return-type/src/consumer.ts b/gitnexus/test/fixtures/cross-file-binding/ts-return-type/src/consumer.ts new file mode 100644 index 000000000..5cfa8fec3 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-return-type/src/consumer.ts @@ -0,0 +1,5 @@ +import { getConfig } from './api'; +export function run() { + const c = getConfig(); + c.validate(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/app.ts b/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/app.ts new file mode 100644 index 000000000..d9d0f0f16 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/app.ts @@ -0,0 +1,5 @@ +import { user } from './service'; +export function main() { + user.save(); + user.getName(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/models.ts b/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/models.ts new file mode 100644 index 000000000..b2115f9f2 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/models.ts @@ -0,0 +1,7 @@ +export class User { + save(): void {} + getName(): string { return ''; } +} +export function getUser(): User { + return new User(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/service.ts b/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/service.ts new file mode 100644 index 000000000..e0fb9dd7b --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/service.ts @@ -0,0 +1,2 @@ +import { getUser } from './models'; +export const user = getUser(); diff --git a/gitnexus/test/integration/cross-file-binding.test.ts b/gitnexus/test/integration/cross-file-binding.test.ts new file mode 100644 index 000000000..ca7c1c265 --- /dev/null +++ b/gitnexus/test/integration/cross-file-binding.test.ts @@ -0,0 +1,232 @@ +/** + * Phase 14: Cross-file type binding propagation + * + * When file A exports `const user = getUser()` (resolved to type User), and + * file B imports `user`, Phase 14 seeds `user → User` into file B's type + * environment, enabling `user.save()` in file B to produce a CALLS edge to + * User#save. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + getRelationships, + getNodesByLabel, + runPipelineFromRepo, + type PipelineResult, +} from './resolvers/helpers.js'; + +const CROSS_FILE_FIXTURES = path.resolve(__dirname, '..', 'fixtures', 'cross-file-binding'); + +// --------------------------------------------------------------------------- +// Simple cross-file: models → service → app +// models.ts exports getUser(): User +// service.ts exports const user = getUser() (user → User via call-result) +// app.ts imports user from service → seeds user → User → resolves user.save() +// --------------------------------------------------------------------------- + +describe('Cross-File Binding Propagation: TypeScript simple cross-file', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'ts-simple'), + () => {}, + ); + }, 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 function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('getUser'); + expect(getNodesByLabel(result, 'Function')).toContain('main'); + }); + + it('resolves user.save() in main() to User#save via cross-file binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && + c.source === 'main' && + c.targetFilePath.includes('models'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves user.getName() in main() to User#getName via cross-file binding', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCall = calls.find(c => + c.target === 'getName' && + c.source === 'main' && + c.targetFilePath.includes('models'), + ); + expect(getNameCall).toBeDefined(); + }); + + it('emits HAS_METHOD edges linking save and getName to User', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'save'); + const getNameEdge = hasMethod.find(e => e.source === 'User' && e.target === 'getName'); + expect(saveEdge).toBeDefined(); + expect(getNameEdge).toBeDefined(); + }); + + it('emits IMPORTS edges across all three files', () => { + const imports = getRelationships(result, 'IMPORTS'); + // service.ts → models.ts and app.ts → service.ts + expect(imports.length).toBeGreaterThanOrEqual(2); + const paths = imports.map(e => `${e.sourceFilePath} → ${e.targetFilePath}`); + expect(paths.some(p => p.includes('service') && p.includes('models'))).toBe(true); + expect(paths.some(p => p.includes('app') && p.includes('service'))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Re-export chain: core → index (barrel) → app +// core.ts exports getConfig(): Config +// index.ts re-exports getConfig from core (no new bindings) +// app.ts imports getConfig from index, creates local const config = getConfig() +// → config.validate() resolves to Config#validate via local call-result binding +// --------------------------------------------------------------------------- + +describe('Cross-File Binding Propagation: TypeScript re-export chain', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'ts-reexport'), + () => {}, + ); + }, 60000); + + it('detects Config class with validate method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Config'); + expect(getNodesByLabel(result, 'Method')).toContain('validate'); + }); + + it('detects getConfig function and init function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('getConfig'); + expect(getNodesByLabel(result, 'Function')).toContain('init'); + }); + + it('resolves config.validate() in init() to Config#validate', () => { + const calls = getRelationships(result, 'CALLS'); + const validateCall = calls.find(c => + c.target === 'validate' && + c.source === 'init' && + c.targetFilePath.includes('core'), + ); + expect(validateCall).toBeDefined(); + }); + + it('emits HAS_METHOD edge from Config to validate', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const edge = hasMethod.find(e => e.source === 'Config' && e.target === 'validate'); + expect(edge).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// E3: Cross-file return type propagation +// api.ts exports getConfig(): Config +// consumer.ts imports getConfig, calls const c = getConfig(); c.validate() +// → c is typed Config via importedReturnTypes (E3), enabling Config#validate edge +// --------------------------------------------------------------------------- + +describe('Cross-File Binding Propagation: TypeScript E3 return type propagation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'ts-return-type'), + () => {}, + ); + }, 60000); + + it('detects Config class with validate method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Config'); + expect(getNodesByLabel(result, 'Method')).toContain('validate'); + }); + + it('detects getConfig function and run function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('getConfig'); + expect(getNodesByLabel(result, 'Function')).toContain('run'); + }); + + it('resolves c.validate() in run() to Config#validate via cross-file return type propagation', () => { + const calls = getRelationships(result, 'CALLS'); + const validateCall = calls.find(c => + c.target === 'validate' && + c.source === 'run' && + c.targetFilePath.includes('api'), + ); + expect(validateCall).toBeDefined(); + }); + + it('emits HAS_METHOD edge from Config to validate', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const edge = hasMethod.find(e => e.source === 'Config' && e.target === 'validate'); + expect(edge).toBeDefined(); + }); + + it('emits IMPORTS edge from consumer to api', () => { + const imports = getRelationships(result, 'IMPORTS'); + const edge = imports.find(e => + e.sourceFilePath.includes('consumer') && e.targetFilePath.includes('api'), + ); + expect(edge).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Circular imports: a.ts ↔ b.ts +// a.ts imports getB from b.ts; b.ts imports A from a.ts +// Conservative expectation: pipeline completes without error. +// Cross-file binding propagation across cycles is not guaranteed. +// --------------------------------------------------------------------------- + +describe('Cross-File Binding Propagation: TypeScript circular imports', () => { + let result: PipelineResult; + let pipelineError: unknown; + + beforeAll(async () => { + try { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'ts-circular'), + () => {}, + ); + } catch (err) { + pipelineError = err; + } + }, 60000); + + it('pipeline completes without throwing on circular imports', () => { + expect(pipelineError).toBeUndefined(); + }); + + it('detects both class A and class B', () => { + expect(getNodesByLabel(result, 'Class')).toContain('A'); + expect(getNodesByLabel(result, 'Class')).toContain('B'); + }); + + it('detects doA and doB methods', () => { + expect(getNodesByLabel(result, 'Method')).toContain('doA'); + expect(getNodesByLabel(result, 'Method')).toContain('doB'); + }); + + it('detects processA and getB functions', () => { + expect(getNodesByLabel(result, 'Function')).toContain('processA'); + expect(getNodesByLabel(result, 'Function')).toContain('getB'); + }); + + it('emits IMPORTS edges reflecting the circular dependency', () => { + const imports = getRelationships(result, 'IMPORTS'); + const paths = imports.map(e => `${e.sourceFilePath} → ${e.targetFilePath}`); + // a.ts imports from b.ts + expect(paths.some(p => p.includes('a.ts') && p.includes('b.ts'))).toBe(true); + // b.ts imports from a.ts + expect(paths.some(p => p.includes('b.ts') && p.includes('a.ts'))).toBe(true); + }); +}); diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 010fdd4d0..4bdf84b56 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.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'; @@ -49,9 +49,12 @@ describe('C++ diamond inheritance', () => { ]); }); - it('captures 1 Method node from duck.cpp (speak)', () => { + it('captures speak as Method nodes (declaration in headers + definition in .cpp)', () => { const methods = getNodesByLabel(result, 'Method'); - expect(methods).toEqual(['speak']); + expect(methods).toContain('speak'); + // speak appears in animal.h (virtual declaration), duck.h (override declaration), + // and duck.cpp (out-of-line definition) — all captured as Method nodes + expect(methods.filter(m => m === 'speak').length).toBeGreaterThanOrEqual(1); }); it('no OVERRIDES edges target Property nodes', () => { @@ -1135,3 +1138,69 @@ describe('C++ default parameter arity resolution', () => { expect(greetCalls.length).toBe(1); }); }); + +// --------------------------------------------------------------------------- +// Phase 14: Cross-file binding propagation (via synthesized wildcard imports) +// models/user.h declares User class with save() and get_name() methods +// models/user_factory.h declares User get_user() free function +// app/main.cpp includes user_factory.h, calls get_user().save() +// → user is typed User via cross-file return type propagation +// --------------------------------------------------------------------------- + +describe('C++ cross-file binding propagation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'cpp-cross-file'), + () => {}, + ); + }, 60000); + + it('detects User class with save and get_name methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('get_name'); + }); + + it('detects get_user factory function and process consumer', () => { + expect(getNodesByLabel(result, 'Function')).toContain('get_user'); + expect(getNodesByLabel(result, 'Function')).toContain('process'); + }); + + it('emits IMPORTS edge from main.cpp to headers', () => { + const imports = getRelationships(result, 'IMPORTS'); + const edge = imports.find(e => + e.sourceFilePath.includes('main') && e.targetFilePath.includes('models'), + ); + expect(edge).toBeDefined(); + }); + + it('resolves user.save() in process() to User#save via cross-file propagation', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && + c.source === 'process' && + c.targetFilePath.includes('models'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves user.get_name() in process() to User#get_name via cross-file propagation', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCall = calls.find(c => + c.target === 'get_name' && + c.source === 'process' && + c.targetFilePath.includes('models'), + ); + expect(getNameCall).toBeDefined(); + }); + + it('emits HAS_METHOD edges linking save and get_name to User (via header declarations)', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'save'); + const getNameEdge = hasMethod.find(e => e.source === 'User' && e.target === 'get_name'); + expect(saveEdge).toBeDefined(); + expect(getNameEdge).toBeDefined(); + }); +}); 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/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 47349084a..69bb68da6 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.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'; @@ -1182,3 +1182,68 @@ describe('Go inc/dec write access tracking (Phase B)', () => { expect(countDec).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Phase 14: Cross-file binding propagation (via synthesized wildcard imports) +// models/user.go exports User struct with Save() and GetName() methods +// models/factory.go exports GetUser() -> User +// app/main.go imports models package, calls models.GetUser().Save() +// → user is typed User via cross-file return type propagation +// --------------------------------------------------------------------------- + +describe('Go cross-file binding propagation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'go-cross-file'), + () => {}, + ); + }, 60000); + + it('detects User struct with Save and GetName methods', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('Save'); + expect(getNodesByLabel(result, 'Method')).toContain('GetName'); + }); + + it('detects GetUser factory function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('GetUser'); + }); + + it('emits IMPORTS edge from main.go to models package files', () => { + const imports = getRelationships(result, 'IMPORTS'); + const edge = imports.find(e => + e.sourceFilePath.includes('main') && e.targetFilePath.includes('models'), + ); + expect(edge).toBeDefined(); + }); + + it('resolves user.Save() in main() to User#Save via cross-file propagation', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'Save' && + c.source === 'main' && + c.targetFilePath.includes('models'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves user.GetName() in main() to User#GetName via cross-file propagation', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCall = calls.find(c => + c.target === 'GetName' && + c.source === 'main' && + c.targetFilePath.includes('models'), + ); + expect(getNameCall).toBeDefined(); + }); + + it('emits HAS_METHOD edges linking Save and GetName to User', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'Save'); + const getNameEdge = hasMethod.find(e => e.source === 'User' && e.target === 'GetName'); + expect(saveEdge).toBeDefined(); + expect(getNameEdge).toBeDefined(); + }); +}); 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/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index d4df2e2c0..c52563de1 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'path'; import { - FIXTURES, getRelationships, getNodesByLabel, edgeSet, + FIXTURES, CROSS_FILE_FIXTURES, getRelationships, getNodesByLabel, edgeSet, runPipelineFromRepo, type PipelineResult, } from './helpers.js'; @@ -1079,3 +1079,69 @@ describe('Ruby default parameter arity resolution', () => { expect(greetCalls.length).toBe(1); }); }); + +// --------------------------------------------------------------------------- +// Phase 14: Cross-file binding propagation (via synthesized wildcard imports) +// models/user.rb exports User class with save and get_name methods +// models/user_factory.rb exports UserFactory with self.get_user -> User.new +// app.rb requires both, calls UserFactory.get_user then .save / .get_name +// → user is typed User via cross-file return type propagation +// --------------------------------------------------------------------------- + +describe('Ruby cross-file binding propagation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'rb-cross-file'), + () => {}, + ); + }, 60000); + + it('detects User class with save and get_name methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('get_name'); + }); + + it('detects UserFactory class and get_user method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('UserFactory'); + expect(getNodesByLabel(result, 'Method')).toContain('get_user'); + }); + + it('emits IMPORTS edge from app.rb to models', () => { + const imports = getRelationships(result, 'IMPORTS'); + const edge = imports.find(e => + e.sourceFilePath.includes('app') && e.targetFilePath.includes('models'), + ); + expect(edge).toBeDefined(); + }); + + it('resolves user.save in process to User#save via cross-file propagation', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && + c.source === 'process' && + c.targetFilePath.includes('models'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves user.get_name in process to User#get_name via cross-file propagation', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCall = calls.find(c => + c.target === 'get_name' && + c.source === 'process' && + c.targetFilePath.includes('models'), + ); + expect(getNameCall).toBeDefined(); + }); + + it('emits HAS_METHOD edges linking save and get_name to User', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'save'); + const getNameEdge = hasMethod.find(e => e.source === 'User' && e.target === 'get_name'); + expect(saveEdge).toBeDefined(); + expect(getNameEdge).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index 26bbf9cb0..4ea635f38 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.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'; @@ -1512,3 +1512,69 @@ describe('Rust struct_pattern destructuring resolution (Phase A)', () => { expect(saveCalls.length).toBeGreaterThanOrEqual(1); }); }); + +// --------------------------------------------------------------------------- +// Phase 14: Cross-file binding propagation +// src/models.rs exports User struct with save() and get_name() methods +// src/factory.rs exports get_user() -> User (uses crate::models::User) +// src/main.rs uses crate::factory::get_user, calls u.save() / u.get_name() +// → u is typed User via cross-file return type propagation +// --------------------------------------------------------------------------- + +describe('Rust cross-file binding propagation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'rs-cross-file'), + () => {}, + ); + }, 60000); + + it('detects User struct with save and get_name methods', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Function')).toContain('save'); + expect(getNodesByLabel(result, 'Function')).toContain('get_name'); + }); + + it('detects get_user and process functions', () => { + expect(getNodesByLabel(result, 'Function')).toContain('get_user'); + expect(getNodesByLabel(result, 'Function')).toContain('process'); + }); + + it('emits IMPORTS edge from main.rs to factory.rs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const edge = imports.find(e => + e.sourceFilePath.includes('main') && e.targetFilePath.includes('factory'), + ); + expect(edge).toBeDefined(); + }); + + it('resolves u.save() in process() to User#save via cross-file return type propagation', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && + c.source === 'process' && + c.targetFilePath.includes('models'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves u.get_name() in process() to User#get_name via cross-file return type propagation', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCall = calls.find(c => + c.target === 'get_name' && + c.source === 'process' && + c.targetFilePath.includes('models'), + ); + expect(getNameCall).toBeDefined(); + }); + + it('emits HAS_METHOD edges linking save and get_name to User', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'save'); + const getNameEdge = hasMethod.find(e => e.source === 'User' && e.target === 'get_name'); + expect(saveEdge).toBeDefined(); + expect(getNameEdge).toBeDefined(); + }); +}); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index 429bdd0d2..3b9b2c552 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { processCallsFromExtracted } from '../../src/core/ingestion/call-processor.js'; +import { processCallsFromExtracted, seedCrossFileReceiverTypes } from '../../src/core/ingestion/call-processor.js'; import { extractReturnTypeName } from '../../src/core/ingestion/type-extractors/shared.js'; import { createResolutionContext, type ResolutionContext } from '../../src/core/ingestion/resolution-context.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; @@ -868,3 +868,167 @@ describe('extractReturnTypeName', () => { expect(extractReturnTypeName('UserService')).toBe('UserService'); }); }); + +describe('seedCrossFileReceiverTypes', () => { + it('single-hop: imported receiver gets type from ExportedTypeMap', () => { + const calls: ExtractedCall[] = [{ + filePath: 'src/service.ts', + calledName: 'save', + sourceId: 'Function:src/service.ts:run', + receiverName: 'repo', + callForm: 'member', + }]; + + const namedImportMap = new Map([ + ['src/service.ts', new Map([ + ['repo', { sourcePath: 'src/models/repo.ts', exportedName: 'repo' }], + ])], + ]); + + const exportedTypeMap = new Map([ + ['src/models/repo.ts', new Map([ + ['repo', 'Repo'], + ])], + ]); + + const { enrichedCount } = seedCrossFileReceiverTypes(calls, namedImportMap, exportedTypeMap); + + expect(enrichedCount).toBe(1); + expect(calls[0].receiverTypeName).toBe('Repo'); + }); + + it('no-op when receiverTypeName already exists', () => { + const calls: ExtractedCall[] = [{ + filePath: 'src/service.ts', + calledName: 'save', + sourceId: 'Function:src/service.ts:run', + receiverName: 'repo', + receiverTypeName: 'AlreadyKnown', + callForm: 'member', + }]; + + const namedImportMap = new Map([ + ['src/service.ts', new Map([ + ['repo', { sourcePath: 'src/models/repo.ts', exportedName: 'repo' }], + ])], + ]); + + const exportedTypeMap = new Map([ + ['src/models/repo.ts', new Map([ + ['repo', 'Repo'], + ])], + ]); + + const { enrichedCount } = seedCrossFileReceiverTypes(calls, namedImportMap, exportedTypeMap); + + expect(enrichedCount).toBe(0); + expect(calls[0].receiverTypeName).toBe('AlreadyKnown'); + }); + + it('no-op for free function calls (callForm !== member)', () => { + const calls: ExtractedCall[] = [{ + filePath: 'src/service.ts', + calledName: 'doSomething', + sourceId: 'Function:src/service.ts:run', + receiverName: 'repo', + callForm: 'free', + }]; + + const namedImportMap = new Map([ + ['src/service.ts', new Map([ + ['repo', { sourcePath: 'src/models/repo.ts', exportedName: 'repo' }], + ])], + ]); + + const exportedTypeMap = new Map([ + ['src/models/repo.ts', new Map([ + ['repo', 'Repo'], + ])], + ]); + + const { enrichedCount } = seedCrossFileReceiverTypes(calls, namedImportMap, exportedTypeMap); + + expect(enrichedCount).toBe(0); + expect(calls[0].receiverTypeName).toBeUndefined(); + }); + + it('aliased imports: local name maps to exported name via binding', () => { + const calls: ExtractedCall[] = [{ + filePath: 'src/controller.ts', + calledName: 'find', + sourceId: 'Function:src/controller.ts:handle', + receiverName: 'myRepo', + callForm: 'member', + }]; + + // import { repoInstance as myRepo } from 'src/models/repo.ts' + const namedImportMap = new Map([ + ['src/controller.ts', new Map([ + ['myRepo', { sourcePath: 'src/models/repo.ts', exportedName: 'repoInstance' }], + ])], + ]); + + const exportedTypeMap = new Map([ + ['src/models/repo.ts', new Map([ + ['repoInstance', 'Repo'], + ])], + ]); + + const { enrichedCount } = seedCrossFileReceiverTypes(calls, namedImportMap, exportedTypeMap); + + expect(enrichedCount).toBe(1); + expect(calls[0].receiverTypeName).toBe('Repo'); + }); + + it('early exit when maps are empty', () => { + const calls: ExtractedCall[] = [{ + filePath: 'src/service.ts', + calledName: 'save', + sourceId: 'Function:src/service.ts:run', + receiverName: 'repo', + callForm: 'member', + }]; + + const { enrichedCount: countA } = seedCrossFileReceiverTypes( + calls, + new Map(), + new Map([['src/models/repo.ts', new Map([['repo', 'Repo']])]]), + ); + expect(countA).toBe(0); + + const { enrichedCount: countB } = seedCrossFileReceiverTypes( + calls, + new Map([['src/service.ts', new Map([['repo', { sourcePath: 'src/models/repo.ts', exportedName: 'repo' }]])]]), + new Map(), + ); + expect(countB).toBe(0); + + expect(calls[0].receiverTypeName).toBeUndefined(); + }); + + it('no mutation when no matching exports found', () => { + const calls: ExtractedCall[] = [{ + filePath: 'src/service.ts', + calledName: 'save', + sourceId: 'Function:src/service.ts:run', + receiverName: 'repo', + callForm: 'member', + }]; + + // namedImportMap has the file, but exportedTypeMap has no entry for the source path + const namedImportMap = new Map([ + ['src/service.ts', new Map([ + ['repo', { sourcePath: 'src/models/repo.ts', exportedName: 'repo' }], + ])], + ]); + + const exportedTypeMap = new Map([ + ['src/other-file.ts', new Map([['something', 'OtherType']])], + ]); + + const { enrichedCount } = seedCrossFileReceiverTypes(calls, namedImportMap, exportedTypeMap); + + expect(enrichedCount).toBe(0); + expect(calls[0].receiverTypeName).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/topological-sort.test.ts b/gitnexus/test/unit/topological-sort.test.ts new file mode 100644 index 000000000..25ad787d9 --- /dev/null +++ b/gitnexus/test/unit/topological-sort.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import { topologicalLevelSort } from '../../src/core/ingestion/pipeline.js'; + +describe('topologicalLevelSort', () => { + it('returns empty levels for empty graph', () => { + const importMap = new Map>(); + const { levels, cycleCount } = topologicalLevelSort(importMap); + expect(levels).toEqual([]); + expect(cycleCount).toBe(0); + }); + + it('returns single level for files with no imports', () => { + const importMap = new Map>([ + ['a.ts', new Set()], + ['b.ts', new Set()], + ]); + const { levels } = topologicalLevelSort(importMap); + expect(levels).toHaveLength(1); + expect(levels[0]).toContain('a.ts'); + expect(levels[0]).toContain('b.ts'); + }); + + it('orders files by dependency depth', () => { + // b imports a, c imports b → a first, then b, then c + const importMap = new Map>([ + ['a.ts', new Set()], + ['b.ts', new Set(['a.ts'])], + ['c.ts', new Set(['b.ts'])], + ]); + const { levels } = topologicalLevelSort(importMap); + expect(levels).toHaveLength(3); + expect(levels[0]).toContain('a.ts'); + expect(levels[1]).toContain('b.ts'); + expect(levels[2]).toContain('c.ts'); + }); + + it('groups independent files at the same level', () => { + // b and c both import a → a at level 0, b and c at level 1 + const importMap = new Map>([ + ['a.ts', new Set()], + ['b.ts', new Set(['a.ts'])], + ['c.ts', new Set(['a.ts'])], + ]); + const { levels } = topologicalLevelSort(importMap); + expect(levels).toHaveLength(2); + expect(levels[0]).toContain('a.ts'); + expect(levels[1]).toContain('b.ts'); + expect(levels[1]).toContain('c.ts'); + }); + + it('handles cycles by grouping them in a final level', () => { + // a imports b, b imports a — circular + const importMap = new Map>([ + ['a.ts', new Set(['b.ts'])], + ['b.ts', new Set(['a.ts'])], + ]); + const { levels, cycleCount } = topologicalLevelSort(importMap); + // Both should appear (in a cycle group) + const allFiles = levels.flat(); + expect(allFiles).toContain('a.ts'); + expect(allFiles).toContain('b.ts'); + expect(cycleCount).toBe(2); + }); + + it('handles disconnected components', () => { + // Two independent groups with no cross-links + const importMap = new Map>([ + ['a.ts', new Set()], + ['b.ts', new Set(['a.ts'])], + ['x.ts', new Set()], + ['y.ts', new Set(['x.ts'])], + ]); + const { levels } = topologicalLevelSort(importMap); + // Level 0 has both roots, level 1 has both dependents + expect(levels[0]).toContain('a.ts'); + expect(levels[0]).toContain('x.ts'); + expect(levels[1]).toContain('b.ts'); + expect(levels[1]).toContain('y.ts'); + }); + + it('handles diamond dependencies', () => { + // d imports b and c; b and c both import a + const importMap = new Map>([ + ['a.ts', new Set()], + ['b.ts', new Set(['a.ts'])], + ['c.ts', new Set(['a.ts'])], + ['d.ts', new Set(['b.ts', 'c.ts'])], + ]); + const { levels } = topologicalLevelSort(importMap); + expect(levels).toHaveLength(3); + expect(levels[0]).toContain('a.ts'); + expect(levels[1]).toContain('b.ts'); + expect(levels[1]).toContain('c.ts'); + expect(levels[2]).toContain('d.ts'); + }); + + it('handles a single file with no imports', () => { + const importMap = new Map>([ + ['only.ts', new Set()], + ]); + const { levels } = topologicalLevelSort(importMap); + expect(levels).toHaveLength(1); + expect(levels[0]).toContain('only.ts'); + }); + + it('handles a dependency on a file not explicitly in the map', () => { + // b imports external.ts which is not itself a key in importMap + const importMap = new Map>([ + ['b.ts', new Set(['external.ts'])], + ]); + const { levels } = topologicalLevelSort(importMap); + // external.ts has in-degree 0 (no one depends on it as a key), appears first + // b.ts depends on external.ts so appears after + const allFiles = levels.flat(); + expect(allFiles).toContain('external.ts'); + expect(allFiles).toContain('b.ts'); + const externalLevel = levels.findIndex(l => l.includes('external.ts')); + const bLevel = levels.findIndex(l => l.includes('b.ts')); + expect(externalLevel).toBeLessThan(bLevel); + }); + + it('handles a cycle mixed with an acyclic dependent', () => { + // a and b are cyclic; c depends on b (and ends up in cycle group too + // because b never reaches in-degree 0) + const importMap = new Map>([ + ['a.ts', new Set(['b.ts'])], + ['b.ts', new Set(['a.ts'])], + ['c.ts', new Set(['b.ts'])], + ]); + const { levels } = topologicalLevelSort(importMap); + // No file has in-degree 0 to start: a needs b, b needs a, c needs b + // All end up in the cycle group + const allFiles = levels.flat(); + expect(allFiles).toContain('a.ts'); + expect(allFiles).toContain('b.ts'); + expect(allFiles).toContain('c.ts'); + }); + + it('all files appear exactly once across all levels', () => { + const importMap = new Map>([ + ['a.ts', new Set()], + ['b.ts', new Set(['a.ts'])], + ['c.ts', new Set(['a.ts'])], + ['d.ts', new Set(['b.ts', 'c.ts'])], + ]); + const { levels } = topologicalLevelSort(importMap); + const allFiles = levels.flat(); + const uniqueFiles = new Set(allFiles); + expect(uniqueFiles.size).toBe(allFiles.length); + expect(uniqueFiles.size).toBe(4); + }); +}); diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 5ec9c05c3..2e7eaa78c 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -4044,4 +4044,201 @@ class App { }); }); + describe('Phase 14: importedBindings seeding', () => { + it('seeds imported bindings into file scope for unbound names', () => { + // Source has no local declaration of 'config', so the imported binding wins + const tree = parse('', TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript', { + importedBindings: new Map([['config', 'Config']]), + }); + const fileScope = env.get(''); + expect(fileScope?.get('config')).toBe('Config'); + }); + + it('local declarations take precedence over imported bindings', () => { + // Source declares config: AppConfig — imported binding for 'config' must not overwrite it + const tree = parse('const config: AppConfig = getConfig();', TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript', { + importedBindings: new Map([['config', 'Config']]), + }); + const fileScope = env.get(''); + // AppConfig from the local annotation wins over the imported 'Config' + expect(fileScope?.get('config')).toBe('AppConfig'); + }); + + it('does nothing when importedBindings is empty', () => { + const tree = parse('const user: User = getUser();', TypeScript.typescript); + const { env: envWithout } = buildTypeEnv(tree, 'typescript'); + const { env: envWith } = buildTypeEnv(tree, 'typescript', { + importedBindings: new Map(), + }); + // Both envs should produce the same file-scope content + const scopeWithout = envWithout.get(''); + const scopeWith = envWith.get(''); + expect(scopeWith?.get('user')).toBe(scopeWithout?.get('user')); + expect(scopeWith?.size).toBe(scopeWithout?.size); + }); + + it('seeds multiple bindings', () => { + const tree = parse('', TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript', { + importedBindings: new Map([ + ['user', 'User'], + ['config', 'Config'], + ]), + }); + const fileScope = env.get(''); + expect(fileScope?.get('user')).toBe('User'); + expect(fileScope?.get('config')).toBe('Config'); + }); + + it('seeded bindings are reachable via lookup from a nested call node', () => { + // A call inside a function should still be able to look up a file-scope seeded binding + const code = ` +function process() { + config.validate(); +}`; + const tree = parse(code, TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript', { + importedBindings: new Map([['config', 'Config']]), + }); + + const calls: any[] = []; + function findCalls(node: any) { + if (node.type === 'call_expression') calls.push(node); + for (let i = 0; i < node.childCount; i++) findCalls(node.child(i)); + } + findCalls(tree.rootNode); + + // config.validate() — lookup 'config' from inside the process function scope + expect(typeEnv.lookup('config', calls[0])).toBe('Config'); + }); + + it('seeds bindings with no conflict when local file has unrelated declarations', () => { + // File has 'user' declared locally; 'config' comes from importedBindings + const tree = parse('const user: User = getUser();', TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript', { + importedBindings: new Map([['config', 'Config']]), + }); + const fileScope = env.get(''); + // Local binding preserved + expect(fileScope?.get('user')).toBe('User'); + // Imported binding added for the name that had no local declaration + expect(fileScope?.get('config')).toBe('Config'); + }); + }); + + describe('importedReturnTypes (Phase 14 E3)', () => { + // Minimal mock SymbolTable that returns a known callable + const makeSymbolTable = ( + callables: Array<{ name: string; returnType?: string }>, + ) => ({ + lookupFuzzyCallable: (name: string) => + callables + .filter(c => c.name === name) + .map(c => ({ nodeId: 'n1', filePath: 'src.ts', type: 'Function' as const, returnType: c.returnType })), + lookupFuzzy: () => [], + lookupExact: () => undefined, + lookupExactFull: () => undefined, + add: () => {}, + getStats: () => ({ fileCount: 0, globalSymbolCount: 0 }), + clear: () => {}, + }); + + it('SymbolTable has unambiguous match → uses it, ignores cross-file', () => { + // SymbolTable knows getConfig() returns Config (SymbolType) + // importedReturnTypes says getConfig → WrongType — SymbolTable must win + const symbolTable = makeSymbolTable([{ name: 'getConfig', returnType: 'Config' }]); + const tree = parse('const c = getConfig();', TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript', { + symbolTable: symbolTable as any, + importedReturnTypes: new Map([['getConfig', 'WrongType']]), + }); + // SymbolTable result (Config) wins over cross-file fallback (WrongType) + expect(flatGet(typeEnv.env, 'c')).toBe('Config'); + }); + + it('SymbolTable has no match (0 results) → falls back to cross-file', () => { + // SymbolTable knows nothing about getConfig + const symbolTable = makeSymbolTable([]); + const tree = parse('const c = getConfig();', TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript', { + symbolTable: symbolTable as any, + importedReturnTypes: new Map([['getConfig', 'Config']]), + }); + // Cross-file fallback provides Config + expect(flatGet(typeEnv.env, 'c')).toBe('Config'); + }); + + it('SymbolTable has 2+ matches (ambiguous) → returns undefined, NO cross-file fallback', () => { + // Two overloads of process() — ambiguous → must NOT fall back to cross-file + const symbolTable = makeSymbolTable([ + { name: 'process', returnType: 'User' }, + { name: 'process', returnType: 'Admin' }, + ]); + const tree = parse('const r = process();', TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript', { + symbolTable: symbolTable as any, + importedReturnTypes: new Map([['process', 'User']]), + }); + // Ambiguous → conservative → no binding produced + expect(flatGet(typeEnv.env, 'r')).toBeUndefined(); + }); + + it('no SymbolTable → uses cross-file return types directly', () => { + const tree = parse('const c = getConfig();', TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript', { + importedReturnTypes: new Map([['getConfig', 'Config']]), + }); + expect(flatGet(typeEnv.env, 'c')).toBe('Config'); + }); + + it('cross-file has entry but SymbolTable covers it → SymbolTable wins', () => { + // SymbolTable provides the authoritative return type; cross-file entry is ignored + const symbolTable = makeSymbolTable([{ name: 'getUser', returnType: 'User' }]); + const tree = parse('const u = getUser();', TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript', { + symbolTable: symbolTable as any, + importedReturnTypes: new Map([['getUser', 'CrossFileUser']]), + }); + // SymbolTable result (User) wins + expect(flatGet(typeEnv.env, 'u')).toBe('User'); + }); + + it('does nothing when importedReturnTypes is absent', () => { + const tree = parse('const c = getConfig();', TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript'); + // No annotation, no SymbolTable, no cross-file → no binding + expect(flatGet(typeEnv.env, 'c')).toBeUndefined(); + }); + + it('resolved cross-file callee enables downstream call edges via lookup', () => { + // File has: const c = getConfig(); c.validate(); + // importedReturnTypes maps getConfig → Config + // After fixpoint, c should be typed as Config, making lookup('c', ...) return 'Config' + const code = ` +function process() { + const c = getConfig(); + c.validate(); +}`; + const tree = parse(code, TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript', { + importedReturnTypes: new Map([['getConfig', 'Config']]), + }); + + const calls: any[] = []; + function findCalls(node: any) { + if (node.type === 'call_expression') calls.push(node); + for (let i = 0; i < node.childCount; i++) findCalls(node.child(i)); + } + findCalls(tree.rootNode); + + // calls[0] = getConfig(), calls[1] = c.validate() + // From inside process(), c should resolve to Config + const validateCall = calls.find((n: any) => n.text.includes('validate')); + expect(validateCall).toBeDefined(); + expect(typeEnv.lookup('c', validateCall)).toBe('Config'); + }); + }); + }); diff --git a/type-resolution-roadmap.md b/type-resolution-roadmap.md index da1de8890..b43298ce5 100644 --- a/type-resolution-roadmap.md +++ b/type-resolution-roadmap.md @@ -125,35 +125,51 @@ Languages benefiting: Java, Kotlin, C#, C++, TypeScript (overloading). All OOP l --- -### Phase 14: Cross-File Binding Propagation +### Phase 14: Cross-File Binding Propagation ✅ -**Problem:** `buildTypeEnv` is per-file. Inferred types don't cross file boundaries. +**Shipped in** `feat/phase14-cross-file-binding-propagation`. -```typescript -// file-a.ts — fixpoint resolves: config → Config -export const config = getConfig(); +Three enrichment mechanisms: +- **E1:** `seedCrossFileReceiverTypes` — pre-seeds `receiverTypeName` for single-hop imported receivers (zero re-parse) +- **E2:** `ExportedTypeMap` seeded into `importedBindings` for re-resolution pass +- **E3:** `buildImportedReturnTypes` — cross-file return types for imported callables (local-first, SymbolTable takes precedence) -// file-b.ts — config has no type -import { config } from './file-a'; -config.validate(); // missed -``` +Architecture: +- Topological import ordering via Kahn's BFS (`topologicalLevelSort`, returns `{ levels, cycleCount }`) +- Cycle-safe: files in cycles grouped in final level, no cross-cycle propagation +- `runCrossFileBindingPropagation()` extracted as standalone pipeline phase +- `synthesizeWildcardImportBindings()` expands whole-module imports (Go/Ruby/C/C++/Swift) into per-symbol namedImportMap entries from graph-exported symbols — runs before Phase 14 +- Worker path: `buildExportedTypeMapFromGraph` collects Tier 0 (annotated) exports only +- Sequential path: `collectExportedBindings` captures full fixpoint-inferred exports -**Approach: Export-type index.** After each file's fixpoint, export resolved bindings for exported symbols into `ExportedBindings: Map>`. Subsequent files seed scopeEnv from this index for imported symbols. +**Per-language Phase 14 coverage:** +| Language | namedImportMap | ExportedTypeMap (E1/E2) | E3 (importedReturnTypes) | Benefit | +|----------|:-:|:-:|:-:|---| +| TypeScript | Full (named imports) | File-scope vars | Full | **High** | +| JavaScript | Full (named imports) | File-scope vars | Full | **High** | +| Python | from-imports | File-scope vars | Full | **High** | +| Kotlin | Top-level fns | Top-level props | Full | **High** | +| Rust | use clauses | Limited | Full | **High** | +| Go | Synthesized¹ | Exported symbols | Full | **Medium** | +| Ruby | Synthesized¹ | Exported symbols | Full | **Medium** | +| C/C++ | Synthesized¹ | Exported symbols | Full | **Medium** | +| Swift | Synthesized¹ | Exported symbols | Full | **Low** (Phase S blocked) | +| PHP | use classes | Inert (class-scope) | Inert (no fn imports) | **Marginal** | +| Java | Classes + static methods | Inert (no file-scope) | Via SymbolTable | **Medium** | +| C# | Alias + `using static` | Inert (no file-scope) | Via SymbolTable | **Medium** | -**Details:** -- Process files in topological import order (import-processor already builds the dependency graph) -- Re-exports: follow import chain transitively in `ExportedBindings` -- Barrel files (`index.ts`): chain of re-exports — same mechanism -- Default exports: keyed as `"default"` in the map, mapped to local name at import site -- Dynamic imports (`import()`, conditional `require()`): excluded — runtime-only edges -- Circular imports: files in a cycle processed in arbitrary order within the cycle; cross-cycle bindings don't propagate (conservative) -- Parallelism preserved within topological levels +¹ Whole-module import languages: namedImportMap entries synthesized from graph-exported symbols via `synthesizeWildcardImportBindings()` (capped at 1000 per file) -**Why this is last:** Every earlier phase makes the per-file fixpoint stronger, reducing cases where cross-file propagation is needed. This is also the highest-risk architectural change. +**Named binding extraction details:** +- Java: `import static X.Y.method` now captured (static modifier detection). Ambiguous static imports (same method from multiple classes) fall through to Tier 2a for arity narrowing. +- C#: `using static NS.Type;` now captured (last segment as class binding). Non-alias `using NS;` remains unsupported (namespace import requires type inference). -**Risks:** Topological ordering correctness (mitigated by reusing import-processor's existing graph). Re-export chain depth (bounded by import depth, typically 2-3). Memory for `ExportedBindings` (~100K entries for 10K-file monorepo — negligible). +**Resolved limitations (this PR):** +- ~~Worker path vs sequential path quality split~~ — workers now return file-scope TypeEnv bindings; main thread merges fixpoint-inferred exports into ExportedTypeMap (filtered by graph `isExported`) +- ~~`lookupRawReturnType` no cross-file fallback~~ — separate `importedRawReturnTypes` map stores raw declared types (e.g., `User[]`) for for-loop element extraction via `extractElementTypeFromString` +- ~~C++ header method declarations~~ — tree-sitter query fix: `field_identifier` added to declaration pattern alongside `identifier`, plus pointer/reference return type variants -**Impact: High | Effort: High** +**Impact: High | Effort: High** — delivered --- @@ -161,14 +177,14 @@ config.validate(); // missed ``` Milestone D (Phases A, B, C) ✅ ──┐ - ├──→ Phase 14 (cross-file) + ├──→ Phase 14 (cross-file) ✅ Phase P (polymorphism) ───────────┤ │ Phase S (Swift parity) ───────────┘ Phase P.1–P.4 are delivered. P.5 (covariant return types) remains open. Phase P and Phase S are independent of each other and Phase 14. -Phase 14 benefits from Phase P (better per-file resolution = fewer cross-file gaps). +Phase 14 is delivered. Remaining open: Phase P.5, Phase S. ``` --- @@ -184,7 +200,7 @@ Phase 14 benefits from Phase P (better per-file resolution = fewer cross-file ga - ~~Virtual dispatch: `Dog()` uses `call_expression` (no `new` keyword)~~ — **RESOLVED** via `detectConstructorType` hook ### All languages -- Cross-file binding propagation → Phase 14 +- ~~Cross-file binding propagation → Phase 14~~ — **DELIVERED** for all 13 languages via two mechanisms: (1) named import extraction (TS/JS/Python/Kotlin/Rust/PHP/Java/C#), (2) wildcard import synthesis from graph-exported symbols (Go/Ruby/C/C++/Swift). Remaining gap: C# non-alias `using NS;` (namespace import, requires type inference). --- @@ -206,9 +222,9 @@ Unified fixpoint loop, call-result binding, field access binding, method-call-re Consolidated Phases 10–13 into 3 balanced phases. Loop-fixpoint bridge, MRO-aware inheritance walking, `this`/`self` resolution, object/struct destructuring, null-check narrowing. Kotlin null-check bug fix. Full 11-language integration test coverage. -### Milestone E — Cross-Boundary ← **next** (Phase 14) +### Milestone E — Cross-Boundary ✅ (Phase 14) -Export-type index, cross-file binding propagation. +Export-type index, cross-file binding propagation. Full coverage for TS/JS/Python/Kotlin. Marginal for PHP. Inert for Java/C#/Go/Ruby/C/C++ (relies on Phase 9 SymbolTable). ### Milestone P — Polymorphism & Overloading (Phase P) diff --git a/type-resolution-system.md b/type-resolution-system.md index 605e1c6b6..62a59e760 100644 --- a/type-resolution-system.md +++ b/type-resolution-system.md @@ -9,7 +9,7 @@ This system is designed to be: - **Conservative** — it prefers missing a binding over introducing a misleading one - **Walk + fixpoint** — bindings are collected during a single AST walk, then a unified fixpoint loop iterates over pending assignments (copy, callResult, fieldAccess, methodCallResult) until no new bindings are produced - **Scope-aware** — function-local bindings are isolated from file-level bindings -- **Per-file** — the environment is built for one file at a time, though it may consult the global `SymbolTable` for validation in specific cases +- **Per-file with cross-file seeding** — the environment is built for one file at a time, but Phase 14 seeds upstream bindings (imported types, return types) into the file scope before the fixpoint for all 13 languages It is **not** a full compiler type checker. Its job is to recover enough type information to improve call-edge accuracy during ingestion. @@ -120,10 +120,16 @@ It does: It does not: - perform full semantic type checking -- run fixpoint inference -- propagate inferred bindings across files as ordinary environment entries - guarantee resolution for every ambiguous construct +It now does (Phase 14): + +- run a unified fixpoint loop per file for copy/callResult/fieldAccess/methodCallResult chains +- propagate inferred bindings across files for all 13 supported languages: + - **Named import extraction** (TS/JS/Python/Kotlin/Rust/PHP/Java/C#): per-symbol bindings extracted from import AST nodes + - **Wildcard import synthesis** (Go/Ruby/C/C++/Swift): namedImportMap entries synthesized from graph-exported symbols via `synthesizeWildcardImportBindings()`, enabling cross-file propagation for whole-module-import languages +- seed imported bindings into file scope after walk, before fixpoint (local declarations always win) + --- ## TypeEnvironment Model @@ -389,6 +395,7 @@ So return-type-aware receiver inference already exists in a constrained downstre | Method overload disambiguation | Yes** | No | Yes | Yes | Yes | No | No | No | No | No | No | Yes | No | | Constructor-visible virtual dispatch | Yes | No | Yes | Yes‡‡ | Yes | No | No | No | No | No | No | Yes§§ | No | | Optional parameter arity resolution | Yes | No | No | Yes | Yes | No | No | Yes | Yes | Yes | No | Yes | No | +| Cross-file binding propagation | Yes | Yes | Yes‖‖ | Yes | Yes¶¶ | Yes*** | Yes | Yes | Partial | Yes*** | Yes*** | Yes*** | Yes*** | \* Python class-level annotated attributes (`address: Address`) now resolve `declaredType` correctly. The `self.x` instance attribute pattern is not yet supported. @@ -412,6 +419,12 @@ So return-type-aware receiver inference already exists in a constrained downstre §§ C++ smart pointer virtual dispatch supported for `make_shared()`/`make_unique()` factory patterns. Raw pointer `new` also supported. +‖‖ Java: `import static X.Y.method` now captured. Ambiguous static imports (same name from multiple classes) fall through to Tier 2a for arity narrowing. Non-static lowercase imports still skipped (package imports). + +¶¶ C#: `using static NS.Type;` now captured (last segment as class binding). Non-alias `using NS;` still unsupported — namespace imports can't be reduced to per-symbol bindings without type inference. + +\*\*\* Whole-module-import languages (Go, Ruby, C/C++, Swift): namedImportMap entries synthesized from graph-exported symbols via `synthesizeWildcardImportBindings()`. Not from import AST node extraction. + --- ## Current Strengths @@ -432,6 +445,7 @@ The current system provides strong value for call resolution because it combines - method overload disambiguation via argument literal types (Java, Kotlin, C#, C++) - constructor-visible virtual dispatch for same-file subclasses (Java, C#, TypeScript, C++, Kotlin) - optional/default parameter arity resolution — calls with omitted optional args still resolve (TS, Python, Kotlin, C#, C++, PHP, Ruby) +- cross-file binding propagation across all 13 languages — named import extraction for languages with per-symbol imports, wildcard import synthesis for whole-module-import languages (Go, Ruby, C/C++, Swift) This is enough to materially improve call-edge precision even without implementing a full static type system.