diff --git a/.github/workflows/ci-integration.yml b/.github/workflows/ci-integration.yml index a6c1bc6e30..2043eb9ff5 100644 --- a/.github/workflows/ci-integration.yml +++ b/.github/workflows/ci-integration.yml @@ -23,7 +23,7 @@ jobs: # on Linux, and its C++ destructors segfault during # process.exit(). Running each file in its own process lets # the OS reclaim all resources cleanly. - # pipeline — 3 files: ingestion pipeline + csv, each creates own temp DB + # pipeline — 12 files: ingestion pipeline + csv + 9 resolver tests # e2e — 2 files: child-process only (spawnSync), no in-process kuzu # standalone — 4 files: pure logic, no kuzu, no child processes test-matrix: @@ -42,6 +42,15 @@ jobs: test/integration/pipeline.test.ts test/integration/csv-pipeline.test.ts test/integration/parsing.test.ts + test/integration/resolvers/typescript.test.ts + test/integration/resolvers/csharp.test.ts + test/integration/resolvers/cpp.test.ts + test/integration/resolvers/java.test.ts + test/integration/resolvers/python.test.ts + test/integration/resolvers/rust.test.ts + test/integration/resolvers/go.test.ts + test/integration/resolvers/kotlin.test.ts + test/integration/resolvers/php.test.ts - test-group: e2e test-glob: >- test/integration/cli-e2e.test.ts @@ -141,6 +150,15 @@ jobs: test/integration/enrichment.test.ts test/integration/tree-sitter-languages.test.ts test/integration/worker-pool.test.ts + test/integration/resolvers/typescript.test.ts + test/integration/resolvers/csharp.test.ts + test/integration/resolvers/cpp.test.ts + test/integration/resolvers/java.test.ts + test/integration/resolvers/python.test.ts + test/integration/resolvers/rust.test.ts + test/integration/resolvers/go.test.ts + test/integration/resolvers/kotlin.test.ts + test/integration/resolvers/php.test.ts - name: Upload integration coverage if: always() diff --git a/AGENTS.md b/AGENTS.md index 5dd5532629..6a9f026a40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (1683 symbols, 4407 relationships, 127 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (1747 symbols, 4569 relationships, 130 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 5dd5532629..6a9f026a40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (1683 symbols, 4407 relationships, 127 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (1747 symbols, 4569 relationships, 130 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/README.md b/gitnexus/README.md index 7b66139791..957da11b9f 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -157,7 +157,26 @@ GitNexus supports indexing multiple repositories. Each `gitnexus analyze` regist ## Supported Languages -TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Swift +TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift + +### Language Feature Matrix + +| Language | Imports | Types | Exports | Named Bindings | Config | Frameworks | Entry Points | Heritage | +|----------|---------|-------|---------|----------------|--------|------------|-------------|----------| +| TypeScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| JavaScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Python | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Java | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | +| Kotlin | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | +| Go | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | +| Rust | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | +| PHP | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | +| Swift | — | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | +| C | — | ✓ | ✓ | — | — | ✓ | ✓ | ✓ | +| C++ | — | ✓ | ✓ | — | — | ✓ | ✓ | ✓ | + +**Imports** — cross-file import resolution · **Types** — type annotation extraction · **Exports** — public/exported symbol detection · **Named Bindings** — `import { X }` tracking · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics · **Heritage** — class inheritance / interface implementation ## Agent Skills diff --git a/gitnexus/src/core/graph/types.ts b/gitnexus/src/core/graph/types.ts index c675bdf1d1..aa9b245384 100644 --- a/gitnexus/src/core/graph/types.ts +++ b/gitnexus/src/core/graph/types.ts @@ -35,12 +35,14 @@ export type NodeLabel = | 'Template'; +import { SupportedLanguages } from '../../config/supported-languages.js'; + export type NodeProperties = { name: string, filePath: string, startLine?: number, endLine?: number, - language?: string, + language?: SupportedLanguages, isExported?: boolean, // Optional AST-derived framework hint (e.g. @Controller, @GetMapping) astFrameworkMultiplier?: number, @@ -61,19 +63,23 @@ export type NodeProperties = { // Entry point scoring (computed by process detection) entryPointScore?: number, entryPointReason?: string, + // Method signature (for MRO disambiguation) + parameterCount?: number, + returnType?: string, } -export type RelationshipType = - | 'CONTAINS' - | 'CALLS' - | 'INHERITS' - | 'OVERRIDES' +export type RelationshipType = + | 'CONTAINS' + | 'CALLS' + | 'INHERITS' + | 'OVERRIDES' | 'IMPORTS' | 'USES' | 'DEFINES' | 'DECORATES' | 'IMPLEMENTS' | 'EXTENDS' + | 'HAS_METHOD' | 'MEMBER_OF' | 'STEP_IN_PROCESS' diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 0651072ad4..6718faba87 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1,12 +1,25 @@ import { KnowledgeGraph } from '../graph/types.js'; import { ASTCache } from './ast-cache.js'; -import { SymbolTable } from './symbol-table.js'; -import { ImportMap } from './import-processor.js'; +import type { SymbolDefinition, SymbolTable } from './symbol-table.js'; +import { ImportMap, PackageMap, NamedImportMap, isFileInPackageDir } from './import-processor.js'; +import { resolveSymbol, resolveSymbolInternal } from './symbol-resolver.js'; +import { walkBindingChain } from './named-binding-extraction.js'; import Parser from 'tree-sitter'; import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js'; import { LANGUAGE_QUERIES } from './tree-sitter-queries.js'; import { generateId } from '../../lib/utils.js'; -import { getLanguageFromFilename, isVerboseIngestionEnabled, yieldToEventLoop, FUNCTION_NODE_TYPES, extractFunctionName, isBuiltInOrNoise } from './utils.js'; +import { + getLanguageFromFilename, + isVerboseIngestionEnabled, + yieldToEventLoop, + FUNCTION_NODE_TYPES, + extractFunctionName, + isBuiltInOrNoise, + countCallArguments, + inferCallForm, + extractReceiverName, +} from './utils.js'; +import { buildTypeEnv, lookupTypeEnv } from './type-env.js'; import { getTreeSitterBufferSize } from './constants.js'; import type { ExtractedCall, ExtractedRoute } from './workers/parse-worker.js'; @@ -44,7 +57,9 @@ export const processCalls = async ( astCache: ASTCache, symbolTable: SymbolTable, importMap: ImportMap, - onProgress?: (current: number, total: number) => void + packageMap?: PackageMap, + onProgress?: (current: number, total: number) => void, + namedImportMap?: NamedImportMap, ) => { const parser = await loadParser(); const logSkipped = isVerboseIngestionEnabled(); @@ -100,6 +115,10 @@ export const processCalls = async ( continue; } + // Build per-file TypeEnv for receiver resolution + const lang = getLanguageFromFilename(file.path); + const typeEnv = lang ? buildTypeEnv(tree, lang) : new Map(); + // 3. Process each call match matches.forEach(match => { const captureMap: Record = {}; @@ -116,18 +135,22 @@ export const processCalls = async ( // Skip common built-ins and noise if (isBuiltInOrNoise(calledName)) return; + const callNode = captureMap['call']; + const callForm = inferCallForm(callNode, nameNode); + const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined; + const receiverTypeName = receiverName ? lookupTypeEnv(typeEnv, receiverName, callNode) : undefined; + // 4. Resolve the target using priority strategy (returns confidence) - const resolved = resolveCallTarget( + const resolved = resolveCallTarget({ calledName, - file.path, - symbolTable, - importMap - ); + argCount: countCallArguments(callNode), + callForm, + receiverTypeName, + }, file.path, symbolTable, importMap, packageMap, namedImportMap); if (!resolved) return; // 5. Find the enclosing function (caller) - const callNode = captureMap['call']; const enclosingFuncId = findEnclosingFunction(callNode, file.path, symbolTable); // Use enclosing function as source, fallback to file for top-level calls @@ -163,49 +186,167 @@ export const processCalls = async ( interface ResolveResult { nodeId: string; confidence: number; // 0-1: how sure are we? - reason: string; // 'import-resolved' | 'same-file' | 'fuzzy-global' + reason: string; // 'import-resolved' | 'same-file' | 'unique-global' } +type ResolutionTier = 'same-file' | 'import-scoped' | 'unique-global'; + +interface TieredCandidates { + candidates: SymbolDefinition[]; + tier: ResolutionTier; +} + +const CALLABLE_SYMBOL_TYPES = new Set([ + 'Function', + 'Method', + 'Constructor', + 'Macro', + 'Delegate', +]); + +const collectTieredCandidates = ( + calledName: string, + currentFile: string, + symbolTable: SymbolTable, + importMap: ImportMap, + packageMap?: PackageMap, + namedImportMap?: NamedImportMap, +): TieredCandidates | null => { + const allDefs = symbolTable.lookupFuzzy(calledName); + + // Tier 1: Same-file — highest priority, prevents imports from shadowing local defs + // (matches resolveSymbolInternal which checks lookupExactFull before named bindings) + const localDefs = allDefs.filter(def => def.filePath === currentFile); + if (localDefs.length > 0) { + return { candidates: localDefs, tier: 'same-file' }; + } + + // Tier 2a-named: Check named bindings with re-export chain following. + // Aliased imports (import { User as U }) mean lookupFuzzy('U') returns + // empty but we can resolve via the exported name. + // Re-exports (export { User } from './base') are followed up to 5 hops. + if (namedImportMap) { + const chainResult = resolveNamedBindingChainForCandidates( + calledName, currentFile, symbolTable, namedImportMap, allDefs, + ); + if (chainResult) return chainResult; + } + + if (allDefs.length === 0) return null; + + const importedFiles = importMap.get(currentFile); + if (importedFiles) { + const importedDefs = allDefs.filter(def => importedFiles.has(def.filePath)); + if (importedDefs.length > 0) { + return { candidates: importedDefs, tier: 'import-scoped' }; + } + } + + const importedPackages = packageMap?.get(currentFile); + if (importedPackages) { + const packageDefs = allDefs.filter(def => { + for (const dirSuffix of importedPackages) { + if (isFileInPackageDir(def.filePath, dirSuffix)) return true; + } + return false; + }); + if (packageDefs.length > 0) { + return { candidates: packageDefs, tier: 'import-scoped' }; + } + } + + // Tier 3: Global — pass all candidates through; filterCallableCandidates + // will narrow by kind/arity and resolveCallTarget only emits when exactly 1 remains. + return { candidates: allDefs, tier: 'unique-global' }; +}; + +const CONSTRUCTOR_TARGET_TYPES = new Set(['Constructor', 'Class', 'Struct', 'Record']); + +const filterCallableCandidates = ( + candidates: SymbolDefinition[], + argCount?: number, + callForm?: 'free' | 'member' | 'constructor', +): SymbolDefinition[] => { + let kindFiltered: SymbolDefinition[]; + + if (callForm === 'constructor') { + // For constructor calls, prefer Constructor > Class/Struct/Record > callable fallback + const constructors = candidates.filter(c => c.type === 'Constructor'); + if (constructors.length > 0) { + kindFiltered = constructors; + } else { + const types = candidates.filter(c => CONSTRUCTOR_TARGET_TYPES.has(c.type)); + kindFiltered = types.length > 0 ? types : candidates.filter(c => CALLABLE_SYMBOL_TYPES.has(c.type)); + } + } else { + kindFiltered = candidates.filter(c => CALLABLE_SYMBOL_TYPES.has(c.type)); + } + + if (kindFiltered.length === 0) return []; + if (argCount === undefined) return kindFiltered; + + const hasParameterMetadata = kindFiltered.some(candidate => candidate.parameterCount !== undefined); + if (!hasParameterMetadata) return kindFiltered; + + return kindFiltered.filter(candidate => + candidate.parameterCount === undefined || candidate.parameterCount === argCount + ); +}; + +const toResolveResult = ( + definition: SymbolDefinition, + tier: ResolutionTier, +): ResolveResult => { + if (tier === 'same-file') { + return { nodeId: definition.nodeId, confidence: 0.95, reason: 'same-file' }; + } + if (tier === 'import-scoped') { + return { nodeId: definition.nodeId, confidence: 0.9, reason: 'import-resolved' }; + } + return { nodeId: definition.nodeId, confidence: 0.5, reason: 'unique-global' }; +}; + /** * Resolve a function call to its target node ID using priority strategy: - * A. Check imported files first (highest confidence) - * B. Check local file definitions - * C. Fuzzy global search (lowest confidence) - * - * Returns confidence score so agents know what to trust. + * A. Narrow candidates by scope tier (same-file, import-scoped, unique-global) + * B. Filter to callable symbol kinds (constructor-aware when callForm is set) + * C. Apply arity filtering when parameter metadata is available + * D. Apply receiver-type filtering for member calls with typed receivers + * + * If filtering still leaves multiple candidates, refuse to emit a CALLS edge. */ const resolveCallTarget = ( - calledName: string, + call: Pick, currentFile: string, symbolTable: SymbolTable, - importMap: ImportMap + importMap: ImportMap, + packageMap?: PackageMap, + namedImportMap?: NamedImportMap, ): ResolveResult | null => { - // Strategy B first (cheapest — single map lookup): Check local file - const localNodeId = symbolTable.lookupExact(currentFile, calledName); - if (localNodeId) { - return { nodeId: localNodeId, confidence: 0.85, reason: 'same-file' }; - } - - // Strategy A: Check if any definition of calledName is in an imported file - // Reversed: instead of iterating all imports and checking each, get all definitions - // and check if any is imported. O(definitions) instead of O(imports). - const allDefs = symbolTable.lookupFuzzy(calledName); - if (allDefs.length > 0) { - const importedFiles = importMap.get(currentFile); - if (importedFiles) { - for (const def of allDefs) { - if (importedFiles.has(def.filePath)) { - return { nodeId: def.nodeId, confidence: 0.9, reason: 'import-resolved' }; - } + const tiered = collectTieredCandidates(call.calledName, currentFile, symbolTable, importMap, packageMap, namedImportMap); + if (!tiered) return null; + + const filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, call.callForm); + + // D. Receiver-type filtering: for member calls with a known receiver type, + // filter candidates by ownerId matching the resolved type's nodeId + if (call.callForm === 'member' && call.receiverTypeName && filteredCandidates.length > 1) { + const typeDefs = symbolTable.lookupFuzzy(call.receiverTypeName); + if (typeDefs.length > 0) { + const typeNodeIds = new Set(typeDefs.map(d => d.nodeId)); + const ownerFiltered = filteredCandidates.filter(c => c.ownerId && typeNodeIds.has(c.ownerId)); + if (ownerFiltered.length === 1) { + return toResolveResult(ownerFiltered[0], tiered.tier); } + // If receiver filtering narrows to 0, fall through to name-only resolution + // If still 2+, refuse (don't guess) + if (ownerFiltered.length > 1) return null; } - - // Strategy C: Fuzzy global (no import match found) - const confidence = allDefs.length === 1 ? 0.5 : 0.3; - return { nodeId: allDefs[0].nodeId, confidence, reason: 'fuzzy-global' }; } - return null; + if (filteredCandidates.length !== 1) return null; + + return toResolveResult(filteredCandidates[0], tiered.tier); }; /** @@ -218,7 +359,9 @@ export const processCallsFromExtracted = async ( extractedCalls: ExtractedCall[], symbolTable: SymbolTable, importMap: ImportMap, - onProgress?: (current: number, total: number) => void + packageMap?: PackageMap, + onProgress?: (current: number, total: number) => void, + namedImportMap?: NamedImportMap, ) => { // Group by file for progress reporting const byFile = new Map(); @@ -243,10 +386,12 @@ export const processCallsFromExtracted = async ( for (const call of calls) { const resolved = resolveCallTarget( - call.calledName, + call, call.filePath, symbolTable, - importMap + importMap, + packageMap, + namedImportMap, ); if (!resolved) continue; @@ -273,6 +418,7 @@ export const processRoutesFromExtracted = async ( extractedRoutes: ExtractedRoute[], symbolTable: SymbolTable, importMap: ImportMap, + packageMap?: PackageMap, onProgress?: (current: number, total: number) => void ) => { for (let i = 0; i < extractedRoutes.length; i++) { @@ -284,24 +430,16 @@ export const processRoutesFromExtracted = async ( if (!route.controllerName || !route.methodName) continue; - // Resolve controller class in symbol table - const controllerDefs = symbolTable.lookupFuzzy(route.controllerName); - if (controllerDefs.length === 0) continue; - - // Prefer import-resolved match - const importedFiles = importMap.get(route.filePath); - let controllerDef = controllerDefs[0]; - let confidence = controllerDefs.length === 1 ? 0.7 : 0.5; - - if (importedFiles) { - for (const def of controllerDefs) { - if (importedFiles.has(def.filePath)) { - controllerDef = def; - confidence = 0.9; - break; - } - } - } + // Resolve controller class using shared resolver (Tier 1: same file, + // Tier 2: import-scoped, Tier 3: unique global). + const resolution = resolveSymbolInternal(route.controllerName, route.filePath, symbolTable, importMap, packageMap); + if (!resolution) continue; + + const controllerDef = resolution.definition; + // Derive confidence from the resolution tier + const confidence = resolution.tier === 'same-file' ? 0.95 + : resolution.tier === 'import-scoped' ? 0.9 + : 0.7; // Find the method on the controller const methodId = symbolTable.lookupExact(controllerDef.filePath, route.methodName); @@ -335,3 +473,22 @@ export const processRoutesFromExtracted = async ( onProgress?.(extractedRoutes.length, extractedRoutes.length); }; + +/** + * Follow re-export chains through NamedImportMap for call candidate collection. + * Delegates chain-walking to the shared walkBindingChain utility, then + * applies call-processor semantics: any number of matches accepted. + */ +const resolveNamedBindingChainForCandidates = ( + calledName: string, + currentFile: string, + symbolTable: SymbolTable, + namedImportMap: NamedImportMap, + allDefs: SymbolDefinition[], +): TieredCandidates | null => { + const defs = walkBindingChain(calledName, currentFile, symbolTable, namedImportMap, allDefs); + if (defs && defs.length > 0) { + return { candidates: defs, tier: 'import-scoped' }; + } + return null; +}; diff --git a/gitnexus/src/core/ingestion/entry-point-scoring.ts b/gitnexus/src/core/ingestion/entry-point-scoring.ts index 39dc8e40aa..b05be85e45 100644 --- a/gitnexus/src/core/ingestion/entry-point-scoring.ts +++ b/gitnexus/src/core/ingestion/entry-point-scoring.ts @@ -11,6 +11,7 @@ */ import { detectFrameworkFromPath } from './framework-detection.js'; +import { SupportedLanguages } from '../../config/supported-languages.js'; // ============================================================================ // NAME PATTERNS - All 9 supported languages @@ -38,31 +39,31 @@ const ENTRY_POINT_PATTERNS: Record = { ], // JavaScript/TypeScript - 'javascript': [ + [SupportedLanguages.JavaScript]: [ /^use[A-Z]/, // React hooks (useEffect, etc.) ], - 'typescript': [ + [SupportedLanguages.TypeScript]: [ /^use[A-Z]/, // React hooks ], - + // Python - 'python': [ + [SupportedLanguages.Python]: [ /^app$/, // Flask/FastAPI app /^(get|post|put|delete|patch)_/i, // REST conventions /^api_/, // API functions /^view_/, // Django views ], - + // Java - 'java': [ + [SupportedLanguages.Java]: [ /^do[A-Z]/, // doGet, doPost (Servlets) /^create[A-Z]/, // Factory patterns /^build[A-Z]/, // Builder patterns /Service$/, // UserService ], - + // C# - 'csharp': [ + [SupportedLanguages.CSharp]: [ /^(Get|Post|Put|Delete|Patch)/, // ASP.NET action methods /Action$/, // MVC actions /^On[A-Z]/, // Event handlers / Blazor lifecycle @@ -78,7 +79,7 @@ const ENTRY_POINT_PATTERNS: Record = { ], // Go - 'go': [ + [SupportedLanguages.Go]: [ /Handler$/, // http.Handler pattern /^Serve/, // ServeHTTP /^New[A-Z]/, // Constructor pattern (returns new instance) @@ -86,7 +87,7 @@ const ENTRY_POINT_PATTERNS: Record = { ], // Rust - 'rust': [ + [SupportedLanguages.Rust]: [ /^(get|post|put|delete)_handler$/i, /^handle_/, // handle_request /^new$/, // Constructor pattern @@ -95,7 +96,7 @@ const ENTRY_POINT_PATTERNS: Record = { ], // C - explicit main() boost plus common C entry point conventions - 'c': [ + [SupportedLanguages.C]: [ /^main$/, // THE entry point /^init_/, // init_server, init_client /_init$/, // module_init, server_init @@ -129,7 +130,7 @@ const ENTRY_POINT_PATTERNS: Record = { ], // C++ - same as C plus OOP/template patterns - 'cpp': [ + [SupportedLanguages.CPlusPlus]: [ /^main$/, // THE entry point /^init_/, /_init$/, @@ -151,7 +152,7 @@ const ENTRY_POINT_PATTERNS: Record = { ], // Swift / iOS - 'swift': [ + [SupportedLanguages.Swift]: [ /^viewDidLoad$/, // UIKit lifecycle /^viewWillAppear$/, // UIKit lifecycle /^viewDidAppear$/, // UIKit lifecycle @@ -171,7 +172,7 @@ const ENTRY_POINT_PATTERNS: Record = { ], // PHP / Laravel - 'php': [ + [SupportedLanguages.PHP]: [ /Controller$/, // UserController (class name convention) /^handle$/, // Job::handle(), Listener::handle() /^execute$/, // Command::execute() @@ -254,7 +255,7 @@ export interface EntryPointScoreResult { */ export function calculateEntryPointScore( name: string, - language: string, + language: SupportedLanguages, isExported: boolean, callerCount: number, calleeCount: number, diff --git a/gitnexus/src/core/ingestion/export-detection.ts b/gitnexus/src/core/ingestion/export-detection.ts index 21729ddb58..822b297d26 100644 --- a/gitnexus/src/core/ingestion/export-detection.ts +++ b/gitnexus/src/core/ingestion/export-detection.ts @@ -7,7 +7,60 @@ * Shared between parse-worker.ts (worker pool) and parsing-processor.ts (sequential fallback). */ -import { findSiblingChild } from './utils.js'; +import { findSiblingChild, SyntaxNode } from './utils.js'; +import { SupportedLanguages } from '../../config/supported-languages.js'; + +/** Handler type: given a node and symbol name, return true if the symbol is exported/public. */ +type ExportChecker = (node: SyntaxNode, name: string) => boolean; + +// ============================================================================ +// Per-language export checkers +// ============================================================================ + +/** JS/TS: walk ancestors looking for export_statement or export_specifier. */ +const tsExportChecker: ExportChecker = (node, _name) => { + let current: SyntaxNode | null = node; + while (current) { + const type = current.type; + if (type === 'export_statement' || + type === 'export_specifier' || + (type === 'lexical_declaration' && current.parent?.type === 'export_statement')) { + return true; + } + // Fallback: check if node text starts with 'export ' for edge cases + if (current.text?.startsWith('export ')) { + return true; + } + current = current.parent; + } + return false; +}; + +/** Python: public if no leading underscore (convention). */ +const pythonExportChecker: ExportChecker = (_node, name) => !name.startsWith('_'); + +/** Java: check for 'public' modifier — modifiers are siblings of the name node, not parents. */ +const javaExportChecker: ExportChecker = (node, _name) => { + let current: SyntaxNode | null = node; + while (current) { + if (current.parent) { + const parent = current.parent; + for (let i = 0; i < parent.childCount; i++) { + const child = parent.child(i); + if (child?.type === 'modifiers' && child.text?.includes('public')) { + return true; + } + } + if (parent.type === 'method_declaration' || parent.type === 'constructor_declaration') { + if (parent.text?.trimStart().startsWith('public')) { + return true; + } + } + } + current = current.parent; + } + return false; +}; /** C# declaration node types for sibling modifier scanning. */ const CSHARP_DECL_TYPES = new Set([ @@ -19,6 +72,32 @@ const CSHARP_DECL_TYPES = new Set([ 'namespace_declaration', 'file_scoped_namespace_declaration', ]); +/** + * C#: modifier nodes are SIBLINGS of the name node inside the declaration. + * Walk up to the declaration node, then scan its direct children. + */ +const csharpExportChecker: ExportChecker = (node, _name) => { + let current: SyntaxNode | null = node; + while (current) { + if (CSHARP_DECL_TYPES.has(current.type)) { + for (let i = 0; i < current.childCount; i++) { + const child = current.child(i); + if (child?.type === 'modifier' && child.text === 'public') return true; + } + return false; + } + current = current.parent; + } + return false; +}; + +/** Go: uppercase first letter = exported. */ +const goExportChecker: ExportChecker = (_node, name) => { + if (name.length === 0) return false; + const first = name[0]; + return first === first.toUpperCase() && first !== first.toLowerCase(); +}; + /** Rust declaration node types for sibling visibility_modifier scanning. */ const RUST_DECL_TYPES = new Set([ 'function_item', 'struct_item', 'enum_item', 'trait_item', 'impl_item', @@ -27,172 +106,137 @@ const RUST_DECL_TYPES = new Set([ ]); /** - * Check if a tree-sitter node is exported/public in its language. - * @param node - The tree-sitter AST node - * @param name - The symbol name - * @param language - The programming language - * @returns true if the symbol is exported/public + * Rust: visibility_modifier is a SIBLING of the name node within the declaration node + * (function_item, struct_item, etc.), not a parent. Walk up to the declaration node, + * then scan its direct children. */ -export const isNodeExported = (node: any, name: string, language: string): boolean => { - let current = node; - - switch (language) { - // JavaScript/TypeScript: Check for export keyword in ancestors - case 'javascript': - case 'typescript': - while (current) { - const type = current.type; - if (type === 'export_statement' || - type === 'export_specifier' || - (type === 'lexical_declaration' && current.parent?.type === 'export_statement')) { - return true; - } - // Fallback: check if node text starts with 'export ' for edge cases - if (current.text?.startsWith('export ')) { - return true; - } - current = current.parent; - } - return false; - - // Python: Public if no leading underscore (convention) - case 'python': - return !name.startsWith('_'); - - // Java: Check for 'public' modifier - // In tree-sitter Java, modifiers are siblings of the name node, not parents - case 'java': - while (current) { - if (current.parent) { - const parent = current.parent; - for (let i = 0; i < parent.childCount; i++) { - const child = parent.child(i); - if (child?.type === 'modifiers' && child.text?.includes('public')) { - return true; - } - } - if (parent.type === 'method_declaration' || parent.type === 'constructor_declaration') { - if (parent.text?.trimStart().startsWith('public')) { - return true; - } - } - } - current = current.parent; +const rustExportChecker: ExportChecker = (node, _name) => { + let current: SyntaxNode | null = node; + while (current) { + if (RUST_DECL_TYPES.has(current.type)) { + for (let i = 0; i < current.childCount; i++) { + const child = current.child(i); + if (child?.type === 'visibility_modifier' && child.text?.startsWith('pub')) return true; } return false; + } + current = current.parent; + } + return false; +}; - // C#: modifier nodes are SIBLINGS of the name node inside the declaration. - // Walk up to the declaration node, then scan its direct children. - case 'csharp': { - while (current) { - if (CSHARP_DECL_TYPES.has(current.type)) { - for (let i = 0; i < current.childCount; i++) { - const child = current.child(i); - if (child?.type === 'modifier' && child.text === 'public') return true; - } - return false; - } - current = current.parent; +/** + * Kotlin: default visibility is public (unlike Java). + * visibility_modifier is inside modifiers, a sibling of the name node within the declaration. + */ +const kotlinExportChecker: ExportChecker = (node, _name) => { + let current: SyntaxNode | null = node; + while (current) { + if (current.parent) { + const visMod = findSiblingChild(current.parent, 'modifiers', 'visibility_modifier'); + if (visMod) { + const text = visMod.text; + if (text === 'private' || text === 'internal' || text === 'protected') return false; + if (text === 'public') return true; } - return false; } + current = current.parent; + } + // No visibility modifier = public (Kotlin default) + return true; +}; - // Go: Uppercase first letter = exported - case 'go': - if (name.length === 0) return false; - const first = name[0]; - return first === first.toUpperCase() && first !== first.toLowerCase(); - - // Rust: visibility_modifier is a SIBLING of the name node within the - // declaration node (function_item, struct_item, etc.), not a parent. - // Walk up to the declaration node, then scan its direct children. - case 'rust': { - while (current) { - if (RUST_DECL_TYPES.has(current.type)) { - for (let i = 0; i < current.childCount; i++) { - const child = current.child(i); - if (child?.type === 'visibility_modifier' && child.text?.startsWith('pub')) return true; - } - return false; - } - current = current.parent; +/** + * C/C++: functions without 'static' storage class have external linkage by default, + * making them globally accessible (equivalent to exported). Only functions explicitly + * marked 'static' are file-scoped (not exported). C++ anonymous namespaces + * (namespace { ... }) also give internal linkage. + */ +const cCppExportChecker: ExportChecker = (node, _name) => { + let cur: SyntaxNode | null = node; + while (cur) { + if (cur.type === 'function_definition' || cur.type === 'declaration') { + // Check for 'static' storage class specifier as a direct child node. + // This avoids reading the full function text (which can be very large). + for (let i = 0; i < cur.childCount; i++) { + const child = cur.child(i); + if (child?.type === 'storage_class_specifier' && child.text === 'static') return false; } - return false; } + // C++ anonymous namespace: namespace_definition with no name child = internal linkage + if (cur.type === 'namespace_definition') { + const hasName = cur.childForFieldName?.('name'); + if (!hasName) return false; + } + cur = cur.parent; + } + return true; // Top-level C/C++ functions default to external linkage +}; - // Kotlin: Default visibility is public (unlike Java) - // visibility_modifier is inside modifiers, a sibling of the name node within the declaration - case 'kotlin': - while (current) { - if (current.parent) { - const visMod = findSiblingChild(current.parent, 'modifiers', 'visibility_modifier'); - if (visMod) { - const text = visMod.text; - if (text === 'private' || text === 'internal' || text === 'protected') return false; - if (text === 'public') return true; - } - } - current = current.parent; - } - // No visibility modifier = public (Kotlin default) +/** PHP: check for visibility modifier or top-level scope. */ +const phpExportChecker: ExportChecker = (node, _name) => { + let current: SyntaxNode | null = node; + while (current) { + if (current.type === 'class_declaration' || + current.type === 'interface_declaration' || + current.type === 'trait_declaration' || + current.type === 'enum_declaration') { return true; + } + if (current.type === 'visibility_modifier') { + return current.text === 'public'; + } + current = current.parent; + } + // Top-level functions are globally accessible + return true; +}; - // C/C++: Functions without 'static' storage class have external linkage - // by default, making them globally accessible (equivalent to exported). - // Only functions explicitly marked 'static' are file-scoped (not exported). - // C++ anonymous namespaces (namespace { ... }) also give internal linkage. - case 'c': - case 'cpp': { - // Walk up to the function_definition/declaration and check for 'static' - let cur = node; - while (cur) { - if (cur.type === 'function_definition' || cur.type === 'declaration') { - // Check for 'static' storage class specifier as a direct child node. - // This avoids reading the full function text (which can be very large). - for (let i = 0; i < cur.childCount; i++) { - const child = cur.child(i); - if (child?.type === 'storage_class_specifier' && child.text === 'static') return false; - } - } - // C++ anonymous namespace: namespace_definition with no name child = internal linkage - if (cur.type === 'namespace_definition') { - const hasName = cur.childForFieldName?.('name'); - if (!hasName) return false; - } - cur = cur.parent; - } - return true; // Top-level C/C++ functions default to external linkage +/** Swift: check for 'public' or 'open' access modifiers. */ +const swiftExportChecker: ExportChecker = (node, _name) => { + let current: SyntaxNode | null = node; + while (current) { + if (current.type === 'modifiers' || current.type === 'visibility_modifier') { + const text = current.text || ''; + if (text.includes('public') || text.includes('open')) return true; } + current = current.parent; + } + return false; +}; - // PHP: Check for visibility modifier or top-level scope - case 'php': - while (current) { - if (current.type === 'class_declaration' || - current.type === 'interface_declaration' || - current.type === 'trait_declaration' || - current.type === 'enum_declaration') { - return true; - } - if (current.type === 'visibility_modifier') { - return current.text === 'public'; - } - current = current.parent; - } - // Top-level functions are globally accessible - return true; +// ============================================================================ +// Exhaustive dispatch table — satisfies enforces all SupportedLanguages are covered +// ============================================================================ - // Swift: Check for 'public' or 'open' access modifiers - case 'swift': - while (current) { - if (current.type === 'modifiers' || current.type === 'visibility_modifier') { - const text = current.text || ''; - if (text.includes('public') || text.includes('open')) return true; - } - current = current.parent; - } - return false; +const exportCheckers = { + [SupportedLanguages.JavaScript]: tsExportChecker, + [SupportedLanguages.TypeScript]: tsExportChecker, + [SupportedLanguages.Python]: pythonExportChecker, + [SupportedLanguages.Java]: javaExportChecker, + [SupportedLanguages.CSharp]: csharpExportChecker, + [SupportedLanguages.Go]: goExportChecker, + [SupportedLanguages.Rust]: rustExportChecker, + [SupportedLanguages.Kotlin]: kotlinExportChecker, + [SupportedLanguages.C]: cCppExportChecker, + [SupportedLanguages.CPlusPlus]: cCppExportChecker, + [SupportedLanguages.PHP]: phpExportChecker, + [SupportedLanguages.Swift]: swiftExportChecker, +} satisfies Record; - default: - return false; - } +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Check if a tree-sitter node is exported/public in its language. + * @param node - The tree-sitter AST node + * @param name - The symbol name + * @param language - The programming language + * @returns true if the symbol is exported/public + */ +export const isNodeExported = (node: SyntaxNode, name: string, language: SupportedLanguages): boolean => { + const checker = exportCheckers[language]; + if (!checker) return false; + return checker(node, name); }; diff --git a/gitnexus/src/core/ingestion/framework-detection.ts b/gitnexus/src/core/ingestion/framework-detection.ts index 8729c5c894..eb392a7622 100644 --- a/gitnexus/src/core/ingestion/framework-detection.ts +++ b/gitnexus/src/core/ingestion/framework-detection.ts @@ -437,6 +437,8 @@ export const FRAMEWORK_AST_PATTERNS = { 'combine': ['sink', 'assign', 'Publisher', 'Subscriber'], }; +import { SupportedLanguages } from '../../config/supported-languages.js'; + interface AstFrameworkPatternConfig { framework: string; entryPointMultiplier: number; @@ -445,33 +447,33 @@ interface AstFrameworkPatternConfig { } const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record = { - javascript: [ + [SupportedLanguages.JavaScript]: [ { framework: 'nestjs', entryPointMultiplier: 3.2, reason: 'nestjs-decorator', patterns: FRAMEWORK_AST_PATTERNS.nestjs }, ], - typescript: [ + [SupportedLanguages.TypeScript]: [ { framework: 'nestjs', entryPointMultiplier: 3.2, reason: 'nestjs-decorator', patterns: FRAMEWORK_AST_PATTERNS.nestjs }, ], - python: [ + [SupportedLanguages.Python]: [ { framework: 'fastapi', entryPointMultiplier: 3.0, reason: 'fastapi-decorator', patterns: FRAMEWORK_AST_PATTERNS.fastapi }, { framework: 'flask', entryPointMultiplier: 2.8, reason: 'flask-decorator', patterns: FRAMEWORK_AST_PATTERNS.flask }, ], - java: [ + [SupportedLanguages.Java]: [ { framework: 'spring', entryPointMultiplier: 3.2, reason: 'spring-annotation', patterns: FRAMEWORK_AST_PATTERNS.spring }, { framework: 'jaxrs', entryPointMultiplier: 3.0, reason: 'jaxrs-annotation', patterns: FRAMEWORK_AST_PATTERNS.jaxrs }, ], - kotlin: [ + [SupportedLanguages.Kotlin]: [ { framework: 'spring-kotlin', entryPointMultiplier: 3.2, reason: 'spring-kotlin-annotation', patterns: FRAMEWORK_AST_PATTERNS.spring }, { framework: 'jaxrs', entryPointMultiplier: 3.0, reason: 'jaxrs-annotation', patterns: FRAMEWORK_AST_PATTERNS.jaxrs }, { framework: 'ktor', entryPointMultiplier: 2.8, reason: 'ktor-routing', patterns: ['routing', 'embeddedServer', 'Application.module'] }, { framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-annotation', patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('] }, ], - csharp: [ + [SupportedLanguages.CSharp]: [ { framework: 'aspnet', entryPointMultiplier: 3.2, reason: 'aspnet-attribute', patterns: FRAMEWORK_AST_PATTERNS.aspnet }, { framework: 'signalr', entryPointMultiplier: 2.8, reason: 'signalr-attribute', patterns: FRAMEWORK_AST_PATTERNS.signalr }, { framework: 'blazor', entryPointMultiplier: 2.5, reason: 'blazor-attribute', patterns: FRAMEWORK_AST_PATTERNS.blazor }, { framework: 'efcore', entryPointMultiplier: 2.0, reason: 'efcore-pattern', patterns: FRAMEWORK_AST_PATTERNS.efcore }, ], - php: [ + [SupportedLanguages.PHP]: [ { framework: 'laravel', entryPointMultiplier: 3.0, reason: 'php-route-attribute', patterns: FRAMEWORK_AST_PATTERNS.laravel }, ], }; @@ -491,7 +493,7 @@ const AST_PATTERNS_LOWERED: Record { + const resolved = resolveSymbol(parentName, currentFilePath, symbolTable, importMap, packageMap); + if (resolved) { + const isInterface = resolved.type === 'Interface'; + return isInterface + ? { type: 'IMPLEMENTS', idPrefix: 'Interface' } + : { type: 'EXTENDS', idPrefix: 'Class' }; + } + // Unresolved symbol — fall back to language-specific heuristic + if (language === SupportedLanguages.CSharp || language === SupportedLanguages.Java) { + if (INTERFACE_NAME_RE.test(parentName)) { + return { type: 'IMPLEMENTS', idPrefix: 'Interface' }; + } + } else if (language === SupportedLanguages.Swift) { + // Protocol conformance is far more common than class inheritance in Swift + return { type: 'IMPLEMENTS', idPrefix: 'Interface' }; + } + return { type: 'EXTENDS', idPrefix: 'Class' }; +}; export const processHeritage = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], astCache: ASTCache, symbolTable: SymbolTable, + importMap: ImportMap, + packageMap?: PackageMap, onProgress?: (current: number, total: number) => void ) => { const parser = await loadParser(); @@ -84,27 +135,34 @@ export const processHeritage = async ( captureMap[c.name] = c.node; }); - // EXTENDS: Class extends another Class + // EXTENDS or IMPLEMENTS: resolve via symbol table for languages where + // the tree-sitter query can't distinguish classes from interfaces (C#, Java) if (captureMap['heritage.class'] && captureMap['heritage.extends']) { + // Go struct embedding: skip named fields (only anonymous fields are embedded) + const extendsNode = captureMap['heritage.extends']; + const fieldDecl = extendsNode.parent; + if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name')) { + return; // Named field, not struct embedding + } + const className = captureMap['heritage.class'].text; const parentClassName = captureMap['heritage.extends'].text; - // Resolve both class IDs + const { type: relType, idPrefix } = resolveExtendsType(parentClassName, file.path, symbolTable, importMap, language, packageMap); + const childId = symbolTable.lookupExact(file.path, className) || - symbolTable.lookupFuzzy(className)[0]?.nodeId || + resolveSymbol(className, file.path, symbolTable, importMap, packageMap)?.nodeId || generateId('Class', `${file.path}:${className}`); - - const parentId = symbolTable.lookupFuzzy(parentClassName)[0]?.nodeId || - generateId('Class', `${parentClassName}`); + + const parentId = resolveSymbol(parentClassName, file.path, symbolTable, importMap, packageMap)?.nodeId || + generateId(idPrefix, `${parentClassName}`); if (childId && parentId && childId !== parentId) { - const relId = generateId('EXTENDS', `${childId}->${parentId}`); - graph.addRelationship({ - id: relId, + id: generateId(relType, `${childId}->${parentId}`), sourceId: childId, targetId: parentId, - type: 'EXTENDS', + type: relType, confidence: 1.0, reason: '', }); @@ -118,10 +176,10 @@ export const processHeritage = async ( // Resolve class and interface IDs const classId = symbolTable.lookupExact(file.path, className) || - symbolTable.lookupFuzzy(className)[0]?.nodeId || + resolveSymbol(className, file.path, symbolTable, importMap, packageMap)?.nodeId || generateId('Class', `${file.path}:${className}`); - - const interfaceId = symbolTable.lookupFuzzy(interfaceName)[0]?.nodeId || + + const interfaceId = resolveSymbol(interfaceName, file.path, symbolTable, importMap, packageMap)?.nodeId || generateId('Interface', `${interfaceName}`); if (classId && interfaceId) { @@ -145,10 +203,10 @@ export const processHeritage = async ( // Resolve struct and trait IDs const structId = symbolTable.lookupExact(file.path, structName) || - symbolTable.lookupFuzzy(structName)[0]?.nodeId || + resolveSymbol(structName, file.path, symbolTable, importMap, packageMap)?.nodeId || generateId('Struct', `${file.path}:${structName}`); - - const traitId = symbolTable.lookupFuzzy(traitName)[0]?.nodeId || + + const traitId = resolveSymbol(traitName, file.path, symbolTable, importMap, packageMap)?.nodeId || generateId('Trait', `${traitName}`); if (structId && traitId) { @@ -186,6 +244,8 @@ export const processHeritageFromExtracted = async ( graph: KnowledgeGraph, extractedHeritage: ExtractedHeritage[], symbolTable: SymbolTable, + importMap: ImportMap, + packageMap?: PackageMap, onProgress?: (current: number, total: number) => void ) => { const total = extractedHeritage.length; @@ -199,29 +259,33 @@ export const processHeritageFromExtracted = async ( const h = extractedHeritage[i]; if (h.kind === 'extends') { + const fileLanguage = getLanguageFromFilename(h.filePath); + if (!fileLanguage) continue; + const { type: relType, idPrefix } = resolveExtendsType(h.parentName, h.filePath, symbolTable, importMap, fileLanguage, packageMap); + const childId = symbolTable.lookupExact(h.filePath, h.className) || - symbolTable.lookupFuzzy(h.className)[0]?.nodeId || + resolveSymbol(h.className, h.filePath, symbolTable, importMap, packageMap)?.nodeId || generateId('Class', `${h.filePath}:${h.className}`); - const parentId = symbolTable.lookupFuzzy(h.parentName)[0]?.nodeId || - generateId('Class', `${h.parentName}`); + const parentId = resolveSymbol(h.parentName, h.filePath, symbolTable, importMap, packageMap)?.nodeId || + generateId(idPrefix, `${h.parentName}`); if (childId && parentId && childId !== parentId) { graph.addRelationship({ - id: generateId('EXTENDS', `${childId}->${parentId}`), + id: generateId(relType, `${childId}->${parentId}`), sourceId: childId, targetId: parentId, - type: 'EXTENDS', + type: relType, confidence: 1.0, reason: '', }); } } else if (h.kind === 'implements') { const classId = symbolTable.lookupExact(h.filePath, h.className) || - symbolTable.lookupFuzzy(h.className)[0]?.nodeId || + resolveSymbol(h.className, h.filePath, symbolTable, importMap, packageMap)?.nodeId || generateId('Class', `${h.filePath}:${h.className}`); - const interfaceId = symbolTable.lookupFuzzy(h.parentName)[0]?.nodeId || + const interfaceId = resolveSymbol(h.parentName, h.filePath, symbolTable, importMap, packageMap)?.nodeId || generateId('Interface', `${h.parentName}`); if (classId && interfaceId) { @@ -236,10 +300,10 @@ export const processHeritageFromExtracted = async ( } } else if (h.kind === 'trait-impl') { const structId = symbolTable.lookupExact(h.filePath, h.className) || - symbolTable.lookupFuzzy(h.className)[0]?.nodeId || + resolveSymbol(h.className, h.filePath, symbolTable, importMap, packageMap)?.nodeId || generateId('Struct', `${h.filePath}:${h.className}`); - const traitId = symbolTable.lookupFuzzy(h.parentName)[0]?.nodeId || + const traitId = resolveSymbol(h.parentName, h.filePath, symbolTable, importMap, packageMap)?.nodeId || generateId('Trait', `${h.parentName}`); if (structId && traitId) { diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index fc1914c65c..7533d8f4e4 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -1,5 +1,3 @@ -import fs from 'fs/promises'; -import path from 'path'; import { KnowledgeGraph } from '../graph/types.js'; import { ASTCache } from './ast-cache.js'; import Parser from 'tree-sitter'; @@ -8,8 +6,47 @@ import { LANGUAGE_QUERIES } from './tree-sitter-queries.js'; import { generateId } from '../../lib/utils.js'; import { getLanguageFromFilename, isVerboseIngestionEnabled, yieldToEventLoop } from './utils.js'; import { SupportedLanguages } from '../../config/supported-languages.js'; +import { extractNamedBindings } from './named-binding-extraction.js'; import type { ExtractedImport } from './workers/parse-worker.js'; import { getTreeSitterBufferSize } from './constants.js'; +import { + loadTsconfigPaths, + loadGoModulePath, + loadComposerConfig, + loadCSharpProjectConfig, + loadSwiftPackageConfig, + type SwiftPackageConfig, +} from './language-config.js'; +import { + buildSuffixIndex, + resolveImportPath, + appendKotlinWildcard, + KOTLIN_EXTENSIONS, + resolveJvmWildcard, + resolveJvmMemberImport, + resolveGoPackageDir, + resolveGoPackage, + resolveCSharpImport, + resolveCSharpNamespaceDir, + resolvePhpImport, + resolveRustImport, +} from './resolvers/index.js'; +import type { + SuffixIndex, + TsconfigPaths, + GoModuleConfig, + CSharpProjectConfig, + ComposerConfig +} from './resolvers/index.js'; + +// Re-export resolver types for consumers +export type { + SuffixIndex, + TsconfigPaths, + GoModuleConfig, + CSharpProjectConfig, + ComposerConfig +} from './resolvers/index.js'; const isDev = process.env.NODE_ENV === 'development'; @@ -19,6 +56,36 @@ export type ImportMap = Map>; export const createImportMap = (): ImportMap => new Map(); +// Type: Map> +// Stores Go package directory suffixes imported by a file (e.g., "/internal/auth/"). +// Avoids expanding every Go package import into N individual ImportMap edges. +export type PackageMap = Map>; + +export const createPackageMap = (): PackageMap => new Map(); + +// Type: Map> +// Tracks which specific names a file imports from which sources (TS/Python only). +// Used to tighten Tier 2a resolution: `import { User } from './models'` +// means only `User` (not `Repo`) is visible from models.ts via this import. +// Stores both the resolved source path and the original exported name so that +// aliased imports (`import { User as U }`) can resolve U → User in the source file. +export interface NamedImportBinding { sourcePath: string; exportedName: string } +export type NamedImportMap = Map>; + +export const createNamedImportMap = (): NamedImportMap => new Map(); + +/** + * Check if a file path is directly inside a package directory identified by its suffix. + * Used by the symbol resolver for Go and C# directory-level import matching. + */ +export function isFileInPackageDir(filePath: string, dirSuffix: string): boolean { + // Prepend '/' so paths like "internal/auth/service.go" match suffix "/internal/auth/" + const normalized = '/' + filePath.replace(/\\/g, '/'); + if (!normalized.includes(dirSuffix)) return false; + const afterDir = normalized.substring(normalized.indexOf(dirSuffix) + dirSuffix.length); + return !afterDir.includes('/'); +} + /** Pre-built lookup structures for import resolution. Build once, reuse across chunks. */ export interface ImportResolutionContext { allFilePaths: Set; @@ -28,10 +95,6 @@ export interface ImportResolutionContext { resolveCache: Map; } -/** Max entries in the resolve cache. Beyond this, the cache is cleared to bound memory. - * 100K entries ≈ 15MB — covers the most common import patterns. */ -const RESOLVE_CACHE_CAP = 100_000; - export function buildImportResolutionContext(allPaths: string[]): ImportResolutionContext { const allFileList = allPaths; const normalizedFileList = allFileList.map(p => p.replace(/\\/g, '/')); @@ -40,827 +103,194 @@ export function buildImportResolutionContext(allPaths: string[]): ImportResoluti return { allFilePaths, allFileList, normalizedFileList, suffixIndex, resolveCache: new Map() }; } +// Config loaders extracted to ./language-config.ts (Phase 2 refactor) +// Resolver functions are in ./resolvers/ — imported above + // ============================================================================ -// LANGUAGE-SPECIFIC CONFIG +// SHARED LANGUAGE DISPATCH // ============================================================================ -/** TypeScript path alias config parsed from tsconfig.json */ -interface TsconfigPaths { - /** Map of alias prefix -> target prefix (e.g., "@/" -> "src/") */ - aliases: Map; - /** Base URL for path resolution (relative to repo root) */ - baseUrl: string; -} - -/** Go module config parsed from go.mod */ -interface GoModuleConfig { - /** Module path (e.g., "github.com/user/repo") */ - modulePath: string; +/** Bundled language-specific configs loaded once per ingestion run. */ +interface LanguageConfigs { + tsconfigPaths: TsconfigPaths | null; + goModule: GoModuleConfig | null; + composerConfig: ComposerConfig | null; + swiftPackageConfig: SwiftPackageConfig | null; + csharpConfigs: CSharpProjectConfig[]; } -/** - * Parse tsconfig.json to extract path aliases. - * Tries tsconfig.json, tsconfig.app.json, tsconfig.base.json in order. - */ -async function loadTsconfigPaths(repoRoot: string): Promise { - const candidates = ['tsconfig.json', 'tsconfig.app.json', 'tsconfig.base.json']; - - for (const filename of candidates) { - try { - const tsconfigPath = path.join(repoRoot, filename); - const raw = await fs.readFile(tsconfigPath, 'utf-8'); - // Strip JSON comments (// and /* */ style) for robustness - const stripped = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); - const tsconfig = JSON.parse(stripped); - const compilerOptions = tsconfig.compilerOptions; - if (!compilerOptions?.paths) continue; - - const baseUrl = compilerOptions.baseUrl || '.'; - const aliases = new Map(); - - for (const [pattern, targets] of Object.entries(compilerOptions.paths)) { - if (!Array.isArray(targets) || targets.length === 0) continue; - const target = targets[0] as string; - - // Convert glob patterns: "@/*" -> "@/", "src/*" -> "src/" - const aliasPrefix = pattern.endsWith('/*') ? pattern.slice(0, -1) : pattern; - const targetPrefix = target.endsWith('/*') ? target.slice(0, -1) : target; - - aliases.set(aliasPrefix, targetPrefix); - } - - if (aliases.size > 0) { - if (isDev) { - console.log(`📦 Loaded ${aliases.size} path aliases from ${filename}`); - } - return { aliases, baseUrl }; - } - } catch { - // File doesn't exist or isn't valid JSON - try next - } - } - - return null; +/** Context for import path resolution (file lists, indexes, cache). */ +interface ResolveCtx { + allFilePaths: Set; + allFileList: string[]; + normalizedFileList: string[]; + index: SuffixIndex; + resolveCache: Map; } /** - * Parse go.mod to extract module path. + * Result of resolving an import via language-specific dispatch. + * - 'files': resolved to one or more files → add to ImportMap + * - 'package': resolved to a directory → add graph edges + store dirSuffix in PackageMap + * - null: no resolution (external dependency, etc.) */ -async function loadGoModulePath(repoRoot: string): Promise { - try { - const goModPath = path.join(repoRoot, 'go.mod'); - const content = await fs.readFile(goModPath, 'utf-8'); - const match = content.match(/^module\s+(\S+)/m); - if (match) { - if (isDev) { - console.log(`📦 Loaded Go module path: ${match[1]}`); - } - return { modulePath: match[1] }; - } - } catch { - // No go.mod - } - return null; -} - -/** PHP Composer PSR-4 autoload config */ -interface ComposerConfig { - /** Map of namespace prefix -> directory (e.g., "App\\" -> "app/") */ - psr4: Map; -} - -async function loadComposerConfig(repoRoot: string): Promise { - try { - const composerPath = path.join(repoRoot, 'composer.json'); - const raw = await fs.readFile(composerPath, 'utf-8'); - const composer = JSON.parse(raw); - const psr4Raw = composer.autoload?.['psr-4'] ?? {}; - const psr4Dev = composer['autoload-dev']?.['psr-4'] ?? {}; - const merged = { ...psr4Raw, ...psr4Dev }; - - const psr4 = new Map(); - for (const [ns, dir] of Object.entries(merged)) { - const nsNorm = (ns as string).replace(/\\+$/, ''); - const dirNorm = (dir as string).replace(/\\/g, '/').replace(/\/+$/, ''); - psr4.set(nsNorm, dirNorm); - } - - if (isDev) { - console.log(`📦 Loaded ${psr4.size} PSR-4 mappings from composer.json`); - } - return { psr4 }; - } catch { - return null; - } -} - -/** C# project config parsed from .csproj files */ -interface CSharpProjectConfig { - /** Root namespace from or assembly name (default: project directory name) */ - rootNamespace: string; - /** Directory containing the .csproj file */ - projectDir: string; -} +type ImportResult = + | { kind: 'files'; files: string[] } + | { kind: 'package'; files: string[]; dirSuffix: string } + | null; /** - * Parse .csproj files to extract RootNamespace. - * Scans the repo root for .csproj files and returns configs for each. + * Shared language dispatch for import resolution. + * Used by both processImports and processImportsFromExtracted. */ -async function loadCSharpProjectConfig(repoRoot: string): Promise { - const configs: CSharpProjectConfig[] = []; - // BFS scan for .csproj files up to 5 levels deep, cap at 100 dirs to avoid runaway scanning - const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }]; - const maxDepth = 5; - const maxDirs = 100; - let dirsScanned = 0; - - while (scanQueue.length > 0 && dirsScanned < maxDirs) { - const { dir, depth } = scanQueue.shift()!; - dirsScanned++; - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && depth < maxDepth) { - // Skip common non-project directories - if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'bin' || entry.name === 'obj') continue; - scanQueue.push({ dir: path.join(dir, entry.name), depth: depth + 1 }); - } - if (entry.isFile() && entry.name.endsWith('.csproj')) { - try { - const csprojPath = path.join(dir, entry.name); - const content = await fs.readFile(csprojPath, 'utf-8'); - const nsMatch = content.match(/\s*([^<]+)\s*<\/RootNamespace>/); - const rootNamespace = nsMatch - ? nsMatch[1].trim() - : entry.name.replace(/\.csproj$/, ''); - const projectDir = path.relative(repoRoot, dir).replace(/\\/g, '/'); - configs.push({ rootNamespace, projectDir }); - if (isDev) { - console.log(`📦 Loaded C# project: ${entry.name} (namespace: ${rootNamespace}, dir: ${projectDir})`); - } - } catch { - // Can't read .csproj - } - } +function resolveLanguageImport( + filePath: string, + rawImportPath: string, + language: SupportedLanguages, + configs: LanguageConfigs, + ctx: ResolveCtx, +): ImportResult { + const { allFilePaths, allFileList, normalizedFileList, index, resolveCache } = ctx; + const { tsconfigPaths, goModule, composerConfig, swiftPackageConfig, csharpConfigs } = configs; + + // JVM languages (Java + Kotlin): handle wildcards and member imports + if (language === SupportedLanguages.Java || language === SupportedLanguages.Kotlin) { + const exts = language === SupportedLanguages.Java ? ['.java'] : KOTLIN_EXTENSIONS; + + if (rawImportPath.endsWith('.*')) { + const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index); + if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) { + const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index); + if (javaMatches.length > 0) return { kind: 'files', files: javaMatches }; } - } catch { - // Can't read directory - } - } - return configs; -} - -/** - * Resolve a C# using directive to file paths. - * C# `using` directives import namespaces (not files), so one using can resolve - * to multiple .cs files in a directory — similar to Go package imports. - * - * e.g. "MyApp.Services" -> all .cs files in "src/Services/" - * e.g. "MyApp.Services.UserService" -> "src/Services/UserService.cs" (single file) - * - * Strategy: - * 1. Strip root namespace prefix from each known .csproj project - * 2. Convert remaining namespace to path: Dots -> / - * 3. Try as single file first (ClassName import), then as directory (namespace import) - */ -function resolveCSharpImport( - importPath: string, - csharpConfigs: CSharpProjectConfig[], - normalizedFileList: string[], - allFileList: string[], - index?: SuffixIndex, -): string[] { - const namespacePath = importPath.replace(/\./g, '/'); - const results: string[] = []; - - for (const config of csharpConfigs) { - const nsPath = config.rootNamespace.replace(/\./g, '/'); - let relative: string; - if (namespacePath.startsWith(nsPath + '/')) { - relative = namespacePath.slice(nsPath.length + 1); - } else if (namespacePath === nsPath) { - // The import IS the root namespace — resolve to all .cs files in project root - relative = ''; + if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles }; + // Fall through to standard resolution } else { - continue; - } - - const dirPrefix = config.projectDir - ? (relative ? config.projectDir + '/' + relative : config.projectDir) - : relative; - - // 1. Try as single file: relative.cs (e.g., "Models/DlqMessage.cs") - if (relative) { - const candidate = dirPrefix + '.cs'; - if (index) { - const result = index.get(candidate) || index.getInsensitive(candidate); - if (result) return [result]; + let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index); + if (!memberResolved && language === SupportedLanguages.Kotlin) { + memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index); } - // Also try suffix match - const suffixResult = index?.get(relative + '.cs') || index?.getInsensitive(relative + '.cs'); - if (suffixResult) return [suffixResult]; - } - - // 2. Try as directory: all .cs files directly inside (namespace import) - if (index) { - const dirFiles = index.getFilesInDir(dirPrefix, '.cs'); - for (const f of dirFiles) { - const normalized = f.replace(/\\/g, '/'); - // Check it's a direct child by finding the dirPrefix and ensuring no deeper slashes - const prefixIdx = normalized.indexOf(dirPrefix + '/'); - if (prefixIdx < 0) continue; - const afterDir = normalized.substring(prefixIdx + dirPrefix.length + 1); - if (!afterDir.includes('/')) { - results.push(f); - } - } - if (results.length > 0) return results; - } - - // 3. Linear scan fallback for directory matching - if (results.length === 0) { - const dirTrail = dirPrefix + '/'; - for (let i = 0; i < normalizedFileList.length; i++) { - const normalized = normalizedFileList[i]; - if (!normalized.endsWith('.cs')) continue; - const prefixIdx = normalized.indexOf(dirTrail); - if (prefixIdx < 0) continue; - const afterDir = normalized.substring(prefixIdx + dirTrail.length); - if (!afterDir.includes('/')) { - results.push(allFileList[i]); - } - } - if (results.length > 0) return results; + if (memberResolved) return { kind: 'files', files: [memberResolved] }; + // Fall through to standard resolution } } - // Fallback: suffix matching without namespace stripping (single file) - const pathParts = namespacePath.split('/').filter(Boolean); - const fallback = suffixResolve(pathParts, normalizedFileList, allFileList, index); - return fallback ? [fallback] : []; -} - -/** Swift Package Manager module config */ -interface SwiftPackageConfig { - /** Map of target name -> source directory path (e.g., "SiuperModel" -> "Package/Sources/SiuperModel") */ - targets: Map; -} - -async function loadSwiftPackageConfig(repoRoot: string): Promise { - // Swift imports are module-name based (e.g., `import SiuperModel`) - // SPM convention: Sources// or Package/Sources// - // We scan for these directories to build a target map - const targets = new Map(); - - const sourceDirs = ['Sources', 'Package/Sources', 'src']; - for (const sourceDir of sourceDirs) { - try { - const fullPath = path.join(repoRoot, sourceDir); - const entries = await fs.readdir(fullPath, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - targets.set(entry.name, sourceDir + '/' + entry.name); - } + // Go: handle package-level imports + if (language === SupportedLanguages.Go && goModule && rawImportPath.startsWith(goModule.modulePath)) { + const pkgSuffix = resolveGoPackageDir(rawImportPath, goModule); + if (pkgSuffix) { + const pkgFiles = resolveGoPackage(rawImportPath, goModule, normalizedFileList, allFileList); + if (pkgFiles.length > 0) { + return { kind: 'package', files: pkgFiles, dirSuffix: pkgSuffix }; } - } catch { - // Directory doesn't exist - } - } - - if (targets.size > 0) { - if (isDev) { - console.log(`📦 Loaded ${targets.size} Swift package targets`); } - return { targets }; - } - return null; -} - -// ============================================================================ -// IMPORT PATH RESOLUTION -// ============================================================================ - -/** All file extensions to try during resolution */ -const EXTENSIONS = [ - '', - // TypeScript/JavaScript - '.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts', '/index.jsx', '/index.js', - // Python - '.py', '/__init__.py', - // Java - '.java', - // Kotlin - '.kt', '.kts', - // C/C++ - '.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hxx', '.hh', - // C# - '.cs', - // Go - '.go', - // Rust - '.rs', '/mod.rs', - // PHP - '.php', '.phtml', - // Swift - '.swift', -]; - -/** - * Try to match a path (with extensions) against the known file set. - * Returns the matched file path or null. - */ -function tryResolveWithExtensions( - basePath: string, - allFiles: Set, -): string | null { - for (const ext of EXTENSIONS) { - const candidate = basePath + ext; - if (allFiles.has(candidate)) return candidate; + // Fall through if no files found (package might be external) } - return null; -} -/** - * Build a suffix index for O(1) endsWith lookups. - * Maps every possible path suffix to its original file path. - * e.g. for "src/com/example/Foo.java": - * "Foo.java" -> "src/com/example/Foo.java" - * "example/Foo.java" -> "src/com/example/Foo.java" - * "com/example/Foo.java" -> "src/com/example/Foo.java" - * etc. - */ -export interface SuffixIndex { - /** Exact suffix lookup (case-sensitive) */ - get(suffix: string): string | undefined; - /** Case-insensitive suffix lookup */ - getInsensitive(suffix: string): string | undefined; - /** Get all files in a directory suffix */ - getFilesInDir(dirSuffix: string, extension: string): string[]; -} - -function buildSuffixIndex(normalizedFileList: string[], allFileList: string[]): SuffixIndex { - // Map: normalized suffix -> original file path - const exactMap = new Map(); - // Map: lowercase suffix -> original file path - const lowerMap = new Map(); - // Map: directory suffix -> list of file paths in that directory - const dirMap = new Map(); - - for (let i = 0; i < normalizedFileList.length; i++) { - const normalized = normalizedFileList[i]; - const original = allFileList[i]; - const parts = normalized.split('/'); - - // Index all suffixes: "a/b/c.java" -> ["c.java", "b/c.java", "a/b/c.java"] - for (let j = parts.length - 1; j >= 0; j--) { - const suffix = parts.slice(j).join('/'); - // Only store first match (longest path wins for ambiguous suffixes) - if (!exactMap.has(suffix)) { - exactMap.set(suffix, original); - } - const lower = suffix.toLowerCase(); - if (!lowerMap.has(lower)) { - lowerMap.set(lower, original); + // C#: handle namespace-based imports (using directives) + if (language === SupportedLanguages.CSharp && csharpConfigs.length > 0) { + const resolvedFiles = resolveCSharpImport(rawImportPath, csharpConfigs, normalizedFileList, allFileList, index); + if (resolvedFiles.length > 1) { + const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs); + if (dirSuffix) { + return { kind: 'package', files: resolvedFiles, dirSuffix }; } } - - // Index directory membership - const lastSlash = normalized.lastIndexOf('/'); - if (lastSlash >= 0) { - // Build all directory suffixes - const dirParts = parts.slice(0, -1); - const fileName = parts[parts.length - 1]; - const ext = fileName.substring(fileName.lastIndexOf('.')); - - for (let j = dirParts.length - 1; j >= 0; j--) { - const dirSuffix = dirParts.slice(j).join('/'); - const key = `${dirSuffix}:${ext}`; - let list = dirMap.get(key); - if (!list) { - list = []; - dirMap.set(key, list); - } - list.push(original); - } - } - } - - return { - get: (suffix: string) => exactMap.get(suffix), - getInsensitive: (suffix: string) => lowerMap.get(suffix.toLowerCase()), - getFilesInDir: (dirSuffix: string, extension: string) => { - return dirMap.get(`${dirSuffix}:${extension}`) || []; - }, - }; -} - -/** - * Suffix-based resolution using index. O(1) per lookup instead of O(files). - */ -function suffixResolve( - pathParts: string[], - normalizedFileList: string[], - allFileList: string[], - index?: SuffixIndex, -): string | null { - if (index) { - for (let i = 0; i < pathParts.length; i++) { - const suffix = pathParts.slice(i).join('/'); - for (const ext of EXTENSIONS) { - const suffixWithExt = suffix + ext; - const result = index.get(suffixWithExt) || index.getInsensitive(suffixWithExt); - if (result) return result; - } - } - return null; - } - - // Fallback: linear scan (for backward compatibility) - for (let i = 0; i < pathParts.length; i++) { - const suffix = pathParts.slice(i).join('/'); - for (const ext of EXTENSIONS) { - const suffixWithExt = suffix + ext; - const suffixPattern = '/' + suffixWithExt; - const matchIdx = normalizedFileList.findIndex(filePath => - filePath.endsWith(suffixPattern) || filePath.toLowerCase().endsWith(suffixPattern.toLowerCase()) - ); - if (matchIdx !== -1) { - return allFileList[matchIdx]; - } - } - } - return null; -} - -/** - * Resolve an import path to a file path in the repository. - * - * Language-specific preprocessing is applied before the generic resolution: - * - TypeScript/JavaScript: rewrites tsconfig path aliases - * - Rust: converts crate::/super::/self:: to relative paths - * - * Java wildcards and Go package imports are handled separately in processImports - * because they resolve to multiple files. - */ -const resolveImportPath = ( - currentFile: string, - importPath: string, - allFiles: Set, - allFileList: string[], - normalizedFileList: string[], - resolveCache: Map, - language: SupportedLanguages, - tsconfigPaths: TsconfigPaths | null, - index?: SuffixIndex, -): string | null => { - const cacheKey = `${currentFile}::${importPath}`; - if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey) ?? null; - - const cache = (result: string | null): string | null => { - // Evict oldest 20% when cap is reached instead of clearing all - if (resolveCache.size >= RESOLVE_CACHE_CAP) { - const evictCount = Math.floor(RESOLVE_CACHE_CAP * 0.2); - const iter = resolveCache.keys(); - for (let i = 0; i < evictCount; i++) { - const key = iter.next().value; - if (key !== undefined) resolveCache.delete(key); - } - } - resolveCache.set(cacheKey, result); - return result; - }; - - // ---- TypeScript/JavaScript: rewrite path aliases ---- - if ( - (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) && - tsconfigPaths && - !importPath.startsWith('.') - ) { - for (const [aliasPrefix, targetPrefix] of tsconfigPaths.aliases) { - if (importPath.startsWith(aliasPrefix)) { - const remainder = importPath.slice(aliasPrefix.length); - // Build the rewritten path relative to baseUrl - const rewritten = tsconfigPaths.baseUrl === '.' - ? targetPrefix + remainder - : tsconfigPaths.baseUrl + '/' + targetPrefix + remainder; - - // Try direct resolution from repo root - const resolved = tryResolveWithExtensions(rewritten, allFiles); - if (resolved) return cache(resolved); - - // Try suffix matching as fallback - const parts = rewritten.split('/').filter(Boolean); - const suffixResult = suffixResolve(parts, normalizedFileList, allFileList, index); - if (suffixResult) return cache(suffixResult); - } - } - } - - // ---- Rust: convert module path syntax to file paths ---- - if (language === SupportedLanguages.Rust) { - const rustResult = resolveRustImport(currentFile, importPath, allFiles); - if (rustResult) return cache(rustResult); - // Fall through to generic resolution if Rust-specific didn't match - } - - // ---- Generic relative import resolution (./ and ../) ---- - const currentDir = currentFile.split('/').slice(0, -1); - const parts = importPath.split('/'); - - for (const part of parts) { - if (part === '.') continue; - if (part === '..') { - currentDir.pop(); - } else { - currentDir.push(part); - } - } - - const basePath = currentDir.join('/'); - - if (importPath.startsWith('.')) { - const resolved = tryResolveWithExtensions(basePath, allFiles); - return cache(resolved); - } - - // ---- Generic package/absolute import resolution (suffix matching) ---- - // Java wildcards are handled in processImports, not here - if (importPath.endsWith('.*')) { - return cache(null); - } - - const pathLike = importPath.includes('/') - ? importPath - : importPath.replace(/\./g, '/'); - const pathParts = pathLike.split('/').filter(Boolean); - - const resolved = suffixResolve(pathParts, normalizedFileList, allFileList, index); - return cache(resolved); -}; - -// ============================================================================ -// RUST MODULE RESOLUTION -// ============================================================================ - -/** - * Resolve Rust use-path to a file. - * Handles crate::, super::, self:: prefixes and :: path separators. - */ -function resolveRustImport( - currentFile: string, - importPath: string, - allFiles: Set, -): string | null { - let rustPath: string; - - if (importPath.startsWith('crate::')) { - // crate:: resolves from src/ directory (standard Rust layout) - rustPath = importPath.slice(7).replace(/::/g, '/'); - - // Try from src/ (standard layout) - const fromSrc = tryRustModulePath('src/' + rustPath, allFiles); - if (fromSrc) return fromSrc; - - // Try from repo root (non-standard) - const fromRoot = tryRustModulePath(rustPath, allFiles); - if (fromRoot) return fromRoot; - + if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles }; return null; } - if (importPath.startsWith('super::')) { - // super:: = parent directory of current file's module - const currentDir = currentFile.split('/').slice(0, -1); - currentDir.pop(); // Go up one level for super:: - rustPath = importPath.slice(7).replace(/::/g, '/'); - const fullPath = [...currentDir, rustPath].join('/'); - return tryRustModulePath(fullPath, allFiles); - } - - if (importPath.startsWith('self::')) { - // self:: = current module's directory - const currentDir = currentFile.split('/').slice(0, -1); - rustPath = importPath.slice(6).replace(/::/g, '/'); - const fullPath = [...currentDir, rustPath].join('/'); - return tryRustModulePath(fullPath, allFiles); - } - - // Bare path without prefix (e.g., from a use in a nested module) - // Convert :: to / and try suffix matching - if (importPath.includes('::')) { - rustPath = importPath.replace(/::/g, '/'); - return tryRustModulePath(rustPath, allFiles); - } - - return null; -} - -/** - * Try to resolve a Rust module path to a file. - * Tries: path.rs, path/mod.rs, and with the last segment stripped - * (last segment might be a symbol name, not a module). - */ -function tryRustModulePath(modulePath: string, allFiles: Set): string | null { - // Try direct: path.rs - if (allFiles.has(modulePath + '.rs')) return modulePath + '.rs'; - // Try directory: path/mod.rs - if (allFiles.has(modulePath + '/mod.rs')) return modulePath + '/mod.rs'; - // Try path/lib.rs (for crate root) - if (allFiles.has(modulePath + '/lib.rs')) return modulePath + '/lib.rs'; - - // The last segment might be a symbol (function, struct, etc.), not a module. - // Strip it and try again. - const lastSlash = modulePath.lastIndexOf('/'); - if (lastSlash > 0) { - const parentPath = modulePath.substring(0, lastSlash); - if (allFiles.has(parentPath + '.rs')) return parentPath + '.rs'; - if (allFiles.has(parentPath + '/mod.rs')) return parentPath + '/mod.rs'; - } - - return null; -} - -/** - * Append .* to a Kotlin import path if the AST has a wildcard_import sibling node. - * Pure function — returns a new string without mutating the input. - */ -const appendKotlinWildcard = (importPath: string, importNode: any): string => { - for (let i = 0; i < importNode.childCount; i++) { - if (importNode.child(i)?.type === 'wildcard_import') { - return importPath.endsWith('.*') ? importPath : `${importPath}.*`; - } - } - return importPath; -}; - -// ============================================================================ -// JVM MULTI-FILE RESOLUTION (Java + Kotlin) -// ============================================================================ - -/** Kotlin file extensions for JVM resolver reuse */ -const KOTLIN_EXTENSIONS: readonly string[] = ['.kt', '.kts']; - -/** - * Resolve a JVM wildcard import (com.example.*) to all matching files. - * Works for both Java (.java) and Kotlin (.kt, .kts). - */ -function resolveJvmWildcard( - importPath: string, - normalizedFileList: string[], - allFileList: string[], - extensions: readonly string[], - index?: SuffixIndex, -): string[] { - // "com.example.util.*" -> "com/example/util" - const packagePath = importPath.slice(0, -2).replace(/\./g, '/'); - - if (index) { - const candidates = extensions.flatMap(ext => index.getFilesInDir(packagePath, ext)); - // Filter to only direct children (no subdirectories) - const packageSuffix = '/' + 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); - return !afterPkg.includes('/'); - }); - } - - // Fallback: linear scan - const packageSuffix = '/' + 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]); - } - } + // PHP: handle namespace-based imports (use statements) + if (language === SupportedLanguages.PHP) { + const resolved = resolvePhpImport(rawImportPath, composerConfig, allFilePaths, normalizedFileList, allFileList, index); + return resolved ? { kind: 'files', files: [resolved] } : null; } - return matches; -} -/** - * Try to resolve a JVM member/static import by stripping the member name. - * Java: "com.example.Constants.VALUE" -> resolve "com.example.Constants" - * Kotlin: "com.example.Constants.VALUE" -> resolve "com.example.Constants" - */ -function resolveJvmMemberImport( - importPath: string, - normalizedFileList: string[], - allFileList: string[], - extensions: readonly string[], - index?: SuffixIndex, -): string | null { - // Member imports: com.example.Constants.VALUE or com.example.Constants.* - // The last segment is a member name if it starts with lowercase, is ALL_CAPS, or is a wildcard - const segments = importPath.split('.'); - if (segments.length < 3) return null; - - const lastSeg = segments[segments.length - 1]; - if (lastSeg === '*' || /^[a-z]/.test(lastSeg) || /^[A-Z_]+$/.test(lastSeg)) { - const classPath = segments.slice(0, -1).join('/'); - - for (const ext of extensions) { - const classSuffix = classPath + ext; - if (index) { - const result = index.get(classSuffix) || index.getInsensitive(classSuffix); - if (result) return result; - } else { - const fullSuffix = '/' + classSuffix; - for (let i = 0; i < normalizedFileList.length; i++) { - if (normalizedFileList[i].endsWith(fullSuffix) || - normalizedFileList[i].toLowerCase().endsWith(fullSuffix.toLowerCase())) { - return allFileList[i]; - } + // Swift: handle module imports + if (language === SupportedLanguages.Swift && swiftPackageConfig) { + const targetDir = swiftPackageConfig.targets.get(rawImportPath); + if (targetDir) { + const dirPrefix = targetDir + '/'; + const files: string[] = []; + for (let i = 0; i < normalizedFileList.length; i++) { + if (normalizedFileList[i].startsWith(dirPrefix) && normalizedFileList[i].endsWith('.swift')) { + files.push(allFileList[i]); } } + if (files.length > 0) return { kind: 'files', files }; } + return null; // External framework (Foundation, UIKit, etc.) } - return null; -} - -// ============================================================================ -// GO PACKAGE RESOLUTION -// ============================================================================ - -/** - * Resolve a Go internal package import to all .go files in the package directory. - * Returns an array of file paths. - */ -function resolveGoPackage( - importPath: string, - goModule: GoModuleConfig, - normalizedFileList: string[], - allFileList: string[], -): string[] { - if (!importPath.startsWith(goModule.modulePath)) return []; - - // Strip module path to get relative package path - const relativePkg = importPath.slice(goModule.modulePath.length + 1); // e.g., "internal/auth" - if (!relativePkg) return []; - - const pkgSuffix = '/' + relativePkg + '/'; - const matches: string[] = []; - - for (let i = 0; i < normalizedFileList.length; i++) { - const normalized = normalizedFileList[i]; - // File must be directly in the package directory (not a subdirectory) - if (normalized.includes(pkgSuffix) && normalized.endsWith('.go') && !normalized.endsWith('_test.go')) { - const afterPkg = normalized.substring(normalized.indexOf(pkgSuffix) + pkgSuffix.length); - if (!afterPkg.includes('/')) { - matches.push(allFileList[i]); - } + // Rust: expand top-level grouped imports: use {crate::a, crate::b} + if (language === SupportedLanguages.Rust && rawImportPath.startsWith('{') && rawImportPath.endsWith('}')) { + const inner = rawImportPath.slice(1, -1); + const parts = inner.split(',').map(p => p.trim()).filter(Boolean); + const resolved: string[] = []; + for (const part of parts) { + const r = resolveRustImport(filePath, part, allFilePaths); + if (r) resolved.push(r); } + return resolved.length > 0 ? { kind: 'files', files: resolved } : null; } - return matches; -} + // Standard single-file resolution + const resolvedPath = resolveImportPath( + filePath, + rawImportPath, + allFilePaths, + allFileList, + normalizedFileList, + resolveCache, + language, + tsconfigPaths, + index, + ); -// ============================================================================ -// PHP PSR-4 IMPORT RESOLUTION -// ============================================================================ + return resolvedPath ? { kind: 'files', files: [resolvedPath] } : null; +} /** - * Resolve a PHP use-statement import path using PSR-4 mappings. - * e.g. "App\Http\Controllers\UserController" -> "app/Http/Controllers/UserController.php" + * Apply an ImportResult: emit graph edges and update ImportMap/PackageMap. + * If namedBindings are provided and the import resolves to a single file, + * also populate the NamedImportMap for precise Tier 2a resolution. */ -function resolvePhpImport( - importPath: string, - composerConfig: ComposerConfig | null, - allFiles: Set, - normalizedFileList: string[], - allFileList: string[], - index?: SuffixIndex, -): string | null { - // Normalize: replace backslashes with forward slashes - const normalized = importPath.replace(/\\/g, '/'); - - // Try PSR-4 resolution if composer.json was found - if (composerConfig) { - // Sort namespaces by length descending (longest match wins) - const sorted = [...composerConfig.psr4.entries()].sort((a, b) => b[0].length - a[0].length); - for (const [nsPrefix, dirPrefix] of sorted) { - const nsPrefixSlash = nsPrefix.replace(/\\/g, '/'); - if (normalized.startsWith(nsPrefixSlash + '/') || normalized === nsPrefixSlash) { - const remainder = normalized.slice(nsPrefixSlash.length).replace(/^\//, ''); - const filePath = dirPrefix + (remainder ? '/' + remainder : '') + '.php'; - if (allFiles.has(filePath)) return filePath; - if (index) { - const result = index.getInsensitive(filePath); - if (result) return result; - } +function applyImportResult( + result: ImportResult, + filePath: string, + importMap: ImportMap, + packageMap: PackageMap | undefined, + addImportEdge: (from: string, to: string) => void, + addImportGraphEdge: (from: string, to: string) => void, + namedBindings?: { local: string; exported: string }[], + namedImportMap?: NamedImportMap, +): void { + if (!result) return; + + if (result.kind === 'package' && packageMap) { + // Store directory suffix in PackageMap (skip ImportMap expansion) + for (const resolvedFile of result.files) { + addImportGraphEdge(filePath, resolvedFile); + } + if (!packageMap.has(filePath)) packageMap.set(filePath, new Set()); + packageMap.get(filePath)!.add(result.dirSuffix); + } else { + // 'files' kind, or 'package' without PackageMap — use ImportMap directly + const files = result.files; + for (const resolvedFile of files) { + addImportEdge(filePath, resolvedFile); + } + + // Record named bindings for precise Tier 2a resolution + 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 }); } } } - - // Fallback: suffix matching (works without composer.json) - const pathParts = normalized.split('/').filter(Boolean); - return suffixResolve(pathParts, normalizedFileList, allFileList, index); } // ============================================================================ @@ -875,6 +305,8 @@ export const processImports = async ( onProgress?: (current: number, total: number) => void, repoRoot?: string, allPaths?: string[], + packageMap?: PackageMap, + namedImportMap?: NamedImportMap, ) => { // Use allPaths (full repo) when available for cross-chunk resolution, else fall back to chunk files const allFileList = allPaths ?? files.map(f => f.path); @@ -894,14 +326,17 @@ export const processImports = async ( // Load language-specific configs once before the file loop const effectiveRoot = repoRoot || ''; - const tsconfigPaths = await loadTsconfigPaths(effectiveRoot); - const goModule = await loadGoModulePath(effectiveRoot); - const composerConfig = await loadComposerConfig(effectiveRoot); - const swiftPackageConfig = await loadSwiftPackageConfig(effectiveRoot); - const csharpConfigs = await loadCSharpProjectConfig(effectiveRoot); + const configs: LanguageConfigs = { + tsconfigPaths: await loadTsconfigPaths(effectiveRoot), + goModule: await loadGoModulePath(effectiveRoot), + composerConfig: await loadComposerConfig(effectiveRoot), + swiftPackageConfig: await loadSwiftPackageConfig(effectiveRoot), + csharpConfigs: await loadCSharpProjectConfig(effectiveRoot), + }; + const ctx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache }; - // Helper: add an IMPORTS edge + update import map - const addImportEdge = (filePath: string, resolvedPath: string) => { + // Helper: add an IMPORTS edge to the graph only (no ImportMap update) + const addImportGraphEdge = (filePath: string, resolvedPath: string) => { const sourceId = generateId('File', filePath); const targetId = generateId('File', resolvedPath); const relId = generateId('IMPORTS', `${filePath}->${resolvedPath}`); @@ -916,6 +351,11 @@ export const processImports = async ( confidence: 1.0, reason: '', }); + }; + + // Helper: add an IMPORTS edge + update import map + const addImportEdge = (filePath: string, resolvedPath: string) => { + addImportGraphEdge(filePath, resolvedPath); if (!importMap.has(filePath)) { importMap.set(filePath, new Set()); @@ -1000,104 +440,9 @@ export const processImports = async ( : sourceNode.text.replace(/['"<>]/g, ''); totalImportsFound++; - // ---- JVM languages (Java + Kotlin): handle wildcards and member imports ---- - if (language === SupportedLanguages.Java || language === SupportedLanguages.Kotlin) { - const exts = language === SupportedLanguages.Java ? ['.java'] : KOTLIN_EXTENSIONS; - - if (rawImportPath.endsWith('.*')) { - const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index); - // Kotlin can import Java files in mixed codebases — try .java as fallback - if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) { - const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index); - for (const matchedFile of javaMatches) { - addImportEdge(file.path, matchedFile); - } - if (javaMatches.length > 0) return; - } - for (const matchedFile of matchedFiles) { - addImportEdge(file.path, matchedFile); - } - return; // skip single-file resolution - } - - // Try member/static import resolution (strip member name) - let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index); - // Kotlin can import Java files in mixed codebases — try .java as fallback - if (!memberResolved && language === SupportedLanguages.Kotlin) { - memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index); - } - if (memberResolved) { - addImportEdge(file.path, memberResolved); - return; - } - // Fall through to normal resolution for regular imports - } - - // ---- Go: handle package-level imports ---- - if (language === SupportedLanguages.Go && goModule && rawImportPath.startsWith(goModule.modulePath)) { - const pkgFiles = resolveGoPackage(rawImportPath, goModule, normalizedFileList, allFileList); - if (pkgFiles.length > 0) { - for (const pkgFile of pkgFiles) { - addImportEdge(file.path, pkgFile); - } - return; // skip single-file resolution - } - // Fall through if no files found (package might be external) - } - - // ---- C#: handle namespace-based imports (using directives) ---- - if (language === SupportedLanguages.CSharp && csharpConfigs.length > 0) { - const resolvedFiles = resolveCSharpImport(rawImportPath, csharpConfigs, normalizedFileList, allFileList, index); - for (const resolvedFile of resolvedFiles) { - addImportEdge(file.path, resolvedFile); - } - return; - } - - // ---- PHP: handle namespace-based imports (use statements) ---- - if (language === SupportedLanguages.PHP) { - const resolved = resolvePhpImport(rawImportPath, composerConfig, allFilePaths, normalizedFileList, allFileList, index); - if (resolved) { - addImportEdge(file.path, resolved); - } - return; - } - - // ---- Swift: handle module imports ---- - if (language === SupportedLanguages.Swift && swiftPackageConfig) { - // Swift imports are module names: `import SiuperModel` - // Resolve to the module's source directory → all .swift files in it - const targetDir = swiftPackageConfig.targets.get(rawImportPath); - if (targetDir) { - // Find all .swift files in this target directory - const dirPrefix = targetDir + '/'; - for (const filePath2 of allFileList) { - if (filePath2.startsWith(dirPrefix) && filePath2.endsWith('.swift')) { - addImportEdge(file.path, filePath2); - } - } - return; - } - // External framework (Foundation, UIKit, etc.) — skip - return; - } - - // ---- Standard single-file resolution ---- - const resolvedPath = resolveImportPath( - file.path, - rawImportPath, - allFilePaths, - allFileList, - normalizedFileList, - resolveCache, - language, - tsconfigPaths, - index, - ); - - if (resolvedPath) { - addImportEdge(file.path, resolvedPath); - } + const result = resolveLanguageImport(file.path, rawImportPath, language, configs, ctx); + const bindings = namedImportMap ? extractNamedBindings(captureMap['import'], language) : undefined; + applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge, bindings, namedImportMap); } }); @@ -1129,6 +474,8 @@ export const processImportsFromExtracted = async ( onProgress?: (current: number, total: number) => void, repoRoot?: string, prebuiltCtx?: ImportResolutionContext, + packageMap?: PackageMap, + namedImportMap?: NamedImportMap, ) => { const ctx = prebuiltCtx ?? buildImportResolutionContext(files.map(f => f.path)); const { allFilePaths, allFileList, normalizedFileList, suffixIndex: index, resolveCache } = ctx; @@ -1137,13 +484,17 @@ export const processImportsFromExtracted = async ( let totalImportsResolved = 0; const effectiveRoot = repoRoot || ''; - const tsconfigPaths = await loadTsconfigPaths(effectiveRoot); - const goModule = await loadGoModulePath(effectiveRoot); - const composerConfig = await loadComposerConfig(effectiveRoot); - const swiftPackageConfig = await loadSwiftPackageConfig(effectiveRoot); - const csharpConfigs = await loadCSharpProjectConfig(effectiveRoot); + const configs: LanguageConfigs = { + tsconfigPaths: await loadTsconfigPaths(effectiveRoot), + goModule: await loadGoModulePath(effectiveRoot), + composerConfig: await loadComposerConfig(effectiveRoot), + swiftPackageConfig: await loadSwiftPackageConfig(effectiveRoot), + csharpConfigs: await loadCSharpProjectConfig(effectiveRoot), + }; + const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache }; - const addImportEdge = (filePath: string, resolvedPath: string) => { + // Helper: add an IMPORTS edge to the graph only (no ImportMap update) + const addImportGraphEdge = (filePath: string, resolvedPath: string) => { const sourceId = generateId('File', filePath); const targetId = generateId('File', resolvedPath); const relId = generateId('IMPORTS', `${filePath}->${resolvedPath}`); @@ -1158,6 +509,10 @@ export const processImportsFromExtracted = async ( confidence: 1.0, reason: '', }); + }; + + const addImportEdge = (filePath: string, resolvedPath: string) => { + addImportGraphEdge(filePath, resolvedPath); if (!importMap.has(filePath)) { importMap.set(filePath, new Set()); @@ -1179,21 +534,6 @@ export const processImportsFromExtracted = async ( const totalFiles = importsByFile.size; let filesProcessed = 0; - // Pre-build a suffix index for O(1) suffix lookups instead of O(n) linear scans - const suffixIndex = new Map(); - for (let i = 0; i < normalizedFileList.length; i++) { - const normalized = normalizedFileList[i]; - // Index by last path segment (filename) for fast suffix matching - const lastSlash = normalized.lastIndexOf('/'); - const filename = lastSlash >= 0 ? normalized.substring(lastSlash + 1) : normalized; - let list = suffixIndex.get(filename); - if (!list) { - list = []; - suffixIndex.set(filename, list); - } - list.push(allFileList[i]); - } - for (const [filePath, fileImports] of importsByFile) { filesProcessed++; if (filesProcessed % 100 === 0) { @@ -1201,109 +541,11 @@ export const processImportsFromExtracted = async ( await yieldToEventLoop(); } - for (const { rawImportPath, language } of fileImports) { + for (const imp of fileImports) { totalImportsFound++; - // Check resolve cache first - const cacheKey = `${filePath}::${rawImportPath}`; - if (resolveCache.has(cacheKey)) { - const cached = resolveCache.get(cacheKey); - if (cached) addImportEdge(filePath, cached); - continue; - } - - // JVM languages (Java + Kotlin): handle wildcards and member imports - if (language === SupportedLanguages.Java || language === SupportedLanguages.Kotlin) { - const exts = language === SupportedLanguages.Java ? ['.java'] : KOTLIN_EXTENSIONS; - - if (rawImportPath.endsWith('.*')) { - const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index); - // Kotlin can import Java files in mixed codebases — try .java as fallback - if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) { - const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index); - for (const matchedFile of javaMatches) { - addImportEdge(filePath, matchedFile); - } - if (javaMatches.length > 0) continue; - } - for (const matchedFile of matchedFiles) { - addImportEdge(filePath, matchedFile); - } - continue; - } - - let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index); - // Kotlin can import Java files in mixed codebases — try .java as fallback - if (!memberResolved && language === SupportedLanguages.Kotlin) { - memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index); - } - if (memberResolved) { - resolveCache.set(cacheKey, memberResolved); - addImportEdge(filePath, memberResolved); - continue; - } - } - - // Go: handle package-level imports - if (language === SupportedLanguages.Go && goModule && rawImportPath.startsWith(goModule.modulePath)) { - const pkgFiles = resolveGoPackage(rawImportPath, goModule, normalizedFileList, allFileList); - if (pkgFiles.length > 0) { - for (const pkgFile of pkgFiles) { - addImportEdge(filePath, pkgFile); - } - continue; - } - } - - // C#: handle namespace-based imports (using directives) - if (language === SupportedLanguages.CSharp && csharpConfigs.length > 0) { - const resolvedFiles = resolveCSharpImport(rawImportPath, csharpConfigs, normalizedFileList, allFileList, index); - for (const resolvedFile of resolvedFiles) { - addImportEdge(filePath, resolvedFile); - } - continue; - } - - // PHP: handle namespace-based imports (use statements) - if (language === SupportedLanguages.PHP) { - const resolved = resolvePhpImport(rawImportPath, composerConfig, allFilePaths, normalizedFileList, allFileList, index); - if (resolved) { - resolveCache.set(cacheKey, resolved); - addImportEdge(filePath, resolved); - } - continue; - } - - // Swift: handle module imports - if (language === SupportedLanguages.Swift && swiftPackageConfig) { - const targetDir = swiftPackageConfig.targets.get(rawImportPath); - if (targetDir) { - const dirPrefix = targetDir + '/'; - for (const fp of allFileList) { - if (fp.startsWith(dirPrefix) && fp.endsWith('.swift')) { - addImportEdge(filePath, fp); - } - } - } - continue; - } - - // Standard resolution (has its own internal cache) - const resolvedPath = resolveImportPath( - filePath, - rawImportPath, - allFilePaths, - allFileList, - normalizedFileList, - resolveCache, - language as SupportedLanguages, - tsconfigPaths, - index, - ); - - if (resolvedPath) { - addImportEdge(filePath, resolvedPath); - } + const result = resolveLanguageImport(filePath, imp.rawImportPath, imp.language, configs, resolveCtx); + applyImportResult(result, filePath, importMap, packageMap, addImportEdge, addImportGraphEdge, imp.namedBindings, namedImportMap); } } diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts new file mode 100644 index 0000000000..cd64ca10dc --- /dev/null +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -0,0 +1,215 @@ +import fs from 'fs/promises'; +import path from 'path'; + +const isDev = process.env.NODE_ENV === 'development'; + +// ============================================================================ +// LANGUAGE-SPECIFIC CONFIG TYPES +// ============================================================================ + +/** TypeScript path alias config parsed from tsconfig.json */ +export interface TsconfigPaths { + /** Map of alias prefix -> target prefix (e.g., "@/" -> "src/") */ + aliases: Map; + /** Base URL for path resolution (relative to repo root) */ + baseUrl: string; +} + +/** Go module config parsed from go.mod */ +export interface GoModuleConfig { + /** Module path (e.g., "github.com/user/repo") */ + modulePath: string; +} + +/** PHP Composer PSR-4 autoload config */ +export interface ComposerConfig { + /** Map of namespace prefix -> directory (e.g., "App\\" -> "app/") */ + psr4: Map; +} + +/** C# project config parsed from .csproj files */ +export interface CSharpProjectConfig { + /** Root namespace from or assembly name (default: project directory name) */ + rootNamespace: string; + /** Directory containing the .csproj file */ + projectDir: string; +} + +/** Swift Package Manager module config */ +export interface SwiftPackageConfig { + /** Map of target name -> source directory path (e.g., "SiuperModel" -> "Package/Sources/SiuperModel") */ + targets: Map; +} + +// ============================================================================ +// LANGUAGE-SPECIFIC CONFIG LOADERS +// ============================================================================ + +/** + * Parse tsconfig.json to extract path aliases. + * Tries tsconfig.json, tsconfig.app.json, tsconfig.base.json in order. + */ +export async function loadTsconfigPaths(repoRoot: string): Promise { + const candidates = ['tsconfig.json', 'tsconfig.app.json', 'tsconfig.base.json']; + + for (const filename of candidates) { + try { + const tsconfigPath = path.join(repoRoot, filename); + const raw = await fs.readFile(tsconfigPath, 'utf-8'); + // Strip JSON comments (// and /* */ style) for robustness + const stripped = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); + const tsconfig = JSON.parse(stripped); + const compilerOptions = tsconfig.compilerOptions; + if (!compilerOptions?.paths) continue; + + const baseUrl = compilerOptions.baseUrl || '.'; + const aliases = new Map(); + + for (const [pattern, targets] of Object.entries(compilerOptions.paths)) { + if (!Array.isArray(targets) || targets.length === 0) continue; + const target = targets[0] as string; + + // Convert glob patterns: "@/*" -> "@/", "src/*" -> "src/" + const aliasPrefix = pattern.endsWith('/*') ? pattern.slice(0, -1) : pattern; + const targetPrefix = target.endsWith('/*') ? target.slice(0, -1) : target; + + aliases.set(aliasPrefix, targetPrefix); + } + + if (aliases.size > 0) { + if (isDev) { + console.log(`📦 Loaded ${aliases.size} path aliases from ${filename}`); + } + return { aliases, baseUrl }; + } + } catch { + // File doesn't exist or isn't valid JSON - try next + } + } + + return null; +} + +/** + * Parse go.mod to extract module path. + */ +export async function loadGoModulePath(repoRoot: string): Promise { + try { + const goModPath = path.join(repoRoot, 'go.mod'); + const content = await fs.readFile(goModPath, 'utf-8'); + const match = content.match(/^module\s+(\S+)/m); + if (match) { + if (isDev) { + console.log(`📦 Loaded Go module path: ${match[1]}`); + } + return { modulePath: match[1] }; + } + } catch { + // No go.mod + } + return null; +} + +/** Parse composer.json to extract PSR-4 autoload mappings (including autoload-dev). */ +export async function loadComposerConfig(repoRoot: string): Promise { + try { + const composerPath = path.join(repoRoot, 'composer.json'); + const raw = await fs.readFile(composerPath, 'utf-8'); + const composer = JSON.parse(raw); + const psr4Raw = composer.autoload?.['psr-4'] ?? {}; + const psr4Dev = composer['autoload-dev']?.['psr-4'] ?? {}; + const merged = { ...psr4Raw, ...psr4Dev }; + + const psr4 = new Map(); + for (const [ns, dir] of Object.entries(merged)) { + const nsNorm = (ns as string).replace(/\\+$/, ''); + const dirNorm = (dir as string).replace(/\\/g, '/').replace(/\/+$/, ''); + psr4.set(nsNorm, dirNorm); + } + + if (isDev) { + console.log(`📦 Loaded ${psr4.size} PSR-4 mappings from composer.json`); + } + return { psr4 }; + } catch { + return null; + } +} + +/** + * Parse .csproj files to extract RootNamespace. + * Scans the repo root for .csproj files and returns configs for each. + */ +export async function loadCSharpProjectConfig(repoRoot: string): Promise { + const configs: CSharpProjectConfig[] = []; + // BFS scan for .csproj files up to 5 levels deep, cap at 100 dirs to avoid runaway scanning + const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }]; + const maxDepth = 5; + const maxDirs = 100; + let dirsScanned = 0; + + while (scanQueue.length > 0 && dirsScanned < maxDirs) { + const { dir, depth } = scanQueue.shift()!; + dirsScanned++; + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && depth < maxDepth) { + // Skip common non-project directories + if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'bin' || entry.name === 'obj') continue; + scanQueue.push({ dir: path.join(dir, entry.name), depth: depth + 1 }); + } + if (entry.isFile() && entry.name.endsWith('.csproj')) { + try { + const csprojPath = path.join(dir, entry.name); + const content = await fs.readFile(csprojPath, 'utf-8'); + const nsMatch = content.match(/\s*([^<]+)\s*<\/RootNamespace>/); + const rootNamespace = nsMatch + ? nsMatch[1].trim() + : entry.name.replace(/\.csproj$/, ''); + const projectDir = path.relative(repoRoot, dir).replace(/\\/g, '/'); + configs.push({ rootNamespace, projectDir }); + if (isDev) { + console.log(`📦 Loaded C# project: ${entry.name} (namespace: ${rootNamespace}, dir: ${projectDir})`); + } + } catch { + // Can't read .csproj + } + } + } + } catch { + // Can't read directory + } + } + return configs; +} + +export async function loadSwiftPackageConfig(repoRoot: string): Promise { + // Swift imports are module-name based (e.g., `import SiuperModel`) + // SPM convention: Sources// or Package/Sources// + // We scan for these directories to build a target map + const targets = new Map(); + + const sourceDirs = ['Sources', 'Package/Sources', 'src']; + for (const sourceDir of sourceDirs) { + try { + const fullPath = path.join(repoRoot, sourceDir); + const entries = await fs.readdir(fullPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + targets.set(entry.name, sourceDir + '/' + entry.name); + } + } + } catch { + // Directory doesn't exist + } + } + + if (targets.size > 0) { + if (isDev) { + console.log(`📦 Loaded ${targets.size} Swift package targets`); + } + return { targets }; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/mro-processor.ts b/gitnexus/src/core/ingestion/mro-processor.ts new file mode 100644 index 0000000000..a683e9a616 --- /dev/null +++ b/gitnexus/src/core/ingestion/mro-processor.ts @@ -0,0 +1,465 @@ +/** + * MRO (Method Resolution Order) Processor + * + * Walks the inheritance DAG (EXTENDS/IMPLEMENTS edges), collects methods from + * each ancestor via HAS_METHOD edges, detects method-name collisions across + * parents, and applies language-specific resolution rules to emit OVERRIDES edges. + * + * Language-specific rules: + * - C++: leftmost base class in declaration order wins + * - C#/Java: class method wins over interface default; multiple interface + * methods with same name are ambiguous (null resolution) + * - Python: C3 linearization determines MRO; first in linearized order wins + * - Rust: no auto-resolution — requires qualified syntax, resolvedTo = null + * - Default: single inheritance — first definition wins + * + * OVERRIDES edge direction: Class → Method (not Method → Method). + * The source is the child class that inherits conflicting methods, + * the target is the winning ancestor method node. + * Cypher: MATCH (c:Class)-[r:CodeRelation {type: 'OVERRIDES'}]->(m:Method) + */ + +import { KnowledgeGraph, GraphRelationship } from '../graph/types.js'; +import { generateId } from '../../lib/utils.js'; +import { SupportedLanguages } from '../../config/supported-languages.js'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface MROEntry { + classId: string; + className: string; + language: SupportedLanguages; + mro: string[]; // linearized parent names + ambiguities: MethodAmbiguity[]; +} + +export interface MethodAmbiguity { + methodName: string; + definedIn: Array<{ classId: string; className: string; methodId: string }>; + resolvedTo: string | null; // winning methodId or null if truly ambiguous + reason: string; +} + +export interface MROResult { + entries: MROEntry[]; + overrideEdges: number; + ambiguityCount: number; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Collect EXTENDS, IMPLEMENTS, and HAS_METHOD adjacency from the graph. */ +function buildAdjacency(graph: KnowledgeGraph) { + // parentMap: childId → parentIds[] (in insertion / declaration order) + const parentMap = new Map(); + // methodMap: classId → methodIds[] + const methodMap = new Map(); + // Track which edge type each parent link came from + const parentEdgeType = new Map>(); + + graph.forEachRelationship((rel) => { + if (rel.type === 'EXTENDS' || rel.type === 'IMPLEMENTS') { + let parents = parentMap.get(rel.sourceId); + if (!parents) { + parents = []; + parentMap.set(rel.sourceId, parents); + } + parents.push(rel.targetId); + + let edgeTypes = parentEdgeType.get(rel.sourceId); + if (!edgeTypes) { + edgeTypes = new Map(); + parentEdgeType.set(rel.sourceId, edgeTypes); + } + edgeTypes.set(rel.targetId, rel.type); + } + + if (rel.type === 'HAS_METHOD') { + let methods = methodMap.get(rel.sourceId); + if (!methods) { + methods = []; + methodMap.set(rel.sourceId, methods); + } + methods.push(rel.targetId); + } + }); + + return { parentMap, methodMap, parentEdgeType }; +} + +/** + * Gather all ancestor IDs in BFS / topological order. + * Returns the linearized list of ancestor IDs (excluding the class itself). + */ +function gatherAncestors( + classId: string, + parentMap: Map, +): string[] { + const visited = new Set(); + const order: string[] = []; + const queue: string[] = [...(parentMap.get(classId) ?? [])]; + + while (queue.length > 0) { + const id = queue.shift()!; + if (visited.has(id)) continue; + visited.add(id); + order.push(id); + const grandparents = parentMap.get(id); + if (grandparents) { + for (const gp of grandparents) { + if (!visited.has(gp)) queue.push(gp); + } + } + } + + return order; +} + +// --------------------------------------------------------------------------- +// C3 linearization (Python MRO) +// --------------------------------------------------------------------------- + +/** + * Compute C3 linearization for a class given a parentMap. + * Returns an array of ancestor IDs in C3 order (excluding the class itself), + * or null if linearization fails (inconsistent or cyclic hierarchy). + */ +function c3Linearize( + classId: string, + parentMap: Map, + cache: Map, + inProgress?: Set, +): string[] | null { + if (cache.has(classId)) return cache.get(classId)!; + + // Cycle detection: if we're already computing this class, the hierarchy is cyclic + const visiting = inProgress ?? new Set(); + if (visiting.has(classId)) { + cache.set(classId, null); + return null; + } + visiting.add(classId); + + const directParents = parentMap.get(classId); + if (!directParents || directParents.length === 0) { + visiting.delete(classId); + cache.set(classId, []); + return []; + } + + // Compute linearization for each parent first + const parentLinearizations: string[][] = []; + for (const pid of directParents) { + const pLin = c3Linearize(pid, parentMap, cache, visiting); + if (pLin === null) { + visiting.delete(classId); + cache.set(classId, null); + return null; + } + parentLinearizations.push([pid, ...pLin]); + } + + // Add the direct parents list as the final sequence + const sequences = [...parentLinearizations, [...directParents]]; + const result: string[] = []; + + while (sequences.some(s => s.length > 0)) { + // Find a good head: one that doesn't appear in the tail of any other sequence + let head: string | null = null; + for (const seq of sequences) { + if (seq.length === 0) continue; + const candidate = seq[0]; + const inTail = sequences.some( + other => other.length > 1 && other.indexOf(candidate, 1) !== -1 + ); + if (!inTail) { + head = candidate; + break; + } + } + + if (head === null) { + // Inconsistent hierarchy + visiting.delete(classId); + cache.set(classId, null); + return null; + } + + result.push(head); + + // Remove the chosen head from all sequences + for (const seq of sequences) { + if (seq.length > 0 && seq[0] === head) { + seq.shift(); + } + } + } + + visiting.delete(classId); + cache.set(classId, result); + return result; +} + +// --------------------------------------------------------------------------- +// Language-specific resolution +// --------------------------------------------------------------------------- + +type MethodDef = { classId: string; className: string; methodId: string }; +type Resolution = { resolvedTo: string | null; reason: string }; + +/** Resolve by MRO order — first ancestor in linearized order wins. */ +function resolveByMroOrder( + methodName: string, + defs: MethodDef[], + mroOrder: string[], + reasonPrefix: string, +): Resolution { + for (const ancestorId of mroOrder) { + const match = defs.find(d => d.classId === ancestorId); + if (match) { + return { + resolvedTo: match.methodId, + reason: `${reasonPrefix}: ${match.className}::${methodName}`, + }; + } + } + return { resolvedTo: defs[0].methodId, reason: `${reasonPrefix} fallback: first definition` }; +} + +function resolveCsharpJava( + methodName: string, + defs: MethodDef[], + parentEdgeTypes: Map | undefined, +): Resolution { + const classDefs: MethodDef[] = []; + const interfaceDefs: MethodDef[] = []; + + for (const def of defs) { + const edgeType = parentEdgeTypes?.get(def.classId); + if (edgeType === 'IMPLEMENTS') { + interfaceDefs.push(def); + } else { + classDefs.push(def); + } + } + + if (classDefs.length > 0) { + return { + resolvedTo: classDefs[0].methodId, + reason: `class method wins: ${classDefs[0].className}::${methodName}`, + }; + } + + if (interfaceDefs.length > 1) { + return { + resolvedTo: null, + reason: `ambiguous: ${methodName} defined in multiple interfaces: ${interfaceDefs.map(d => d.className).join(', ')}`, + }; + } + + if (interfaceDefs.length === 1) { + return { + resolvedTo: interfaceDefs[0].methodId, + reason: `single interface default: ${interfaceDefs[0].className}::${methodName}`, + }; + } + + return { resolvedTo: null, reason: 'no resolution found' }; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export function computeMRO(graph: KnowledgeGraph): MROResult { + const { parentMap, methodMap, parentEdgeType } = buildAdjacency(graph); + const c3Cache = new Map(); + + const entries: MROEntry[] = []; + let overrideEdges = 0; + let ambiguityCount = 0; + + // Process every class that has at least one parent + for (const [classId, directParents] of parentMap) { + if (directParents.length === 0) continue; + + const classNode = graph.getNode(classId); + if (!classNode) continue; + + const language = classNode.properties.language; + if (!language) continue; + const className = classNode.properties.name; + + // Compute linearized MRO depending on language + let mroOrder: string[]; + if (language === SupportedLanguages.Python) { + const c3Result = c3Linearize(classId, parentMap, c3Cache); + mroOrder = c3Result ?? gatherAncestors(classId, parentMap); + } else { + mroOrder = gatherAncestors(classId, parentMap); + } + + // Get the parent names for the MRO entry + const mroNames: string[] = mroOrder + .map(id => graph.getNode(id)?.properties.name) + .filter((n): n is string => n !== undefined); + + // Collect methods from all ancestors, grouped by method name + const methodsByName = new Map(); + for (const ancestorId of mroOrder) { + const ancestorNode = graph.getNode(ancestorId); + if (!ancestorNode) continue; + + const methods = methodMap.get(ancestorId) ?? []; + for (const methodId of methods) { + const methodNode = graph.getNode(methodId); + if (!methodNode) continue; + // Properties don't participate in method resolution order + if (methodNode.label === 'Property') continue; + + const methodName = methodNode.properties.name; + let defs = methodsByName.get(methodName); + if (!defs) { + defs = []; + methodsByName.set(methodName, defs); + } + // Avoid duplicates (same method seen via multiple paths) + if (!defs.some(d => d.methodId === methodId)) { + defs.push({ + classId: ancestorId, + className: ancestorNode.properties.name, + methodId, + }); + } + } + } + + // Detect collisions: methods defined in 2+ different ancestors + const ambiguities: MethodAmbiguity[] = []; + + // Compute transitive edge types once per class (only needed for C#/Java) + const needsEdgeTypes = language === SupportedLanguages.CSharp || language === SupportedLanguages.Java || language === SupportedLanguages.Kotlin; + const classEdgeTypes = needsEdgeTypes + ? buildTransitiveEdgeTypes(classId, parentMap, parentEdgeType) + : undefined; + + for (const [methodName, defs] of methodsByName) { + if (defs.length < 2) continue; + + // Own method shadows inherited — no ambiguity + const ownMethods = methodMap.get(classId) ?? []; + const ownDefinesIt = ownMethods.some(mid => { + const mn = graph.getNode(mid); + return mn?.properties.name === methodName; + }); + if (ownDefinesIt) continue; + + let resolution: Resolution; + + switch (language) { + case SupportedLanguages.CPlusPlus: + resolution = resolveByMroOrder(methodName, defs, mroOrder, 'C++ leftmost base'); + break; + case SupportedLanguages.CSharp: + case SupportedLanguages.Java: + case SupportedLanguages.Kotlin: + resolution = resolveCsharpJava(methodName, defs, classEdgeTypes); + break; + case SupportedLanguages.Python: + resolution = resolveByMroOrder(methodName, defs, mroOrder, 'Python C3 MRO'); + break; + case SupportedLanguages.Rust: + resolution = { + resolvedTo: null, + reason: `Rust requires qualified syntax: ::${methodName}()`, + }; + break; + default: + resolution = resolveByMroOrder(methodName, defs, mroOrder, 'first definition'); + break; + } + + const ambiguity: MethodAmbiguity = { + methodName, + definedIn: defs, + resolvedTo: resolution.resolvedTo, + reason: resolution.reason, + }; + ambiguities.push(ambiguity); + + if (resolution.resolvedTo === null) { + ambiguityCount++; + } + + // Emit OVERRIDES edge if resolution found + if (resolution.resolvedTo !== null) { + graph.addRelationship({ + id: generateId('OVERRIDES', `${classId}->${resolution.resolvedTo}`), + sourceId: classId, + targetId: resolution.resolvedTo, + type: 'OVERRIDES', + confidence: 1.0, + reason: resolution.reason, + }); + overrideEdges++; + } + } + + entries.push({ + classId, + className, + language, + mro: mroNames, + ambiguities, + }); + } + + return { entries, overrideEdges, ambiguityCount }; +} + +/** + * Build transitive edge types for a class using BFS from the class to all ancestors. + * + * Known limitation: BFS first-reach heuristic can misclassify an interface as + * EXTENDS if it's reachable via a class chain before being seen via IMPLEMENTS. + * E.g. if BaseClass also implements IFoo, IFoo may be classified as EXTENDS. + * This affects C#/Java/Kotlin conflict resolution in rare diamond hierarchies. + */ +function buildTransitiveEdgeTypes( + classId: string, + parentMap: Map, + parentEdgeType: Map>, +): Map { + const result = new Map(); + const directEdges = parentEdgeType.get(classId); + if (!directEdges) return result; + + // BFS: propagate edge type from direct parents + const queue: Array<{ id: string; edgeType: 'EXTENDS' | 'IMPLEMENTS' }> = []; + const directParents = parentMap.get(classId) ?? []; + + for (const pid of directParents) { + const et = directEdges.get(pid) ?? 'EXTENDS'; + if (!result.has(pid)) { + result.set(pid, et); + queue.push({ id: pid, edgeType: et }); + } + } + + while (queue.length > 0) { + const { id, edgeType } = queue.shift()!; + const grandparents = parentMap.get(id) ?? []; + for (const gp of grandparents) { + if (!result.has(gp)) { + result.set(gp, edgeType); + queue.push({ id: gp, edgeType }); + } + } + } + + return result; +} diff --git a/gitnexus/src/core/ingestion/named-binding-extraction.ts b/gitnexus/src/core/ingestion/named-binding-extraction.ts new file mode 100644 index 0000000000..a4a56fb272 --- /dev/null +++ b/gitnexus/src/core/ingestion/named-binding-extraction.ts @@ -0,0 +1,384 @@ +import { SupportedLanguages } from '../../config/supported-languages.js'; +import type { SymbolTable, SymbolDefinition } from './symbol-table.js'; +import type { NamedImportMap } from './import-processor.js'; + +/** + * Walk a named-binding re-export chain through NamedImportMap. + * + * When file A imports { User } from B, and B re-exports { User } from C, + * the NamedImportMap for A points to B, but B has no User definition. + * This function follows the chain: A→B→C until a definition is found. + * + * Returns the definitions found at the end of the chain, or null if the + * chain breaks (missing binding, circular reference, or depth exceeded). + * Max depth 5 to prevent infinite loops. + * + * @param allDefs Pre-computed `symbolTable.lookupFuzzy(name)` result — must be the + * complete unfiltered result. Passing a file-filtered subset will cause + * silent misses at depth=0 for non-aliased bindings. + */ +export function walkBindingChain( + name: string, + currentFilePath: string, + symbolTable: SymbolTable, + namedImportMap: NamedImportMap, + allDefs: SymbolDefinition[], +): SymbolDefinition[] | null { + let lookupFile = currentFilePath; + let lookupName = name; + const visited = new Set(); + + for (let depth = 0; depth < 5; depth++) { + const bindings = namedImportMap.get(lookupFile); + if (!bindings) return null; + + const binding = bindings.get(lookupName); + if (!binding) return null; + + const key = `${binding.sourcePath}:${binding.exportedName}`; + if (visited.has(key)) return null; // circular + visited.add(key); + + const targetName = binding.exportedName; + const resolvedDefs = targetName !== lookupName || depth > 0 + ? symbolTable.lookupFuzzy(targetName).filter(def => def.filePath === binding.sourcePath) + : allDefs.filter(def => def.filePath === binding.sourcePath); + + if (resolvedDefs.length > 0) return resolvedDefs; + + // No definition in source file → follow re-export chain + lookupFile = binding.sourcePath; + lookupName = targetName; + } + + return null; +} + +/** + * Extract named bindings from an import AST node. + * Returns undefined if the import is not a named import (e.g., import * or default). + * + * TS: import { User, Repo as R } from './models' + * → [{local:'User', exported:'User'}, {local:'R', exported:'Repo'}] + * + * Python: from models import User, Repo as R + * → [{local:'User', exported:'User'}, {local:'R', exported:'Repo'}] + */ +export function extractNamedBindings( + importNode: any, + language: SupportedLanguages, +): { local: string; exported: string }[] | undefined { + if (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) { + return extractTsNamedBindings(importNode); + } + if (language === SupportedLanguages.Python) { + return extractPythonNamedBindings(importNode); + } + if (language === SupportedLanguages.Kotlin) { + return extractKotlinNamedBindings(importNode); + } + if (language === SupportedLanguages.Rust) { + return extractRustNamedBindings(importNode); + } + if (language === SupportedLanguages.PHP) { + return extractPhpNamedBindings(importNode); + } + if (language === SupportedLanguages.CSharp) { + return extractCsharpNamedBindings(importNode); + } + if (language === SupportedLanguages.Java) { + return extractJavaNamedBindings(importNode); + } + return undefined; +} + +export function extractTsNamedBindings(importNode: any): { local: string; exported: string }[] | undefined { + // import_statement > import_clause > named_imports > import_specifier* + const importClause = findChild(importNode, 'import_clause'); + if (importClause) { + const namedImports = findChild(importClause, 'named_imports'); + if (!namedImports) return undefined; // default import, namespace import, or side-effect + + const bindings: { local: string; exported: string }[] = []; + for (let i = 0; i < namedImports.namedChildCount; i++) { + const specifier = namedImports.namedChild(i); + if (specifier?.type !== 'import_specifier') continue; + + const identifiers: string[] = []; + for (let j = 0; j < specifier.namedChildCount; j++) { + const child = specifier.namedChild(j); + if (child?.type === 'identifier') identifiers.push(child.text); + } + + if (identifiers.length === 1) { + bindings.push({ local: identifiers[0], exported: identifiers[0] }); + } else if (identifiers.length === 2) { + // import { Foo as Bar } → exported='Foo', local='Bar' + bindings.push({ local: identifiers[1], exported: identifiers[0] }); + } + } + return bindings.length > 0 ? bindings : undefined; + } + + // Re-export: export { X } from './y' → export_statement > export_clause > export_specifier + const exportClause = findChild(importNode, 'export_clause'); + if (exportClause) { + const bindings: { local: string; exported: string }[] = []; + for (let i = 0; i < exportClause.namedChildCount; i++) { + const specifier = exportClause.namedChild(i); + if (specifier?.type !== 'export_specifier') continue; + + const identifiers: string[] = []; + for (let j = 0; j < specifier.namedChildCount; j++) { + const child = specifier.namedChild(j); + if (child?.type === 'identifier') identifiers.push(child.text); + } + + if (identifiers.length === 1) { + // export { User } from './base' → re-exports User as User + bindings.push({ local: identifiers[0], exported: identifiers[0] }); + } else if (identifiers.length === 2) { + // export { Repo as Repository } from './models' → name=Repo, alias=Repository + // For re-exports, the first id is the source name, second is what's exported + // When another file imports { Repository }, they get Repo from the source + bindings.push({ local: identifiers[1], exported: identifiers[0] }); + } + } + return bindings.length > 0 ? bindings : undefined; + } + + return undefined; +} + +export function extractPythonNamedBindings(importNode: any): { local: string; exported: string }[] | undefined { + // Only from import_from_statement, not plain import_statement + if (importNode.type !== 'import_from_statement') return undefined; + + const bindings: { local: string; exported: string }[] = []; + for (let i = 0; i < importNode.namedChildCount; i++) { + const child = importNode.namedChild(i); + if (!child) continue; + + if (child.type === 'dotted_name') { + // Skip the module_name (first dotted_name is the source module) + const fieldName = importNode.childForFieldName?.('module_name'); + if (fieldName && child.startIndex === fieldName.startIndex) continue; + + // This is an imported name: from x import User + const name = child.text; + if (name) bindings.push({ local: name, exported: name }); + } + + if (child.type === 'aliased_import') { + // from x import Repo as R + const dottedName = findChild(child, 'dotted_name'); + const aliasIdent = findChild(child, 'identifier'); + if (dottedName && aliasIdent) { + bindings.push({ local: aliasIdent.text, exported: dottedName.text }); + } + } + } + + return bindings.length > 0 ? bindings : undefined; +} + +export function extractKotlinNamedBindings(importNode: any): { local: string; exported: string }[] | undefined { + // import_header > identifier + import_alias > simple_identifier + if (importNode.type !== 'import_header') return undefined; + + const fullIdent = findChild(importNode, 'identifier'); + if (!fullIdent) return undefined; + + const fullText = fullIdent.text; + const exportedName = fullText.includes('.') ? fullText.split('.').pop()! : fullText; + + const importAlias = findChild(importNode, 'import_alias'); + if (importAlias) { + // Aliased: import com.example.User as U + const aliasIdent = findChild(importAlias, 'simple_identifier'); + if (!aliasIdent) return undefined; + return [{ local: aliasIdent.text, exported: exportedName }]; + } + + // Non-aliased: import com.example.User → local="User", exported="User" + // 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 + // with the same function name would collide in NamedImportMap, breaking + // arity-based disambiguation. + if (exportedName[0] && exportedName[0] === exportedName[0].toLowerCase()) return undefined; + return [{ local: exportedName, exported: exportedName }]; +} + +export function extractRustNamedBindings(importNode: any): { local: string; exported: string }[] | undefined { + // use_declaration may contain use_as_clause at any depth + if (importNode.type !== 'use_declaration') return undefined; + + const bindings: { local: string; exported: string }[] = []; + collectRustBindings(importNode, bindings); + return bindings.length > 0 ? bindings : undefined; +} + +function collectRustBindings(node: any, bindings: { local: string; exported: string }[]): void { + if (node.type === 'use_as_clause') { + // First identifier = exported name, second identifier = local alias + const idents: string[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'identifier') idents.push(child.text); + // For scoped_identifier, extract the last segment + if (child?.type === 'scoped_identifier') { + const nameNode = child.childForFieldName?.('name'); + if (nameNode) idents.push(nameNode.text); + } + } + if (idents.length === 2) { + bindings.push({ local: idents[1], exported: idents[0] }); + } + return; + } + + // Terminal identifier in a use_list: use crate::models::{User, Repo} + if (node.type === 'identifier' && node.parent?.type === 'use_list') { + bindings.push({ local: node.text, exported: node.text }); + return; + } + + // Skip scoped_identifier that serves as path prefix in scoped_use_list + // e.g. use crate::models::{User, Repo} — the path node "crate::models" is not an importable symbol + if (node.type === 'scoped_identifier' && node.parent?.type === 'scoped_use_list') { + return; // path prefix — the use_list sibling handles the actual symbols + } + + // Terminal scoped_identifier: use crate::models::User; + // Only extract if this is a leaf (no deeper use_list/use_as_clause/scoped_use_list) + if (node.type === 'scoped_identifier') { + let hasDeeper = false; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'use_list' || child?.type === 'use_as_clause' || child?.type === 'scoped_use_list') { + hasDeeper = true; + break; + } + } + if (!hasDeeper) { + const nameNode = node.childForFieldName?.('name'); + if (nameNode) { + bindings.push({ local: nameNode.text, exported: nameNode.text }); + } + return; + } + } + + // Recurse into children + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) collectRustBindings(child, bindings); + } +} + +export function extractPhpNamedBindings(importNode: any): { local: string; exported: string }[] | undefined { + // namespace_use_declaration > namespace_use_clause* (flat) + // namespace_use_declaration > namespace_use_group > namespace_use_clause* (grouped) + if (importNode.type !== 'namespace_use_declaration') return undefined; + + const bindings: { local: string; exported: string }[] = []; + + // Collect all clauses — from direct children AND from namespace_use_group + const clauses: any[] = []; + for (let i = 0; i < importNode.namedChildCount; i++) { + const child = importNode.namedChild(i); + if (child?.type === 'namespace_use_clause') { + clauses.push(child); + } else if (child?.type === 'namespace_use_group') { + for (let j = 0; j < child.namedChildCount; j++) { + const groupChild = child.namedChild(j); + if (groupChild?.type === 'namespace_use_clause') clauses.push(groupChild); + } + } + } + + for (const clause of clauses) { + // Flat imports: qualified_name + name (alias) + let qualifiedName: any = null; + const names: any[] = []; + for (let j = 0; j < clause.namedChildCount; j++) { + const child = clause.namedChild(j); + if (child?.type === 'qualified_name') qualifiedName = child; + else if (child?.type === 'name') names.push(child); + } + + if (qualifiedName && names.length > 0) { + // Flat aliased import: use App\Models\Repo as R; + const fullText = qualifiedName.text; + const exportedName = fullText.includes('\\') ? fullText.split('\\').pop()! : fullText; + bindings.push({ local: names[0].text, exported: exportedName }); + } else if (qualifiedName && names.length === 0) { + // Flat non-aliased import: use App\Models\User; + const fullText = qualifiedName.text; + const lastSegment = fullText.includes('\\') ? fullText.split('\\').pop()! : fullText; + bindings.push({ local: lastSegment, exported: lastSegment }); + } else if (!qualifiedName && names.length >= 2) { + // Grouped aliased import: {Repo as R} — first name = exported, second = alias + bindings.push({ local: names[1].text, exported: names[0].text }); + } else if (!qualifiedName && names.length === 1) { + // Grouped non-aliased import: {User} in use App\Models\{User, Repo as R} + bindings.push({ local: names[0].text, exported: names[0].text }); + } + } + return bindings.length > 0 ? bindings : undefined; +} + +export function extractCsharpNamedBindings(importNode: any): { local: string; exported: string }[] | undefined { + // using_directive with identifier (alias) + qualified_name (target) + if (importNode.type !== 'using_directive') return undefined; + + let aliasIdent: any = null; + let qualifiedName: any = null; + 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; + + const fullText = qualifiedName.text; + const exportedName = fullText.includes('.') ? fullText.split('.').pop()! : fullText; + + return [{ local: aliasIdent.text, exported: exportedName }]; +} + +export function extractJavaNamedBindings(importNode: any): { local: string; exported: string }[] | undefined { + // import_declaration > scoped_identifier "com.example.models.User" + // Wildcard imports (.*) don't produce named bindings + if (importNode.type !== 'import_declaration') return undefined; + + // Check for asterisk (wildcard import) — skip those + for (let i = 0; i < importNode.childCount; i++) { + const child = importNode.child(i); + if (child?.type === 'asterisk') return undefined; + } + + const scopedId = findChild(importNode, 'scoped_identifier'); + if (!scopedId) return undefined; + + const fullText = scopedId.text; + 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; + + return [{ local: className, exported: className }]; +} + +function findChild(node: any, type: string): any { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === type) return child; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index be5317fed3..26d5a6d512 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -5,7 +5,7 @@ import { LANGUAGE_QUERIES } from './tree-sitter-queries.js'; import { generateId } from '../../lib/utils.js'; import { SymbolTable } from './symbol-table.js'; import { ASTCache } from './ast-cache.js'; -import { getLanguageFromFilename, yieldToEventLoop, DEFINITION_CAPTURE_KEYS, getDefinitionNodeFromCaptures } from './utils.js'; +import { getLanguageFromFilename, yieldToEventLoop, DEFINITION_CAPTURE_KEYS, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature } from './utils.js'; import { isNodeExported } from './export-detection.js'; import { detectFrameworkFromAST } from './framework-detection.js'; import { WorkerPool } from './workers/worker-pool.js'; @@ -75,7 +75,10 @@ const processParsingWithWorkers = async ( } for (const sym of result.symbols) { - symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type); + symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type, { + parameterCount: sym.parameterCount, + ownerId: sym.ownerId, + }); } allImports.push(...result.imports); @@ -203,6 +206,11 @@ const processParsingSequential = async ( ? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300)) : null; + // Extract method signature for Method/Constructor nodes + const methodSig = (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') + ? extractMethodSignature(definitionNode) + : undefined; + const node: GraphNode = { id: nodeId, label: nodeLabel as any, @@ -217,12 +225,24 @@ const processParsingSequential = async ( astFrameworkMultiplier: frameworkHint.entryPointMultiplier, astFrameworkReason: frameworkHint.reason, } : {}), + ...(methodSig ? { + parameterCount: methodSig.parameterCount, + returnType: methodSig.returnType, + } : {}), }, }; graph.addNode(node); - symbolTable.add(file.path, nodeName, nodeId, nodeLabel); + // Compute enclosing class for Method/Constructor/Property/Function — used for both ownerId and HAS_METHOD + // Function is included because Kotlin/Rust/Python capture class methods as Function nodes + const needsOwner = nodeLabel === 'Method' || nodeLabel === 'Constructor' || nodeLabel === 'Property' || nodeLabel === 'Function'; + const enclosingClassId = needsOwner ? findEnclosingClassId(nameNode || definitionNodeForRange, file.path) : null; + + symbolTable.add(file.path, nodeName, nodeId, nodeLabel, { + parameterCount: methodSig?.parameterCount, + ownerId: enclosingClassId ?? undefined, + }); const fileId = generateId('File', file.path); @@ -238,6 +258,18 @@ const processParsingSequential = async ( }; graph.addRelationship(relationship); + + // ── HAS_METHOD: link method/constructor/property to enclosing class ── + if (enclosingClassId) { + graph.addRelationship({ + id: generateId('HAS_METHOD', `${enclosingClassId}->${nodeId}`), + sourceId: enclosingClassId, + targetId: nodeId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + } }); } }; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 18a0803cdc..489e18ccf5 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -1,9 +1,17 @@ import { createKnowledgeGraph } from '../graph/graph.js'; import { processStructure } from './structure-processor.js'; import { processParsing } from './parsing-processor.js'; -import { processImports, processImportsFromExtracted, createImportMap, buildImportResolutionContext } from './import-processor.js'; +import { + processImports, + processImportsFromExtracted, + createImportMap, + createPackageMap, + createNamedImportMap, + buildImportResolutionContext +} from './import-processor.js'; import { processCalls, processCallsFromExtracted, processRoutesFromExtracted } from './call-processor.js'; import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js'; +import { computeMRO } from './mro-processor.js'; import { processCommunities } from './community-processor.js'; import { processProcesses } from './process-processor.js'; import { createSymbolTable } from './symbol-table.js'; @@ -36,6 +44,8 @@ export const runPipelineFromRepo = async ( const symbolTable = createSymbolTable(); let astCache = createASTCache(AST_CACHE_CAP); const importMap = createImportMap(); + const packageMap = createPackageMap(); + const namedImportMap = createNamedImportMap(); const cleanup = () => { astCache.clear(); @@ -213,21 +223,36 @@ export const runPipelineFromRepo = async ( if (chunkWorkerData) { // Imports - await processImportsFromExtracted(graph, allPathObjects, chunkWorkerData.imports, importMap, undefined, repoPath, importCtx); - // Calls — resolve immediately, then free the array - if (chunkWorkerData.calls.length > 0) { - await processCallsFromExtracted(graph, chunkWorkerData.calls, symbolTable, importMap); - } - // Heritage — resolve immediately, then free - if (chunkWorkerData.heritage.length > 0) { - await processHeritageFromExtracted(graph, chunkWorkerData.heritage, symbolTable); - } - // Routes — resolve immediately (Laravel route→controller CALLS edges) - if (chunkWorkerData.routes && chunkWorkerData.routes.length > 0) { - await processRoutesFromExtracted(graph, chunkWorkerData.routes, symbolTable, importMap); - } + await processImportsFromExtracted(graph, allPathObjects, chunkWorkerData.imports, importMap, undefined, repoPath, importCtx, packageMap, namedImportMap); + // 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. + await Promise.all([ + processCallsFromExtracted( + graph, + chunkWorkerData.calls, + symbolTable, importMap, + packageMap, + undefined, + namedImportMap + ), + processHeritageFromExtracted( + graph, + chunkWorkerData.heritage, + symbolTable, + importMap, + packageMap + ), + processRoutesFromExtracted( + graph, + chunkWorkerData.routes ?? [], + symbolTable, + importMap, + packageMap + ), + ]); } else { - await processImports(graph, chunkFiles, astCache, importMap, undefined, repoPath, allPaths); + await processImports(graph, chunkFiles, astCache, importMap, undefined, repoPath, allPaths, packageMap, namedImportMap); sequentialChunkPaths.push(chunkPaths); } @@ -248,8 +273,8 @@ export const runPipelineFromRepo = async ( .filter(p => chunkContents.has(p)) .map(p => ({ path: p, content: chunkContents.get(p)! })); astCache = createASTCache(chunkFiles.length); - await processCalls(graph, chunkFiles, astCache, symbolTable, importMap); - await processHeritage(graph, chunkFiles, astCache, symbolTable); + await processCalls(graph, chunkFiles, astCache, symbolTable, importMap, packageMap, undefined, namedImportMap); + await processHeritage(graph, chunkFiles, astCache, symbolTable, importMap, packageMap); astCache.clear(); } @@ -260,12 +285,17 @@ export const runPipelineFromRepo = async ( (importCtx as any).suffixIndex = null; (importCtx as any).normalizedFileList = null; - if (isDev) { - let importsCount = 0; - for (const r of graph.iterRelationships()) { - if (r.type === 'IMPORTS') importsCount++; - } - console.log(`📊 Pipeline: graph has ${importsCount} IMPORTS, ${graph.relationshipCount} total relationships`); + // ── Phase 4.5: Method Resolution Order ────────────────────────────── + onProgress({ + phase: 'parsing', + percent: 81, + message: 'Computing method resolution order...', + stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, + }); + + const mroResult = computeMRO(graph); + if (isDev && mroResult.entries.length > 0) { + console.log(`🔀 MRO: ${mroResult.entries.length} classes analyzed, ${mroResult.ambiguityCount} ambiguities found, ${mroResult.overrideEdges} OVERRIDES edges`); } // ── Phase 5: Communities ─────────────────────────────────────────── diff --git a/gitnexus/src/core/ingestion/process-processor.ts b/gitnexus/src/core/ingestion/process-processor.ts index 4a26ffd046..ab77ee1873 100644 --- a/gitnexus/src/core/ingestion/process-processor.ts +++ b/gitnexus/src/core/ingestion/process-processor.ts @@ -13,6 +13,7 @@ import { KnowledgeGraph, GraphNode, GraphRelationship, NodeLabel } from '../graph/types.js'; import { CommunityMembership } from './community-processor.js'; import { calculateEntryPointScore, isTestFile } from './entry-point-scoring.js'; +import { SupportedLanguages } from '../../config/supported-languages.js'; const isDev = process.env.NODE_ENV === 'development'; @@ -287,7 +288,7 @@ const findEntryPoints = ( // Calculate entry point score using new scoring system const { score: baseScore, reasons } = calculateEntryPointScore( node.properties.name, - node.properties.language || 'javascript', + node.properties.language ?? SupportedLanguages.JavaScript, node.properties.isExported ?? false, callers.length, callees.length, diff --git a/gitnexus/src/core/ingestion/resolvers/csharp.ts b/gitnexus/src/core/ingestion/resolvers/csharp.ts new file mode 100644 index 0000000000..e916456ae3 --- /dev/null +++ b/gitnexus/src/core/ingestion/resolvers/csharp.ts @@ -0,0 +1,128 @@ +/** + * C# namespace import resolution. + * Handles using-directive resolution via .csproj root namespace stripping. + */ + +import type { SuffixIndex } from './utils.js'; +import { suffixResolve } from './utils.js'; + +/** C# project config parsed from .csproj files */ +export interface CSharpProjectConfig { + /** Root namespace from or assembly name (default: project directory name) */ + rootNamespace: string; + /** Directory containing the .csproj file */ + projectDir: string; +} + +/** + * Resolve a C# using-directive import path to matching .cs files. + * Tries single-file match first, then directory match for namespace imports. + */ +export function resolveCSharpImport( + importPath: string, + csharpConfigs: CSharpProjectConfig[], + normalizedFileList: string[], + allFileList: string[], + index?: SuffixIndex, +): string[] { + const namespacePath = importPath.replace(/\./g, '/'); + const results: string[] = []; + + for (const config of csharpConfigs) { + const nsPath = config.rootNamespace.replace(/\./g, '/'); + let relative: string; + if (namespacePath.startsWith(nsPath + '/')) { + relative = namespacePath.slice(nsPath.length + 1); + } else if (namespacePath === nsPath) { + // The import IS the root namespace — resolve to all .cs files in project root + relative = ''; + } else { + continue; + } + + const dirPrefix = config.projectDir + ? (relative ? config.projectDir + '/' + relative : config.projectDir) + : relative; + + // 1. Try as single file: relative.cs (e.g., "Models/DlqMessage.cs") + if (relative) { + const candidate = dirPrefix + '.cs'; + if (index) { + const result = index.get(candidate) || index.getInsensitive(candidate); + if (result) return [result]; + } + // Also try suffix match + const suffixResult = index?.get(relative + '.cs') || index?.getInsensitive(relative + '.cs'); + if (suffixResult) return [suffixResult]; + } + + // 2. Try as directory: all .cs files directly inside (namespace import) + if (index) { + const dirFiles = index.getFilesInDir(dirPrefix, '.cs'); + for (const f of dirFiles) { + const normalized = f.replace(/\\/g, '/'); + // Check it's a direct child by finding the dirPrefix and ensuring no deeper slashes + const prefixIdx = normalized.indexOf(dirPrefix + '/'); + if (prefixIdx < 0) continue; + const afterDir = normalized.substring(prefixIdx + dirPrefix.length + 1); + if (!afterDir.includes('/')) { + results.push(f); + } + } + if (results.length > 0) return results; + } + + // 3. Linear scan fallback for directory matching + if (results.length === 0) { + const dirTrail = dirPrefix + '/'; + for (let i = 0; i < normalizedFileList.length; i++) { + const normalized = normalizedFileList[i]; + if (!normalized.endsWith('.cs')) continue; + const prefixIdx = normalized.indexOf(dirTrail); + if (prefixIdx < 0) continue; + const afterDir = normalized.substring(prefixIdx + dirTrail.length); + if (!afterDir.includes('/')) { + results.push(allFileList[i]); + } + } + if (results.length > 0) return results; + } + } + + // Fallback: suffix matching without namespace stripping (single file) + const pathParts = namespacePath.split('/').filter(Boolean); + const fallback = suffixResolve(pathParts, normalizedFileList, allFileList, index); + return fallback ? [fallback] : []; +} + +/** + * Compute the directory suffix for a C# namespace import (for PackageMap). + * Returns a suffix like "/ProjectDir/Models/" or null if no config matches. + */ +export function resolveCSharpNamespaceDir( + importPath: string, + csharpConfigs: CSharpProjectConfig[], +): string | null { + const namespacePath = importPath.replace(/\./g, '/'); + + for (const config of csharpConfigs) { + const nsPath = config.rootNamespace.replace(/\./g, '/'); + let relative: string; + if (namespacePath.startsWith(nsPath + '/')) { + relative = namespacePath.slice(nsPath.length + 1); + } else if (namespacePath === nsPath) { + relative = ''; + } else { + continue; + } + + const dirPrefix = config.projectDir + ? (relative ? config.projectDir + '/' + relative : config.projectDir) + : relative; + + if (!dirPrefix) continue; + return '/' + dirPrefix + '/'; + } + + return null; +} diff --git a/gitnexus/src/core/ingestion/resolvers/go.ts b/gitnexus/src/core/ingestion/resolvers/go.ts new file mode 100644 index 0000000000..82f104b51a --- /dev/null +++ b/gitnexus/src/core/ingestion/resolvers/go.ts @@ -0,0 +1,58 @@ +/** + * Go package import resolution. + * Handles Go module path-based package imports. + */ + +/** Go module config parsed from go.mod */ +export interface GoModuleConfig { + /** Module path (e.g., "github.com/user/repo") */ + modulePath: string; +} + +/** + * Extract the package directory suffix from a Go import path. + * Returns the suffix string (e.g., "/internal/auth/") or null if invalid. + */ +export function resolveGoPackageDir( + importPath: string, + goModule: GoModuleConfig, +): string | null { + if (!importPath.startsWith(goModule.modulePath)) return null; + const relativePkg = importPath.slice(goModule.modulePath.length + 1); + if (!relativePkg) return null; + return '/' + relativePkg + '/'; +} + +/** + * Resolve a Go internal package import to all .go files in the package directory. + * Returns an array of file paths. + */ +export function resolveGoPackage( + importPath: string, + goModule: GoModuleConfig, + normalizedFileList: string[], + allFileList: string[], +): string[] { + if (!importPath.startsWith(goModule.modulePath)) return []; + + // Strip module path to get relative package path + const relativePkg = importPath.slice(goModule.modulePath.length + 1); // e.g., "internal/auth" + if (!relativePkg) return []; + + const pkgSuffix = '/' + relativePkg + '/'; + const matches: string[] = []; + + for (let i = 0; i < normalizedFileList.length; i++) { + // Prepend '/' so paths like "internal/auth/service.go" match suffix "/internal/auth/" + const normalized = '/' + normalizedFileList[i]; + // File must be directly in the package directory (not a subdirectory) + if (normalized.includes(pkgSuffix) && normalized.endsWith('.go') && !normalized.endsWith('_test.go')) { + const afterPkg = normalized.substring(normalized.indexOf(pkgSuffix) + pkgSuffix.length); + if (!afterPkg.includes('/')) { + matches.push(allFileList[i]); + } + } + } + + return matches; +} diff --git a/gitnexus/src/core/ingestion/resolvers/index.ts b/gitnexus/src/core/ingestion/resolvers/index.ts new file mode 100644 index 0000000000..693ad09a22 --- /dev/null +++ b/gitnexus/src/core/ingestion/resolvers/index.ts @@ -0,0 +1,23 @@ +/** + * Language-specific import resolvers. + * Extracted from import-processor.ts for maintainability. + */ + +export { EXTENSIONS, tryResolveWithExtensions, buildSuffixIndex, suffixResolve } from './utils.js'; +export type { SuffixIndex } from './utils.js'; + +export { KOTLIN_EXTENSIONS, appendKotlinWildcard, resolveJvmWildcard, resolveJvmMemberImport } from './jvm.js'; + +export { resolveGoPackageDir, resolveGoPackage } from './go.js'; +export type { GoModuleConfig } from './go.js'; + +export { resolveCSharpImport, resolveCSharpNamespaceDir } from './csharp.js'; +export type { CSharpProjectConfig } from './csharp.js'; + +export { resolvePhpImport } from './php.js'; +export type { ComposerConfig } from './php.js'; + +export { resolveRustImport, tryRustModulePath } from './rust.js'; + +export { resolveImportPath, RESOLVE_CACHE_CAP } from './standard.js'; +export type { TsconfigPaths } from './standard.js'; diff --git a/gitnexus/src/core/ingestion/resolvers/jvm.ts b/gitnexus/src/core/ingestion/resolvers/jvm.ts new file mode 100644 index 0000000000..5b532e47e8 --- /dev/null +++ b/gitnexus/src/core/ingestion/resolvers/jvm.ts @@ -0,0 +1,106 @@ +/** + * JVM import resolution (Java + Kotlin). + * Handles wildcard imports, member/static imports, and Kotlin-specific patterns. + */ + +import type { SuffixIndex } from './utils.js'; + +/** Kotlin file extensions for JVM resolver reuse */ +export const KOTLIN_EXTENSIONS: readonly string[] = ['.kt', '.kts']; + +/** + * Append .* to a Kotlin import path if the AST has a wildcard_import sibling node. + * Pure function — returns a new string without mutating the input. + */ +export const appendKotlinWildcard = (importPath: string, importNode: any): string => { + for (let i = 0; i < importNode.childCount; i++) { + if (importNode.child(i)?.type === 'wildcard_import') { + return importPath.endsWith('.*') ? importPath : `${importPath}.*`; + } + } + return importPath; +}; + +/** + * Resolve a JVM wildcard import (com.example.*) to all matching files. + * Works for both Java (.java) and Kotlin (.kt, .kts). + */ +export function resolveJvmWildcard( + importPath: string, + normalizedFileList: string[], + allFileList: string[], + extensions: readonly string[], + index?: SuffixIndex, +): string[] { + // "com.example.util.*" -> "com/example/util" + const packagePath = importPath.slice(0, -2).replace(/\./g, '/'); + + if (index) { + const candidates = extensions.flatMap(ext => index.getFilesInDir(packagePath, ext)); + // Filter to only direct children (no subdirectories) + const packageSuffix = '/' + 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); + return !afterPkg.includes('/'); + }); + } + + // Fallback: linear scan + const packageSuffix = '/' + 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]); + } + } + } + return matches; +} + +/** + * Try to resolve a JVM member/static import by stripping the member name. + * Java: "com.example.Constants.VALUE" -> resolve "com.example.Constants" + * Kotlin: "com.example.Constants.VALUE" -> resolve "com.example.Constants" + */ +export function resolveJvmMemberImport( + importPath: string, + normalizedFileList: string[], + allFileList: string[], + extensions: readonly string[], + index?: SuffixIndex, +): string | null { + // Member imports: com.example.Constants.VALUE or com.example.Constants.* + // The last segment is a member name if it starts with lowercase, is ALL_CAPS, or is a wildcard + const segments = importPath.split('.'); + if (segments.length < 3) return null; + + const lastSeg = segments[segments.length - 1]; + if (lastSeg === '*' || /^[a-z]/.test(lastSeg) || /^[A-Z_]+$/.test(lastSeg)) { + const classPath = segments.slice(0, -1).join('/'); + + for (const ext of extensions) { + const classSuffix = classPath + ext; + if (index) { + const result = index.get(classSuffix) || index.getInsensitive(classSuffix); + if (result) return result; + } else { + const fullSuffix = '/' + classSuffix; + for (let i = 0; i < normalizedFileList.length; i++) { + if (normalizedFileList[i].endsWith(fullSuffix) || + normalizedFileList[i].toLowerCase().endsWith(fullSuffix.toLowerCase())) { + return allFileList[i]; + } + } + } + } + } + + return null; +} diff --git a/gitnexus/src/core/ingestion/resolvers/php.ts b/gitnexus/src/core/ingestion/resolvers/php.ts new file mode 100644 index 0000000000..c05ef4e6d3 --- /dev/null +++ b/gitnexus/src/core/ingestion/resolvers/php.ts @@ -0,0 +1,51 @@ +/** + * PHP PSR-4 import resolution. + * Handles use-statement resolution via composer.json autoload mappings. + */ + +import type { SuffixIndex } from './utils.js'; +import { suffixResolve } from './utils.js'; + +/** PHP Composer PSR-4 autoload config */ +export interface ComposerConfig { + /** Map of namespace prefix -> directory (e.g., "App\\" -> "app/") */ + psr4: Map; +} + +/** + * Resolve a PHP use-statement import path using PSR-4 mappings. + * e.g. "App\Http\Controllers\UserController" -> "app/Http/Controllers/UserController.php" + */ +export function resolvePhpImport( + importPath: string, + composerConfig: ComposerConfig | null, + allFiles: Set, + normalizedFileList: string[], + allFileList: string[], + index?: SuffixIndex, +): string | null { + // Normalize: replace backslashes with forward slashes + const normalized = importPath.replace(/\\/g, '/'); + + // Try PSR-4 resolution if composer.json was found + if (composerConfig) { + // Sort namespaces by length descending (longest match wins) + const sorted = [...composerConfig.psr4.entries()].sort((a, b) => b[0].length - a[0].length); + for (const [nsPrefix, dirPrefix] of sorted) { + const nsPrefixSlash = nsPrefix.replace(/\\/g, '/'); + if (normalized.startsWith(nsPrefixSlash + '/') || normalized === nsPrefixSlash) { + const remainder = normalized.slice(nsPrefixSlash.length).replace(/^\//, ''); + const filePath = dirPrefix + (remainder ? '/' + remainder : '') + '.php'; + if (allFiles.has(filePath)) return filePath; + if (index) { + const result = index.getInsensitive(filePath); + if (result) return result; + } + } + } + } + + // Fallback: suffix matching (works without composer.json) + const pathParts = normalized.split('/').filter(Boolean); + return suffixResolve(pathParts, normalizedFileList, allFileList, index); +} diff --git a/gitnexus/src/core/ingestion/resolvers/rust.ts b/gitnexus/src/core/ingestion/resolvers/rust.ts new file mode 100644 index 0000000000..cc62c633f3 --- /dev/null +++ b/gitnexus/src/core/ingestion/resolvers/rust.ts @@ -0,0 +1,82 @@ +/** + * Rust module import resolution. + * Handles crate::, super::, self:: prefix paths and :: separators. + */ + +/** + * Resolve Rust use-path to a file. + * Handles crate::, super::, self:: prefixes and :: path separators. + */ +export function resolveRustImport( + currentFile: string, + importPath: string, + allFiles: Set, +): string | null { + let rustPath: string; + + if (importPath.startsWith('crate::')) { + // crate:: resolves from src/ directory (standard Rust layout) + rustPath = importPath.slice(7).replace(/::/g, '/'); + + // Try from src/ (standard layout) + const fromSrc = tryRustModulePath('src/' + rustPath, allFiles); + if (fromSrc) return fromSrc; + + // Try from repo root (non-standard) + const fromRoot = tryRustModulePath(rustPath, allFiles); + if (fromRoot) return fromRoot; + + return null; + } + + if (importPath.startsWith('super::')) { + // super:: = parent directory of current file's module + const currentDir = currentFile.split('/').slice(0, -1); + currentDir.pop(); // Go up one level for super:: + rustPath = importPath.slice(7).replace(/::/g, '/'); + const fullPath = [...currentDir, rustPath].join('/'); + return tryRustModulePath(fullPath, allFiles); + } + + if (importPath.startsWith('self::')) { + // self:: = current module's directory + const currentDir = currentFile.split('/').slice(0, -1); + rustPath = importPath.slice(6).replace(/::/g, '/'); + const fullPath = [...currentDir, rustPath].join('/'); + return tryRustModulePath(fullPath, allFiles); + } + + // Bare path without prefix (e.g., from a use in a nested module) + // Convert :: to / and try suffix matching + if (importPath.includes('::')) { + rustPath = importPath.replace(/::/g, '/'); + return tryRustModulePath(rustPath, allFiles); + } + + return null; +} + +/** + * Try to resolve a Rust module path to a file. + * Tries: path.rs, path/mod.rs, and with the last segment stripped + * (last segment might be a symbol name, not a module). + */ +export function tryRustModulePath(modulePath: string, allFiles: Set): string | null { + // Try direct: path.rs + if (allFiles.has(modulePath + '.rs')) return modulePath + '.rs'; + // Try directory: path/mod.rs + if (allFiles.has(modulePath + '/mod.rs')) return modulePath + '/mod.rs'; + // Try path/lib.rs (for crate root) + if (allFiles.has(modulePath + '/lib.rs')) return modulePath + '/lib.rs'; + + // The last segment might be a symbol (function, struct, etc.), not a module. + // Strip it and try again. + const lastSlash = modulePath.lastIndexOf('/'); + if (lastSlash > 0) { + const parentPath = modulePath.substring(0, lastSlash); + if (allFiles.has(parentPath + '.rs')) return parentPath + '.rs'; + if (allFiles.has(parentPath + '/mod.rs')) return parentPath + '/mod.rs'; + } + + return null; +} diff --git a/gitnexus/src/core/ingestion/resolvers/standard.ts b/gitnexus/src/core/ingestion/resolvers/standard.ts new file mode 100644 index 0000000000..332ae04857 --- /dev/null +++ b/gitnexus/src/core/ingestion/resolvers/standard.ts @@ -0,0 +1,177 @@ +/** + * Standard import path resolution. + * Handles relative imports, path alias rewriting, and generic suffix matching. + * Used as the fallback when language-specific resolvers don't match. + */ + +import type { SuffixIndex } from './utils.js'; +import { tryResolveWithExtensions, suffixResolve } from './utils.js'; +import { resolveRustImport } from './rust.js'; +import { SupportedLanguages } from '../../../config/supported-languages.js'; + +/** TypeScript path alias config parsed from tsconfig.json */ +export interface TsconfigPaths { + /** Map of alias prefix -> target prefix (e.g., "@/" -> "src/") */ + aliases: Map; + /** Base URL for path resolution (relative to repo root) */ + baseUrl: string; +} + +/** Max entries in the resolve cache. Beyond this, entries are evicted. + * 100K entries ≈ 15MB — covers the most common import patterns. */ +export const RESOLVE_CACHE_CAP = 100_000; + +/** + * Resolve an import path to a file path in the repository. + * + * Language-specific preprocessing is applied before the generic resolution: + * - TypeScript/JavaScript: rewrites tsconfig path aliases + * - Rust: converts crate::/super::/self:: to relative paths + * + * Java wildcards and Go package imports are handled separately in processImports + * because they resolve to multiple files. + */ +export const resolveImportPath = ( + currentFile: string, + importPath: string, + allFiles: Set, + allFileList: string[], + normalizedFileList: string[], + resolveCache: Map, + language: SupportedLanguages, + tsconfigPaths: TsconfigPaths | null, + index?: SuffixIndex, +): string | null => { + const cacheKey = `${currentFile}::${importPath}`; + if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey) ?? null; + + const cache = (result: string | null): string | null => { + // Evict oldest 20% when cap is reached instead of clearing all + if (resolveCache.size >= RESOLVE_CACHE_CAP) { + const evictCount = Math.floor(RESOLVE_CACHE_CAP * 0.2); + const iter = resolveCache.keys(); + for (let i = 0; i < evictCount; i++) { + const key = iter.next().value; + if (key !== undefined) resolveCache.delete(key); + } + } + resolveCache.set(cacheKey, result); + return result; + }; + + // ---- TypeScript/JavaScript: rewrite path aliases ---- + if ( + (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) && + tsconfigPaths && + !importPath.startsWith('.') + ) { + for (const [aliasPrefix, targetPrefix] of tsconfigPaths.aliases) { + if (importPath.startsWith(aliasPrefix)) { + const remainder = importPath.slice(aliasPrefix.length); + // Build the rewritten path relative to baseUrl + const rewritten = tsconfigPaths.baseUrl === '.' + ? targetPrefix + remainder + : tsconfigPaths.baseUrl + '/' + targetPrefix + remainder; + + // Try direct resolution from repo root + const resolved = tryResolveWithExtensions(rewritten, allFiles); + if (resolved) return cache(resolved); + + // Try suffix matching as fallback + const parts = rewritten.split('/').filter(Boolean); + const suffixResult = suffixResolve(parts, normalizedFileList, allFileList, index); + if (suffixResult) return cache(suffixResult); + } + } + } + + // ---- Rust: convert module path syntax to file paths ---- + if (language === SupportedLanguages.Rust) { + // Handle grouped imports: use crate::module::{Foo, Bar, Baz} + // Extract the prefix path before ::{...} and resolve the module, not the symbols + let rustImportPath = importPath; + const braceIdx = importPath.indexOf('::{'); + if (braceIdx !== -1) { + rustImportPath = importPath.substring(0, braceIdx); + } else if (importPath.startsWith('{') && importPath.endsWith('}')) { + // Top-level grouped imports: use {crate::a, crate::b} + // Iterate each part and return the first that resolves. This function returns a single + // string, so callers that need ALL edges must intercept before reaching here (see the + // Rust grouped-import blocks in processImports / processImportsBatch). This fallback + // handles any path that reaches resolveImportPath directly. + const inner = importPath.slice(1, -1); + const parts = inner.split(',').map(p => p.trim()).filter(Boolean); + for (const part of parts) { + const partResult = resolveRustImport(currentFile, part, allFiles); + if (partResult) return cache(partResult); + } + return cache(null); + } + + const rustResult = resolveRustImport(currentFile, rustImportPath, allFiles); + if (rustResult) return cache(rustResult); + // Fall through to generic resolution if Rust-specific didn't match + } + + // ---- Python relative imports (PEP 328): .module, ..module, ... ---- + if (language === SupportedLanguages.Python && importPath.startsWith('.')) { + const dotMatch = importPath.match(/^(\.+)(.*)/); + if (dotMatch) { + const dotCount = dotMatch[1].length; + const modulePart = dotMatch[2]; // e.g., "models" from ".models" + const dirParts = currentFile.split('/').slice(0, -1); // remove filename + + // Navigate up: 1 dot = same package, 2 dots = parent package, etc. + // First dot means "current package", each additional dot goes up one level + for (let i = 1; i < dotCount; i++) { + dirParts.pop(); + } + + if (modulePart) { + // from .models import User → resolve "models" relative to current package + const modulePath = modulePart.replace(/\./g, '/'); + dirParts.push(...modulePath.split('/')); + } + + const basePath = dirParts.join('/'); + const resolved = tryResolveWithExtensions(basePath, allFiles); + return cache(resolved); + } + } + + // ---- Generic relative import resolution (./ and ../) ---- + const currentDir = currentFile.split('/').slice(0, -1); + const parts = importPath.split('/'); + + for (const part of parts) { + if (part === '.') continue; + if (part === '..') { + currentDir.pop(); + } else { + currentDir.push(part); + } + } + + const basePath = currentDir.join('/'); + + if (importPath.startsWith('.')) { + const resolved = tryResolveWithExtensions(basePath, allFiles); + return cache(resolved); + } + + // ---- Generic package/absolute import resolution (suffix matching) ---- + // Java wildcards are handled in processImports, not here + if (importPath.endsWith('.*')) { + return cache(null); + } + + // C/C++ includes use actual file paths (e.g. "animal.h") — don't convert dots to slashes + const isCpp = language === SupportedLanguages.C || language === SupportedLanguages.CPlusPlus; + const pathLike = importPath.includes('/') || isCpp + ? importPath + : importPath.replace(/\./g, '/'); + const pathParts = pathLike.split('/').filter(Boolean); + + const resolved = suffixResolve(pathParts, normalizedFileList, allFileList, index); + return cache(resolved); +}; diff --git a/gitnexus/src/core/ingestion/resolvers/utils.ts b/gitnexus/src/core/ingestion/resolvers/utils.ts new file mode 100644 index 0000000000..e68116e2d1 --- /dev/null +++ b/gitnexus/src/core/ingestion/resolvers/utils.ts @@ -0,0 +1,156 @@ +/** + * Shared utilities for import resolution. + * Extracted from import-processor.ts to reduce file size. + */ + +/** All file extensions to try during resolution */ +export const EXTENSIONS = [ + '', + // TypeScript/JavaScript + '.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts', '/index.jsx', '/index.js', + // Python + '.py', '/__init__.py', + // Java + '.java', + // Kotlin + '.kt', '.kts', + // C/C++ + '.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hxx', '.hh', + // C# + '.cs', + // Go + '.go', + // Rust + '.rs', '/mod.rs', + // PHP + '.php', '.phtml', + // Swift + '.swift', +]; + +/** + * Try to match a path (with extensions) against the known file set. + * Returns the matched file path or null. + */ +export function tryResolveWithExtensions( + basePath: string, + allFiles: Set, +): string | null { + for (const ext of EXTENSIONS) { + const candidate = basePath + ext; + if (allFiles.has(candidate)) return candidate; + } + return null; +} + +/** + * Build a suffix index for O(1) endsWith lookups. + * Maps every possible path suffix to its original file path. + * e.g. for "src/com/example/Foo.java": + * "Foo.java" -> "src/com/example/Foo.java" + * "example/Foo.java" -> "src/com/example/Foo.java" + * "com/example/Foo.java" -> "src/com/example/Foo.java" + * etc. + */ +export interface SuffixIndex { + /** Exact suffix lookup (case-sensitive) */ + get(suffix: string): string | undefined; + /** Case-insensitive suffix lookup */ + getInsensitive(suffix: string): string | undefined; + /** Get all files in a directory suffix */ + getFilesInDir(dirSuffix: string, extension: string): string[]; +} + +export function buildSuffixIndex(normalizedFileList: string[], allFileList: string[]): SuffixIndex { + // Map: normalized suffix -> original file path + const exactMap = new Map(); + // Map: lowercase suffix -> original file path + const lowerMap = new Map(); + // Map: directory suffix -> list of file paths in that directory + const dirMap = new Map(); + + for (let i = 0; i < normalizedFileList.length; i++) { + const normalized = normalizedFileList[i]; + const original = allFileList[i]; + const parts = normalized.split('/'); + + // Index all suffixes: "a/b/c.java" -> ["c.java", "b/c.java", "a/b/c.java"] + for (let j = parts.length - 1; j >= 0; j--) { + const suffix = parts.slice(j).join('/'); + // Only store first match (longest path wins for ambiguous suffixes) + if (!exactMap.has(suffix)) { + exactMap.set(suffix, original); + } + const lower = suffix.toLowerCase(); + if (!lowerMap.has(lower)) { + lowerMap.set(lower, original); + } + } + + // Index directory membership + const lastSlash = normalized.lastIndexOf('/'); + if (lastSlash >= 0) { + // Build all directory suffixes + const dirParts = parts.slice(0, -1); + const fileName = parts[parts.length - 1]; + const ext = fileName.substring(fileName.lastIndexOf('.')); + + for (let j = dirParts.length - 1; j >= 0; j--) { + const dirSuffix = dirParts.slice(j).join('/'); + const key = `${dirSuffix}:${ext}`; + let list = dirMap.get(key); + if (!list) { + list = []; + dirMap.set(key, list); + } + list.push(original); + } + } + } + + return { + get: (suffix: string) => exactMap.get(suffix), + getInsensitive: (suffix: string) => lowerMap.get(suffix.toLowerCase()), + getFilesInDir: (dirSuffix: string, extension: string) => { + return dirMap.get(`${dirSuffix}:${extension}`) || []; + }, + }; +} + +/** + * Suffix-based resolution using index. O(1) per lookup instead of O(files). + */ +export function suffixResolve( + pathParts: string[], + normalizedFileList: string[], + allFileList: string[], + index?: SuffixIndex, +): string | null { + if (index) { + for (let i = 0; i < pathParts.length; i++) { + const suffix = pathParts.slice(i).join('/'); + for (const ext of EXTENSIONS) { + const suffixWithExt = suffix + ext; + const result = index.get(suffixWithExt) || index.getInsensitive(suffixWithExt); + if (result) return result; + } + } + return null; + } + + // Fallback: linear scan (for backward compatibility) + for (let i = 0; i < pathParts.length; i++) { + const suffix = pathParts.slice(i).join('/'); + for (const ext of EXTENSIONS) { + const suffixWithExt = suffix + ext; + const suffixPattern = '/' + suffixWithExt; + const matchIdx = normalizedFileList.findIndex(filePath => + filePath.endsWith(suffixPattern) || filePath.toLowerCase().endsWith(suffixPattern.toLowerCase()) + ); + if (matchIdx !== -1) { + return allFileList[matchIdx]; + } + } + } + return null; +} diff --git a/gitnexus/src/core/ingestion/symbol-resolver.ts b/gitnexus/src/core/ingestion/symbol-resolver.ts new file mode 100644 index 0000000000..2763b8eee7 --- /dev/null +++ b/gitnexus/src/core/ingestion/symbol-resolver.ts @@ -0,0 +1,123 @@ +/** + * Symbol Resolver + * + * Import-filtered candidate narrowing for bare identifier resolution. + * NOT FQN resolution — does not parse qualifiers (ns::Bar, com.foo.Bar). + * + * Shared between heritage-processor.ts and call-processor.ts. + */ + +import type { SymbolTable, SymbolDefinition } from './symbol-table.js'; +import type { ImportMap, PackageMap, NamedImportMap } from './import-processor.js'; +import { isFileInPackageDir } from './import-processor.js'; +import { walkBindingChain } from './named-binding-extraction.js'; + +/** Resolution tier for internal tracking, logging, and test assertions. */ +export type ResolutionTier = 'same-file' | 'import-scoped' | 'unique-global'; + +/** Internal resolution result preserving tier metadata. */ +export interface InternalResolution { + definition: SymbolDefinition; + tier: ResolutionTier; + candidateCount: number; +} + +/** + * Resolve a bare identifier to its best-matching definition using import context. + * + * Resolution tiers (highest confidence first): + * 1. Same file (lookupExactFull — authoritative) + * 2. Import-scoped (lookupFuzzy filtered by importMap — acceptable) + * 3. Unique global (lookupFuzzy with exactly 1 match — acceptable fallback) + * + * If multiple global candidates remain after filtering, returns null. + * A wrong edge is worse than no edge. + */ +export const resolveSymbol = ( + name: string, + currentFilePath: string, + symbolTable: SymbolTable, + importMap: ImportMap, + packageMap?: PackageMap, + namedImportMap?: NamedImportMap, +): SymbolDefinition | null => { + return resolveSymbolInternal(name, currentFilePath, symbolTable, importMap, packageMap, namedImportMap)?.definition ?? null; +}; + +/** Internal resolver preserving tier metadata for logging and test assertions. */ +export const resolveSymbolInternal = ( + name: string, + currentFilePath: string, + symbolTable: SymbolTable, + importMap: ImportMap, + packageMap?: PackageMap, + namedImportMap?: NamedImportMap, +): InternalResolution | null => { + // Tier 1: Same file — authoritative match + const localDef = symbolTable.lookupExactFull(currentFilePath, name); + if (localDef) return { definition: localDef, tier: 'same-file', candidateCount: 1 }; + + // Get all global definitions for subsequent tiers + const allDefs = symbolTable.lookupFuzzy(name); + + // Tier 2a-named: Check named bindings BEFORE the empty-allDefs early return, + // because aliased imports (import { User as U }) mean lookupFuzzy('U') returns + // empty but we can resolve via the exported name. + if (namedImportMap) { + const result = resolveNamedBindingChain(name, currentFilePath, symbolTable, namedImportMap, allDefs); + if (result) return result; + } + + if (allDefs.length === 0) return null; + + // Tier 2a: Import-scoped — check if any definition is in a file imported by currentFile + const importedFiles = importMap.get(currentFilePath); + if (importedFiles) { + for (const def of allDefs) { + if (importedFiles.has(def.filePath)) { + return { definition: def, tier: 'import-scoped', candidateCount: allDefs.length }; + } + } + } + + // Tier 2b: Package-scoped — check if any definition is in a package/namespace dir imported by currentFile + // Used for Go packages and C# namespace imports to avoid ImportMap expansion bloat + const importedPackages = packageMap?.get(currentFilePath); + if (importedPackages) { + for (const def of allDefs) { + for (const dirSuffix of importedPackages) { + if (isFileInPackageDir(def.filePath, dirSuffix)) { + return { definition: def, tier: 'import-scoped', candidateCount: allDefs.length }; + } + } + } + } + + // Tier 3: Unique global — ONLY if exactly one candidate exists + // Ambiguous global matches are refused. A wrong edge is worse than no edge. + if (allDefs.length === 1) { + return { definition: allDefs[0], tier: 'unique-global', candidateCount: 1 }; + } + + // Ambiguous: multiple global candidates, no import or same-file match → refuse + return null; +}; + +/** + * Follow re-export chains through NamedImportMap. + * Delegates chain-walking to the shared walkBindingChain utility, then + * applies symbol-resolver semantics: exactly one match required. + */ +const resolveNamedBindingChain = ( + name: string, + currentFilePath: string, + symbolTable: SymbolTable, + namedImportMap: NamedImportMap, + allDefs: SymbolDefinition[], +): InternalResolution | null => { + const defs = walkBindingChain(name, currentFilePath, symbolTable, namedImportMap, allDefs); + if (defs?.length === 1) { + return { definition: defs[0], tier: 'import-scoped', candidateCount: defs.length }; + } + return null; +}; diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts index c8c35d56f9..3dbfc93ef6 100644 --- a/gitnexus/src/core/ingestion/symbol-table.ts +++ b/gitnexus/src/core/ingestion/symbol-table.ts @@ -2,13 +2,22 @@ export interface SymbolDefinition { nodeId: string; filePath: string; type: string; // 'Function', 'Class', etc. + parameterCount?: number; + /** Links Method/Constructor to owning Class/Struct/Trait nodeId */ + ownerId?: string; } export interface SymbolTable { /** * Register a new symbol definition */ - add: (filePath: string, name: string, nodeId: string, type: string) => void; + add: ( + filePath: string, + name: string, + nodeId: string, + type: string, + metadata?: { parameterCount?: number; ownerId?: string } + ) => void; /** * High Confidence: Look for a symbol specifically inside a file @@ -16,6 +25,12 @@ export interface SymbolTable { */ lookupExact: (filePath: string, name: string) => string | undefined; + /** + * High Confidence: Look for a symbol in a specific file, returning full definition. + * Includes type information needed for heritage resolution (Class vs Interface). + */ + lookupExactFull: (filePath: string, name: string) => SymbolDefinition | undefined; + /** * Low Confidence: Look for a symbol anywhere in the project * Used when imports are missing or for framework magic @@ -34,32 +49,48 @@ export interface SymbolTable { } export const createSymbolTable = (): SymbolTable => { - // 1. File-Specific Index (The "Good" one) - // Structure: FilePath -> (SymbolName -> NodeID) - const fileIndex = new Map>(); + // 1. File-Specific Index — stores full SymbolDefinition for O(1) lookupExactFull + // Structure: FilePath -> (SymbolName -> SymbolDefinition) + const fileIndex = new Map>(); // 2. Global Reverse Index (The "Backup") // Structure: SymbolName -> [List of Definitions] const globalIndex = new Map(); - const add = (filePath: string, name: string, nodeId: string, type: string) => { - // A. Add to File Index + const add = ( + filePath: string, + name: string, + nodeId: string, + type: string, + metadata?: { parameterCount?: number; ownerId?: string } + ) => { + const def: SymbolDefinition = { + nodeId, + filePath, + type, + ...(metadata?.parameterCount !== undefined ? { parameterCount: metadata.parameterCount } : {}), + ...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}), + }; + + // A. Add to File Index (shared reference — zero additional memory) if (!fileIndex.has(filePath)) { fileIndex.set(filePath, new Map()); } - fileIndex.get(filePath)!.set(name, nodeId); + fileIndex.get(filePath)!.set(name, def); - // B. Add to Global Index + // B. Add to Global Index (same object reference) if (!globalIndex.has(name)) { globalIndex.set(name, []); } - globalIndex.get(name)!.push({ nodeId, filePath, type }); + globalIndex.get(name)!.push(def); }; const lookupExact = (filePath: string, name: string): string | undefined => { - const fileSymbols = fileIndex.get(filePath); - if (!fileSymbols) return undefined; - return fileSymbols.get(name); + return fileIndex.get(filePath)?.get(name)?.nodeId; + }; + + const lookupExactFull = (filePath: string, name: string): SymbolDefinition | undefined => { + return fileIndex.get(filePath)?.get(name); }; const lookupFuzzy = (name: string): SymbolDefinition[] => { @@ -76,5 +107,5 @@ export const createSymbolTable = (): SymbolTable => { globalIndex.clear(); }; - return { add, lookupExact, lookupFuzzy, getStats, clear }; -}; \ No newline at end of file + return { add, lookupExact, lookupExactFull, lookupFuzzy, getStats, clear }; +}; diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index e5300b1494..4a8d18707f 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -47,6 +47,10 @@ export const TYPESCRIPT_QUERIES = ` (import_statement source: (string) @import.source) @import +; Re-export statements: export { X } from './y' +(export_statement + source: (string) @import.source) @import + (call_expression function: (identifier) @call.name) @call @@ -54,6 +58,10 @@ export const TYPESCRIPT_QUERIES = ` function: (member_expression property: (property_identifier) @call.name)) @call +; Constructor calls: new Foo() +(new_expression + constructor: (identifier) @call.name) @call + ; Heritage queries - class extends (class_declaration name: (type_identifier) @heritage.class @@ -105,6 +113,10 @@ export const JAVASCRIPT_QUERIES = ` (import_statement source: (string) @import.source) @import +; Re-export statements: export { X } from './y' +(export_statement + source: (string) @import.source) @import + (call_expression function: (identifier) @call.name) @call @@ -112,6 +124,10 @@ export const JAVASCRIPT_QUERIES = ` function: (member_expression property: (property_identifier) @call.name)) @call +; Constructor calls: new Foo() +(new_expression + constructor: (identifier) @call.name) @call + ; Heritage queries - class extends (JavaScript uses different AST than TypeScript) ; In tree-sitter-javascript, class_heritage directly contains the parent identifier (class_declaration @@ -134,6 +150,9 @@ export const PYTHON_QUERIES = ` (import_from_statement module_name: (dotted_name) @import.source) @import +(import_from_statement + module_name: (relative_import) @import.source) @import + (call function: (identifier) @call.name) @call @@ -167,6 +186,9 @@ export const JAVA_QUERIES = ` (method_invocation name: (identifier) @call.name) @call (method_invocation object: (_) name: (identifier) @call.name) @call +; Constructor calls: new Foo() +(object_creation_expression type: (type_identifier) @call.name) @call + ; Heritage - extends class (class_declaration name: (identifier) @heritage.class (superclass (type_identifier) @heritage.extends)) @heritage @@ -216,15 +238,26 @@ export const GO_QUERIES = ` ; Types (type_declaration (type_spec name: (type_identifier) @name type: (struct_type))) @definition.struct (type_declaration (type_spec name: (type_identifier) @name type: (interface_type))) @definition.interface -(type_declaration (type_spec name: (type_identifier) @name)) @definition.type ; Imports (import_declaration (import_spec path: (interpreted_string_literal) @import.source)) @import (import_declaration (import_spec_list (import_spec path: (interpreted_string_literal) @import.source))) @import +; Struct embedding (anonymous fields = inheritance) +(type_declaration + (type_spec + name: (type_identifier) @heritage.class + type: (struct_type + (field_declaration_list + (field_declaration + type: (type_identifier) @heritage.extends))))) @definition.struct + ; Calls (call_expression function: (identifier) @call.name) @call (call_expression function: (selector_expression field: (field_identifier) @call.name)) @call + +; Struct literal construction: User{Name: "Alice"} +(composite_literal type: (type_identifier) @call.name) @call `; // C++ queries - works with tree-sitter-cpp @@ -288,6 +321,9 @@ export const CPP_QUERIES = ` (call_expression function: (qualified_identifier name: (identifier) @call.name)) @call (call_expression function: (template_function name: (identifier) @call.name)) @call +; Constructor calls: new User() +(new_expression type: (type_identifier) @call.name) @call + ; Heritage (class_specifier name: (type_identifier) @heritage.class (base_class_clause (type_identifier) @heritage.extends)) @heritage @@ -317,6 +353,10 @@ export const CSHARP_QUERIES = ` (constructor_declaration name: (identifier) @name) @definition.constructor (property_declaration name: (identifier) @name) @definition.property +; Primary constructors (C# 12): class User(string name, int age) { } +(class_declaration name: (identifier) @name (parameter_list) @definition.constructor) +(record_declaration name: (identifier) @name (parameter_list) @definition.constructor) + ; Using (using_directive (qualified_name) @import.source) @import (using_directive (identifier) @import.source) @import @@ -325,6 +365,12 @@ export const CSHARP_QUERIES = ` (invocation_expression function: (identifier) @call.name) @call (invocation_expression function: (member_access_expression name: (identifier) @call.name)) @call +; Constructor calls: new Foo() and new Foo { Props } +(object_creation_expression type: (identifier) @call.name) @call + +; Target-typed new (C# 9): User u = new("x", 5) +(variable_declaration type: (identifier) @call.name (variable_declarator (implicit_object_creation_expression) @call)) + ; Heritage (class_declaration name: (identifier) @heritage.class (base_list (identifier) @heritage.extends)) @heritage @@ -358,6 +404,9 @@ export const RUST_QUERIES = ` (call_expression function: (scoped_identifier name: (identifier) @call.name)) @call (call_expression function: (generic_function function: (identifier) @call.name)) @call +; Struct literal construction: User { name: value } +(struct_expression name: (type_identifier) @call.name) @call + ; Heritage (trait implementation) — all combinations of concrete/generic trait × concrete/generic type (impl_item trait: (type_identifier) @heritage.trait type: (type_identifier) @heritage.class) @heritage (impl_item trait: (generic_type type: (type_identifier) @heritage.trait) type: (type_identifier) @heritage.class) @heritage @@ -424,6 +473,9 @@ export const PHP_QUERIES = ` (scoped_call_expression name: (name) @call.name) @call +; Constructor call: new User() +(object_creation_expression (name) @call.name) @call + ; ── Heritage: extends ──────────────────────────────────────────────────────── (class_declaration name: (name) @heritage.class @@ -575,6 +627,11 @@ export const SWIFT_QUERIES = ` ; Heritage - protocol inheritance (protocol_declaration name: (type_identifier) @heritage.class (inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage + +; Heritage - extension protocol conformance (e.g. extension Foo: SomeProtocol) +; Extensions wrap the name in user_type unlike class/struct/enum declarations +(class_declaration "extension" name: (user_type (type_identifier) @heritage.class) + (inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage `; export const LANGUAGE_QUERIES: Record = { diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts new file mode 100644 index 0000000000..9e37646d84 --- /dev/null +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -0,0 +1,124 @@ +import type { SyntaxNode } from './utils.js'; +import { FUNCTION_NODE_TYPES, extractFunctionName } from './utils.js'; +import { SupportedLanguages } from '../../config/supported-languages.js'; +import { typeConfigs, TYPED_PARAMETER_TYPES } from './type-extractors/index.js'; + +/** + * Per-file scoped type environment: maps (scope, variableName) → typeName. + * Scope-aware: variables inside functions are keyed by function name, + * file-level variables use the '' (empty string) scope. + * + * Design constraints: + * - Explicit-only: only type annotations, never inferred types + * - Scope-aware: function-local variables don't collide across functions + * - Conservative: complex/generic types extract the base name only + * - Per-file: built once, used for receiver resolution, then discarded + */ +export type TypeEnv = Map>; + +/** File-level scope key */ +const FILE_SCOPE = ''; + +/** + * Look up a variable's type in the TypeEnv, trying the call's enclosing + * function scope first, then falling back to file-level scope. + */ +export const lookupTypeEnv = ( + env: TypeEnv, + varName: string, + callNode: SyntaxNode, +): string | undefined => { + // Determine the enclosing function scope for the call + const scopeKey = findEnclosingScopeKey(callNode); + + // Try function-local scope first + if (scopeKey) { + const scopeEnv = env.get(scopeKey); + if (scopeEnv) { + const result = scopeEnv.get(varName); + if (result) return result; + } + } + + // Fall back to file-level scope + const fileEnv = env.get(FILE_SCOPE); + return fileEnv?.get(varName); +}; + +/** Find the enclosing function name for scope lookup. */ +const findEnclosingScopeKey = (node: SyntaxNode): string | undefined => { + let current = node.parent; + while (current) { + if (FUNCTION_NODE_TYPES.has(current.type)) { + const { funcName } = extractFunctionName(current); + if (funcName) return `${funcName}@${current.startIndex}`; + } + current = current.parent; + } + return undefined; +}; + +/** + * Build a scoped TypeEnv from a tree-sitter AST for a given language. + * Walks the tree tracking enclosing function scopes, so that variables + * inside different functions don't collide. + */ +export const buildTypeEnv = ( + tree: { rootNode: SyntaxNode }, + language: SupportedLanguages, +): TypeEnv => { + const env: TypeEnv = new Map(); + walkForTypes(tree.rootNode, language, env, FILE_SCOPE); + return env; +}; + +const walkForTypes = ( + node: SyntaxNode, + language: SupportedLanguages, + env: TypeEnv, + currentScope: string, +): void => { + // Detect scope boundaries (function/method definitions) + let scope = currentScope; + if (FUNCTION_NODE_TYPES.has(node.type)) { + const { funcName } = extractFunctionName(node); + if (funcName) scope = `${funcName}@${node.startIndex}`; + } + + // Get or create the sub-map for this scope + if (!env.has(scope)) env.set(scope, new Map()); + const scopeEnv = env.get(scope)!; + + // Check if this node provides type information + extractTypeBinding(node, language, scopeEnv); + + // Recurse into children + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) walkForTypes(child, language, env, scope); + } +}; + +/** + * Try to extract a (variableName → typeName) binding from a single AST node. + * Delegates to per-language type configurations. + */ +const extractTypeBinding = ( + node: SyntaxNode, + language: SupportedLanguages, + env: Map, +): void => { + // === PARAMETERS (most languages) === + // This guard eliminates 90%+ of calls before any language dispatch. + if (TYPED_PARAMETER_TYPES.has(node.type)) { + const config = typeConfigs[language]; + config.extractParameter(node, env); + return; + } + + // === Per-language declaration extraction === + const config = typeConfigs[language]; + if (config.declarationNodeTypes.has(node.type)) { + config.extractDeclaration(node, env); + } +}; diff --git a/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts b/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts new file mode 100644 index 0000000000..db47248721 --- /dev/null +++ b/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts @@ -0,0 +1,63 @@ +import type { SyntaxNode } from '../utils.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import { extractSimpleTypeName, extractVarName } from './shared.js'; + +const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ + 'declaration', +]); + +/** C++: Type x = ...; Type* x; Type& x; */ +const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + const typeNode = node.childForFieldName('type'); + if (!typeNode) return; + const typeName = extractSimpleTypeName(typeNode); + if (!typeName) return; + + const declarator = node.childForFieldName('declarator'); + if (!declarator) return; + + // init_declarator: Type x = value + const nameNode = declarator.type === 'init_declarator' + ? declarator.childForFieldName('declarator') + : declarator; + if (!nameNode) return; + + // Handle pointer/reference declarators + const finalName = nameNode.type === 'pointer_declarator' || nameNode.type === 'reference_declarator' + ? nameNode.firstNamedChild + : nameNode; + if (!finalName) return; + + const varName = extractVarName(finalName); + if (varName) env.set(varName, typeName); +}; + +/** C/C++: parameter_declaration → type declarator */ +const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map): void => { + let nameNode: SyntaxNode | null = null; + let typeNode: SyntaxNode | null = null; + + if (node.type === 'parameter_declaration') { + typeNode = node.childForFieldName('type'); + const declarator = node.childForFieldName('declarator'); + if (declarator) { + nameNode = declarator.type === 'pointer_declarator' || declarator.type === 'reference_declarator' + ? declarator.firstNamedChild + : declarator; + } + } else { + nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern'); + typeNode = node.childForFieldName('type'); + } + + if (!nameNode || !typeNode) return; + const varName = extractVarName(nameNode); + const typeName = extractSimpleTypeName(typeNode); + if (varName && typeName) env.set(varName, typeName); +}; + +export const typeConfig: LanguageTypeConfig = { + declarationNodeTypes: DECLARATION_NODE_TYPES, + extractDeclaration, + extractParameter, +}; diff --git a/gitnexus/src/core/ingestion/type-extractors/csharp.ts b/gitnexus/src/core/ingestion/type-extractors/csharp.ts new file mode 100644 index 0000000000..9840326a44 --- /dev/null +++ b/gitnexus/src/core/ingestion/type-extractors/csharp.ts @@ -0,0 +1,93 @@ +import type { SyntaxNode } from '../utils.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import { extractSimpleTypeName, extractVarName, findChildByType } from './shared.js'; + +const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ + 'local_declaration_statement', + 'variable_declaration', + 'field_declaration', +]); + +/** C#: Type x = ...; var x = new Type(); */ +const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + // C# tree-sitter: local_declaration_statement > variable_declaration > ... + // Recursively descend through wrapper nodes + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if (child.type === 'variable_declaration' || child.type === 'local_declaration_statement') { + extractDeclaration(child, env); + return; + } + } + + // At variable_declaration level: first child is type, rest are variable_declarators + let typeNode: SyntaxNode | null = null; + const declarators: SyntaxNode[] = []; + + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + + if (!typeNode && child.type !== 'variable_declarator' && child.type !== 'equals_value_clause') { + // First non-declarator child is the type (identifier, implicit_type, generic_name, etc.) + typeNode = child; + } + if (child.type === 'variable_declarator') { + declarators.push(child); + } + } + + if (!typeNode || declarators.length === 0) return; + + // Handle 'var x = new Foo()' — infer from object_creation_expression + let typeName: string | undefined; + if (typeNode.type === 'implicit_type' && typeNode.text === 'var') { + // Try to infer from initializer: var x = new Foo() + // C# tree-sitter puts object_creation_expression as direct child of variable_declarator + if (declarators.length === 1) { + const initializer = findChildByType(declarators[0], 'object_creation_expression') + ?? findChildByType(declarators[0], 'equals_value_clause')?.firstNamedChild; + if (initializer?.type === 'object_creation_expression') { + const ctorType = initializer.childForFieldName('type'); + if (ctorType) typeName = extractSimpleTypeName(ctorType); + } + } + } else { + typeName = extractSimpleTypeName(typeNode); + } + + if (!typeName) return; + for (const decl of declarators) { + const nameNode = decl.childForFieldName('name') ?? decl.firstNamedChild; + if (nameNode) { + const varName = extractVarName(nameNode); + if (varName) env.set(varName, typeName); + } + } +}; + +/** C#: parameter → type name */ +const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map): void => { + let nameNode: SyntaxNode | null = null; + let typeNode: SyntaxNode | null = null; + + if (node.type === 'parameter') { + typeNode = node.childForFieldName('type'); + nameNode = node.childForFieldName('name'); + } else { + nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern'); + typeNode = node.childForFieldName('type'); + } + + if (!nameNode || !typeNode) return; + const varName = extractVarName(nameNode); + const typeName = extractSimpleTypeName(typeNode); + if (varName && typeName) env.set(varName, typeName); +}; + +export const typeConfig: LanguageTypeConfig = { + declarationNodeTypes: DECLARATION_NODE_TYPES, + extractDeclaration, + extractParameter, +}; diff --git a/gitnexus/src/core/ingestion/type-extractors/go.ts b/gitnexus/src/core/ingestion/type-extractors/go.ts new file mode 100644 index 0000000000..1b252d5e8e --- /dev/null +++ b/gitnexus/src/core/ingestion/type-extractors/go.ts @@ -0,0 +1,104 @@ +import type { SyntaxNode } from '../utils.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import { extractSimpleTypeName, extractVarName } from './shared.js'; + +const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ + 'var_declaration', + 'var_spec', + 'short_var_declaration', +]); + +/** Go: var x Foo */ +const extractGoVarDeclaration = (node: SyntaxNode, env: Map): void => { + // Go var_declaration contains var_spec children + if (node.type === 'var_declaration') { + for (let i = 0; i < node.namedChildCount; i++) { + const spec = node.namedChild(i); + if (spec?.type === 'var_spec') extractGoVarDeclaration(spec, env); + } + return; + } + + // var_spec: name type [= value] + const nameNode = node.childForFieldName('name'); + const typeNode = node.childForFieldName('type'); + if (!nameNode || !typeNode) return; + const varName = extractVarName(nameNode); + const typeName = extractSimpleTypeName(typeNode); + if (varName && typeName) env.set(varName, typeName); +}; + +/** Go: x := Foo{...} — infer type from composite literal (handles multi-assignment) */ +const extractGoShortVarDeclaration = (node: SyntaxNode, env: Map): void => { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (!left || !right) return; + + // Collect LHS names and RHS values (may be expression_lists for multi-assignment) + const lhsNodes: SyntaxNode[] = []; + const rhsNodes: SyntaxNode[] = []; + + if (left.type === 'expression_list') { + for (let i = 0; i < left.namedChildCount; i++) { + const c = left.namedChild(i); + if (c) lhsNodes.push(c); + } + } else { + lhsNodes.push(left); + } + + if (right.type === 'expression_list') { + for (let i = 0; i < right.namedChildCount; i++) { + const c = right.namedChild(i); + if (c) rhsNodes.push(c); + } + } else { + rhsNodes.push(right); + } + + // Pair each LHS name with its corresponding RHS value + const count = Math.min(lhsNodes.length, rhsNodes.length); + for (let i = 0; i < count; i++) { + const valueNode = rhsNodes[i]; + if (valueNode.type !== 'composite_literal') continue; + const typeNode = valueNode.childForFieldName('type'); + if (!typeNode) continue; + const typeName = extractSimpleTypeName(typeNode); + if (!typeName) continue; + const varName = extractVarName(lhsNodes[i]); + if (varName) env.set(varName, typeName); + } +}; + +const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + if (node.type === 'var_declaration' || node.type === 'var_spec') { + extractGoVarDeclaration(node, env); + } else if (node.type === 'short_var_declaration') { + extractGoShortVarDeclaration(node, env); + } +}; + +/** Go: parameter → name type */ +const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map): void => { + let nameNode: SyntaxNode | null = null; + let typeNode: SyntaxNode | null = null; + + if (node.type === 'parameter') { + nameNode = node.childForFieldName('name'); + typeNode = node.childForFieldName('type'); + } else { + nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern'); + typeNode = node.childForFieldName('type'); + } + + if (!nameNode || !typeNode) return; + const varName = extractVarName(nameNode); + const typeName = extractSimpleTypeName(typeNode); + if (varName && typeName) env.set(varName, typeName); +}; + +export const typeConfig: LanguageTypeConfig = { + declarationNodeTypes: DECLARATION_NODE_TYPES, + extractDeclaration, + extractParameter, +}; diff --git a/gitnexus/src/core/ingestion/type-extractors/index.ts b/gitnexus/src/core/ingestion/type-extractors/index.ts new file mode 100644 index 0000000000..f0d6468d36 --- /dev/null +++ b/gitnexus/src/core/ingestion/type-extractors/index.ts @@ -0,0 +1,35 @@ +/** + * Per-language type extraction configurations. + * Assembled here into a dispatch map keyed by SupportedLanguages. + */ + +import { SupportedLanguages } from '../../../config/supported-languages.js'; +import type { LanguageTypeConfig } from './types.js'; + +import { typeConfig as typescriptConfig } from './typescript.js'; +import { javaTypeConfig, kotlinTypeConfig } from './jvm.js'; +import { typeConfig as csharpConfig } from './csharp.js'; +import { typeConfig as goConfig } from './go.js'; +import { typeConfig as rustConfig } from './rust.js'; +import { typeConfig as pythonConfig } from './python.js'; +import { typeConfig as swiftConfig } from './swift.js'; +import { typeConfig as cCppConfig } from './c-cpp.js'; +import { typeConfig as phpConfig } from './php.js'; + +export const typeConfigs = { + [SupportedLanguages.JavaScript]: typescriptConfig, + [SupportedLanguages.TypeScript]: typescriptConfig, + [SupportedLanguages.Java]: javaTypeConfig, + [SupportedLanguages.Kotlin]: kotlinTypeConfig, + [SupportedLanguages.CSharp]: csharpConfig, + [SupportedLanguages.Go]: goConfig, + [SupportedLanguages.Rust]: rustConfig, + [SupportedLanguages.Python]: pythonConfig, + [SupportedLanguages.Swift]: swiftConfig, + [SupportedLanguages.C]: cCppConfig, + [SupportedLanguages.CPlusPlus]: cCppConfig, + [SupportedLanguages.PHP]: phpConfig, +} satisfies Record; + +export type { LanguageTypeConfig, TypeBindingExtractor, ParameterExtractor } from './types.js'; +export { TYPED_PARAMETER_TYPES, extractSimpleTypeName, extractVarName, findChildByType } from './shared.js'; diff --git a/gitnexus/src/core/ingestion/type-extractors/jvm.ts b/gitnexus/src/core/ingestion/type-extractors/jvm.ts new file mode 100644 index 0000000000..150cf9e66b --- /dev/null +++ b/gitnexus/src/core/ingestion/type-extractors/jvm.ts @@ -0,0 +1,122 @@ +import type { SyntaxNode } from '../utils.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import { extractSimpleTypeName, extractVarName, findChildByType } from './shared.js'; + +// ── Java ────────────────────────────────────────────────────────────────── + +const JAVA_DECLARATION_NODE_TYPES: ReadonlySet = new Set([ + 'local_variable_declaration', + 'field_declaration', +]); + +/** Java: Type x = ...; Type x; */ +const extractJavaDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + const typeNode = node.childForFieldName('type'); + if (!typeNode) return; + const typeName = extractSimpleTypeName(typeNode); + if (!typeName) return; + + // Find variable_declarator children + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type !== 'variable_declarator') continue; + const nameNode = child.childForFieldName('name'); + if (nameNode) { + const varName = extractVarName(nameNode); + if (varName) env.set(varName, typeName); + } + } +}; + +/** Java: formal_parameter → type name */ +const extractJavaParameter: ParameterExtractor = (node: SyntaxNode, env: Map): void => { + let nameNode: SyntaxNode | null = null; + let typeNode: SyntaxNode | null = null; + + if (node.type === 'formal_parameter') { + typeNode = node.childForFieldName('type'); + nameNode = node.childForFieldName('name'); + } else { + // Generic fallback + nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern'); + typeNode = node.childForFieldName('type'); + } + + if (!nameNode || !typeNode) return; + const varName = extractVarName(nameNode); + const typeName = extractSimpleTypeName(typeNode); + if (varName && typeName) env.set(varName, typeName); +}; + +export const javaTypeConfig: LanguageTypeConfig = { + declarationNodeTypes: JAVA_DECLARATION_NODE_TYPES, + extractDeclaration: extractJavaDeclaration, + extractParameter: extractJavaParameter, +}; + +// ── Kotlin ──────────────────────────────────────────────────────────────── + +const KOTLIN_DECLARATION_NODE_TYPES: ReadonlySet = new Set([ + 'property_declaration', + 'variable_declaration', +]); + +/** Kotlin: val x: Foo = ... */ +const extractKotlinDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + if (node.type === 'property_declaration') { + // Kotlin property_declaration: name/type are inside a variable_declaration child + const varDecl = findChildByType(node, 'variable_declaration'); + if (varDecl) { + const nameNode = findChildByType(varDecl, 'simple_identifier'); + const typeNode = findChildByType(varDecl, 'user_type'); + if (!nameNode || !typeNode) return; + const varName = extractVarName(nameNode); + const typeName = extractSimpleTypeName(typeNode); + if (varName && typeName) env.set(varName, typeName); + return; + } + // Fallback: try direct fields + const nameNode = node.childForFieldName('name') + ?? findChildByType(node, 'simple_identifier'); + const typeNode = node.childForFieldName('type') + ?? findChildByType(node, 'user_type'); + if (!nameNode || !typeNode) return; + const varName = extractVarName(nameNode); + const typeName = extractSimpleTypeName(typeNode); + if (varName && typeName) env.set(varName, typeName); + } else if (node.type === 'variable_declaration') { + // variable_declaration directly inside functions + const nameNode = findChildByType(node, 'simple_identifier'); + const typeNode = findChildByType(node, 'user_type'); + if (nameNode && typeNode) { + const varName = extractVarName(nameNode); + const typeName = extractSimpleTypeName(typeNode); + if (varName && typeName) env.set(varName, typeName); + } + } +}; + +/** Kotlin: formal_parameter → type name */ +const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map): void => { + let nameNode: SyntaxNode | null = null; + let typeNode: SyntaxNode | null = null; + + if (node.type === 'formal_parameter') { + typeNode = node.childForFieldName('type'); + nameNode = node.childForFieldName('name'); + } else { + nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern'); + typeNode = node.childForFieldName('type'); + } + + if (!nameNode || !typeNode) return; + const varName = extractVarName(nameNode); + const typeName = extractSimpleTypeName(typeNode); + if (varName && typeName) env.set(varName, typeName); +}; + +export const kotlinTypeConfig: LanguageTypeConfig = { + declarationNodeTypes: KOTLIN_DECLARATION_NODE_TYPES, + extractDeclaration: extractKotlinDeclaration, + extractParameter: extractKotlinParameter, +}; diff --git a/gitnexus/src/core/ingestion/type-extractors/php.ts b/gitnexus/src/core/ingestion/type-extractors/php.ts new file mode 100644 index 0000000000..8448cb8472 --- /dev/null +++ b/gitnexus/src/core/ingestion/type-extractors/php.ts @@ -0,0 +1,36 @@ +import type { SyntaxNode } from '../utils.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import { extractSimpleTypeName, extractVarName } from './shared.js'; + +// PHP has no local variable type annotations; only params carry types +const DECLARATION_NODE_TYPES: ReadonlySet = new Set(); + +/** PHP: no typed local variable declarations */ +const extractDeclaration: TypeBindingExtractor = (_node: SyntaxNode, _env: Map): void => { + // PHP has no local variable type annotations +}; + +/** PHP: simple_parameter → type $name */ +const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map): void => { + let nameNode: SyntaxNode | null = null; + let typeNode: SyntaxNode | null = null; + + if (node.type === 'simple_parameter') { + typeNode = node.childForFieldName('type'); + nameNode = node.childForFieldName('name'); + } else { + nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern'); + typeNode = node.childForFieldName('type'); + } + + if (!nameNode || !typeNode) return; + const varName = extractVarName(nameNode); + const typeName = extractSimpleTypeName(typeNode); + if (varName && typeName) env.set(varName, typeName); +}; + +export const typeConfig: LanguageTypeConfig = { + declarationNodeTypes: DECLARATION_NODE_TYPES, + extractDeclaration, + extractParameter, +}; diff --git a/gitnexus/src/core/ingestion/type-extractors/python.ts b/gitnexus/src/core/ingestion/type-extractors/python.ts new file mode 100644 index 0000000000..9e8a6557b9 --- /dev/null +++ b/gitnexus/src/core/ingestion/type-extractors/python.ts @@ -0,0 +1,44 @@ +import type { SyntaxNode } from '../utils.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import { extractSimpleTypeName, extractVarName } from './shared.js'; + +const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ + 'assignment', +]); + +/** Python: x: Foo = ... (PEP 484 annotations) */ +const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + // Python annotated assignment: left : type = value + // tree-sitter represents this differently based on grammar version + const left = node.childForFieldName('left'); + const typeNode = node.childForFieldName('type'); + if (!left || !typeNode) return; + const varName = extractVarName(left); + const typeName = extractSimpleTypeName(typeNode); + if (varName && typeName) env.set(varName, typeName); +}; + +/** Python: parameter with type annotation */ +const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map): void => { + let nameNode: SyntaxNode | null = null; + let typeNode: SyntaxNode | null = null; + + if (node.type === 'parameter') { + nameNode = node.childForFieldName('name'); + typeNode = node.childForFieldName('type'); + } else { + nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern'); + typeNode = node.childForFieldName('type'); + } + + if (!nameNode || !typeNode) return; + const varName = extractVarName(nameNode); + const typeName = extractSimpleTypeName(typeNode); + if (varName && typeName) env.set(varName, typeName); +}; + +export const typeConfig: LanguageTypeConfig = { + declarationNodeTypes: DECLARATION_NODE_TYPES, + extractDeclaration, + extractParameter, +}; diff --git a/gitnexus/src/core/ingestion/type-extractors/rust.ts b/gitnexus/src/core/ingestion/type-extractors/rust.ts new file mode 100644 index 0000000000..64b9213007 --- /dev/null +++ b/gitnexus/src/core/ingestion/type-extractors/rust.ts @@ -0,0 +1,42 @@ +import type { SyntaxNode } from '../utils.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import { extractSimpleTypeName, extractVarName } from './shared.js'; + +const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ + 'let_declaration', +]); + +/** Rust: let x: Foo = ... */ +const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + const pattern = node.childForFieldName('pattern'); + const typeNode = node.childForFieldName('type'); + if (!pattern || !typeNode) return; + const varName = extractVarName(pattern); + const typeName = extractSimpleTypeName(typeNode); + if (varName && typeName) env.set(varName, typeName); +}; + +/** Rust: parameter → pattern: type */ +const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map): void => { + let nameNode: SyntaxNode | null = null; + let typeNode: SyntaxNode | null = null; + + if (node.type === 'parameter') { + nameNode = node.childForFieldName('pattern'); + typeNode = node.childForFieldName('type'); + } else { + nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern'); + typeNode = node.childForFieldName('type'); + } + + if (!nameNode || !typeNode) return; + const varName = extractVarName(nameNode); + const typeName = extractSimpleTypeName(typeNode); + if (varName && typeName) env.set(varName, typeName); +}; + +export const typeConfig: LanguageTypeConfig = { + declarationNodeTypes: DECLARATION_NODE_TYPES, + extractDeclaration, + extractParameter, +}; diff --git a/gitnexus/src/core/ingestion/type-extractors/shared.ts b/gitnexus/src/core/ingestion/type-extractors/shared.ts new file mode 100644 index 0000000000..a6fef757a1 --- /dev/null +++ b/gitnexus/src/core/ingestion/type-extractors/shared.ts @@ -0,0 +1,103 @@ +import type { SyntaxNode } from '../utils.js'; + +/** + * Extract the simple type name from a type AST node. + * Handles generic types (e.g., List → List), qualified names + * (e.g., models.User → User), and nullable types (e.g., User? → User). + * Returns undefined for complex types (unions, intersections, function types). + */ +export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined => { + // Direct type identifier + if (typeNode.type === 'type_identifier' || typeNode.type === 'identifier' + || typeNode.type === 'simple_identifier') { + return typeNode.text; + } + + // Qualified/scoped names: take the last segment (e.g., models.User → User) + if (typeNode.type === 'scoped_identifier' || typeNode.type === 'qualified_identifier' + || typeNode.type === 'scoped_type_identifier' || typeNode.type === 'qualified_name' + || typeNode.type === 'qualified_type' + || typeNode.type === 'member_expression' || typeNode.type === 'attribute') { + const last = typeNode.lastNamedChild; + if (last && (last.type === 'type_identifier' || last.type === 'identifier' + || last.type === 'simple_identifier' || last.type === 'name')) { + return last.text; + } + } + + // Generic types: extract the base type (e.g., List → List) + if (typeNode.type === 'generic_type' || typeNode.type === 'parameterized_type') { + const base = typeNode.childForFieldName('name') + ?? typeNode.childForFieldName('type') + ?? typeNode.firstNamedChild; + if (base) return extractSimpleTypeName(base); + } + + // Nullable types (Kotlin User?, C# User?) + if (typeNode.type === 'nullable_type') { + const inner = typeNode.firstNamedChild; + if (inner) return extractSimpleTypeName(inner); + } + + // Type annotations that wrap the actual type (TS/Python: `: Foo`, Kotlin: user_type) + if (typeNode.type === 'type_annotation' || typeNode.type === 'type' + || typeNode.type === 'user_type') { + const inner = typeNode.firstNamedChild; + if (inner) return extractSimpleTypeName(inner); + } + + // Pointer/reference types (C++, Rust): User*, &User, &mut User + if (typeNode.type === 'pointer_type' || typeNode.type === 'reference_type') { + const inner = typeNode.firstNamedChild; + if (inner) return extractSimpleTypeName(inner); + } + + // PHP named_type / optional_type + if (typeNode.type === 'named_type' || typeNode.type === 'optional_type') { + const inner = typeNode.childForFieldName('name') ?? typeNode.firstNamedChild; + if (inner) return extractSimpleTypeName(inner); + } + + // Name node (PHP) + if (typeNode.type === 'name') { + return typeNode.text; + } + + return undefined; +}; + +/** + * Extract variable name from a declarator or pattern node. + * Returns the simple identifier text, or undefined for destructuring/complex patterns. + */ +export const extractVarName = (node: SyntaxNode): string | undefined => { + if (node.type === 'identifier' || node.type === 'simple_identifier' + || node.type === 'variable_name' || node.type === 'name') { + return node.text; + } + // variable_declarator (Java/C#): has a 'name' field + if (node.type === 'variable_declarator') { + const nameChild = node.childForFieldName('name'); + if (nameChild) return extractVarName(nameChild); + } + return undefined; +}; + +/** Node types for function/method parameters with type annotations */ +export const TYPED_PARAMETER_TYPES = new Set([ + 'required_parameter', // TS: (x: Foo) + 'optional_parameter', // TS: (x?: Foo) + 'formal_parameter', // Java/Kotlin + 'parameter', // C#/Rust/Go/Python/Swift + 'parameter_declaration', // C/C++ void f(Type name) + 'simple_parameter', // PHP function(Foo $x) +]); + +/** Find the first named child with the given node type */ +export const findChildByType = (node: SyntaxNode, type: string): SyntaxNode | null => { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === type) return child; + } + return null; +}; diff --git a/gitnexus/src/core/ingestion/type-extractors/swift.ts b/gitnexus/src/core/ingestion/type-extractors/swift.ts new file mode 100644 index 0000000000..374c8ebbda --- /dev/null +++ b/gitnexus/src/core/ingestion/type-extractors/swift.ts @@ -0,0 +1,46 @@ +import type { SyntaxNode } from '../utils.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import { extractSimpleTypeName, extractVarName, findChildByType } from './shared.js'; + +const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ + 'property_declaration', +]); + +/** Swift: let x: Foo = ... */ +const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + // Swift property_declaration has pattern and type_annotation + const pattern = node.childForFieldName('pattern') + ?? findChildByType(node, 'pattern'); + const typeAnnotation = node.childForFieldName('type') + ?? findChildByType(node, 'type_annotation'); + if (!pattern || !typeAnnotation) return; + const varName = extractVarName(pattern) ?? pattern.text; + const typeName = extractSimpleTypeName(typeAnnotation); + if (varName && typeName) env.set(varName, typeName); +}; + +/** Swift: parameter → name: type */ +const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map): void => { + let nameNode: SyntaxNode | null = null; + let typeNode: SyntaxNode | null = null; + + if (node.type === 'parameter') { + nameNode = node.childForFieldName('name') + ?? node.childForFieldName('internal_name'); + typeNode = node.childForFieldName('type'); + } else { + nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern'); + typeNode = node.childForFieldName('type'); + } + + if (!nameNode || !typeNode) return; + const varName = extractVarName(nameNode); + const typeName = extractSimpleTypeName(typeNode); + if (varName && typeName) env.set(varName, typeName); +}; + +export const typeConfig: LanguageTypeConfig = { + declarationNodeTypes: DECLARATION_NODE_TYPES, + extractDeclaration, + extractParameter, +}; diff --git a/gitnexus/src/core/ingestion/type-extractors/types.ts b/gitnexus/src/core/ingestion/type-extractors/types.ts new file mode 100644 index 0000000000..d3bf830e81 --- /dev/null +++ b/gitnexus/src/core/ingestion/type-extractors/types.ts @@ -0,0 +1,17 @@ +import type { SyntaxNode } from '../utils.js'; + +/** Extracts type bindings from a declaration node into the env map */ +export type TypeBindingExtractor = (node: SyntaxNode, env: Map) => void; + +/** Extracts type bindings from a parameter node into the env map */ +export type ParameterExtractor = (node: SyntaxNode, env: Map) => void; + +/** Per-language type extraction configuration */ +export interface LanguageTypeConfig { + /** Node types that represent typed declarations for this language */ + declarationNodeTypes: ReadonlySet; + /** Extract a (varName → typeName) binding from a declaration node */ + extractDeclaration: TypeBindingExtractor; + /** Extract a (varName → typeName) binding from a parameter node */ + extractParameter: ParameterExtractor; +} diff --git a/gitnexus/src/core/ingestion/type-extractors/typescript.ts b/gitnexus/src/core/ingestion/type-extractors/typescript.ts new file mode 100644 index 0000000000..2d77d8ee23 --- /dev/null +++ b/gitnexus/src/core/ingestion/type-extractors/typescript.ts @@ -0,0 +1,48 @@ +import type { SyntaxNode } from '../utils.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import { extractSimpleTypeName, extractVarName } from './shared.js'; + +const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ + 'lexical_declaration', + 'variable_declaration', +]); + +/** TypeScript: const x: Foo = ..., let x: Foo */ +const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + for (let i = 0; i < node.namedChildCount; i++) { + const declarator = node.namedChild(i); + if (declarator?.type !== 'variable_declarator') continue; + const nameNode = declarator.childForFieldName('name'); + const typeAnnotation = declarator.childForFieldName('type'); + if (!nameNode || !typeAnnotation) continue; + const varName = extractVarName(nameNode); + const typeName = extractSimpleTypeName(typeAnnotation); + if (varName && typeName) env.set(varName, typeName); + } +}; + +/** TypeScript: required_parameter / optional_parameter → name: type */ +const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map): void => { + let nameNode: SyntaxNode | null = null; + let typeNode: SyntaxNode | null = null; + + if (node.type === 'required_parameter' || node.type === 'optional_parameter') { + nameNode = node.childForFieldName('pattern') ?? node.childForFieldName('name'); + typeNode = node.childForFieldName('type'); + } else { + // Generic fallback + nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern'); + typeNode = node.childForFieldName('type'); + } + + if (!nameNode || !typeNode) return; + const varName = extractVarName(nameNode); + const typeName = extractSimpleTypeName(typeNode); + if (varName && typeName) env.set(varName, typeName); +}; + +export const typeConfig: LanguageTypeConfig = { + declarationNodeTypes: DECLARATION_NODE_TYPES, + extractDeclaration, + extractParameter, +}; diff --git a/gitnexus/src/core/ingestion/utils.ts b/gitnexus/src/core/ingestion/utils.ts index 3fb36e6e97..bbab770125 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -1,4 +1,9 @@ +import type Parser from 'tree-sitter'; import { SupportedLanguages } from '../../config/supported-languages.js'; +import { generateId } from '../../lib/utils.js'; + +/** Tree-sitter AST node. Re-exported for use across ingestion modules. */ +export type SyntaxNode = Parser.SyntaxNode; /** * Ordered list of definition capture keys for tree-sitter query matches. @@ -236,6 +241,84 @@ 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); +/** 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', + 'interface_declaration', 'struct_declaration', 'record_declaration', + 'class_specifier', 'struct_specifier', + 'impl_item', 'trait_item', + 'class_definition', + 'trait_declaration', + 'protocol_declaration', +]); + +export const CONTAINER_TYPE_TO_LABEL: Record = { + class_declaration: 'Class', + abstract_class_declaration: 'Class', + interface_declaration: 'Interface', + struct_declaration: 'Struct', + struct_specifier: 'Struct', + class_specifier: 'Class', + class_definition: 'Class', + impl_item: 'Impl', + trait_item: 'Trait', + trait_declaration: 'Trait', + record_declaration: 'Record', + protocol_declaration: 'Interface', +}; + +/** Walk up AST to find enclosing class/struct/interface/impl, return its generateId or null. + * For Go method_declaration nodes, extracts receiver type (e.g. `func (u *User) Save()` → User struct). */ +export const findEnclosingClassId = (node: any, filePath: string): string | null => { + let current = node.parent; + while (current) { + // Go: method_declaration has a receiver parameter with the struct type + if (current.type === 'method_declaration') { + const receiver = current.childForFieldName?.('receiver'); + if (receiver) { + // receiver is a parameter_list: (u *User) or (u User) + const paramDecl = receiver.namedChildren?.find?.((c: any) => c.type === 'parameter_declaration'); + if (paramDecl) { + const typeNode = paramDecl.childForFieldName?.('type'); + if (typeNode) { + // Unwrap pointer_type (*User → User) + const inner = typeNode.type === 'pointer_type' ? typeNode.firstNamedChild : typeNode; + if (inner && (inner.type === 'type_identifier' || inner.type === 'identifier')) { + return generateId('Struct', `${filePath}:${inner.text}`); + } + } + } + } + } + if (CLASS_CONTAINER_TYPES.has(current.type)) { + // Rust impl_item: for `impl Trait for Struct {}`, pick the type after `for` + if (current.type === 'impl_item') { + const children = current.children ?? []; + const forIdx = children.findIndex((c: any) => c.text === 'for'); + if (forIdx !== -1) { + const nameNode = children.slice(forIdx + 1).find((c: any) => + c.type === 'type_identifier' || c.type === 'identifier' + ); + if (nameNode) { + return generateId('Impl', `${filePath}:${nameNode.text}`); + } + } + // Fall through: plain `impl Struct {}` — use first type_identifier below + } + const nameNode = current.childForFieldName?.('name') + ?? current.children?.find((c: any) => + c.type === 'type_identifier' || c.type === 'identifier' || c.type === 'name' + ); + if (nameNode) { + const label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class'; + return generateId(label, `${filePath}:${nameNode.text}`); + } + } + current = current.parent; + } + return null; +}; + /** * Extract function name and label from a function_definition or similar AST node. * Handles C/C++ qualified_identifier (ClassName::MethodName) and other language patterns. @@ -292,10 +375,10 @@ export const extractFunctionName = (node: any): { funcName: string | null; label } } - // Fallback for other languages + // Fallback for other languages (Kotlin uses simple_identifier, Swift uses simple_identifier) if (!funcName) { const nameNode = node.childForFieldName?.('name') || - node.children?.find((c: any) => c.type === 'identifier' || c.type === 'property_identifier'); + node.children?.find((c: any) => c.type === 'identifier' || c.type === 'property_identifier' || c.type === 'simple_identifier'); funcName = nameNode?.text; } } else if (node.type === 'impl_item') { @@ -390,9 +473,337 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages | return null; }; +export interface MethodSignature { + parameterCount: number | undefined; + returnType: string | undefined; +} + +const CALL_ARGUMENT_LIST_TYPES = new Set([ + 'arguments', + 'argument_list', + 'value_arguments', +]); + +/** + * Extract parameter count and return type text from an AST method/function node. + * Works across languages by looking for common AST patterns. + */ +export const extractMethodSignature = (node: SyntaxNode | null | undefined): MethodSignature => { + let parameterCount: number | undefined = 0; + let returnType: string | undefined; + let isVariadic = false; + + if (!node) return { parameterCount, returnType }; + + const paramListTypes = new Set([ + 'formal_parameters', 'parameters', 'parameter_list', + 'function_parameters', 'method_parameters', 'function_value_parameters', + ]); + + // Node types that indicate variadic/rest parameters + const VARIADIC_PARAM_TYPES = new Set([ + 'variadic_parameter_declaration', // Go: ...string + 'variadic_parameter', // Rust: extern "C" fn(...) + 'spread_parameter', // Java: Object... args + 'list_splat_pattern', // Python: *args + 'dictionary_splat_pattern', // Python: **kwargs + ]); + + const findParameterList = (current: SyntaxNode): SyntaxNode | null => { + for (const child of current.children) { + if (paramListTypes.has(child.type)) return child; + } + for (const child of current.children) { + const nested = findParameterList(child); + if (nested) return nested; + } + return null; + }; + + const parameterList = ( + paramListTypes.has(node.type) ? node // node itself IS the parameter list (e.g. C# primary constructors) + : node.childForFieldName?.('parameters') + ?? findParameterList(node) + ); + + if (parameterList && paramListTypes.has(parameterList.type)) { + for (const param of parameterList.namedChildren) { + if (param.type === 'comment') continue; + if (param.text === 'self' || param.text === '&self' || param.text === '&mut self' || + param.type === 'self_parameter') { + continue; + } + // Check for variadic parameter types + if (VARIADIC_PARAM_TYPES.has(param.type)) { + isVariadic = true; + continue; + } + // TypeScript/JavaScript: rest parameter — required_parameter containing rest_pattern + if (param.type === 'required_parameter' || param.type === 'optional_parameter') { + for (const child of param.children) { + if (child.type === 'rest_pattern') { + isVariadic = true; + break; + } + } + if (isVariadic) continue; + } + // Kotlin: vararg modifier on a regular parameter + if (param.type === 'parameter' || param.type === 'formal_parameter') { + const prev = param.previousSibling; + if (prev?.type === 'parameter_modifiers' && prev.text.includes('vararg')) { + isVariadic = true; + } + } + parameterCount++; + } + // C/C++: bare `...` token in parameter list (not a named child — check all children) + if (!isVariadic) { + for (const child of parameterList.children) { + if (!child.isNamed && child.text === '...') { + isVariadic = true; + break; + } + } + } + } + + // Return type extraction — language-specific field names + // Go: 'result' field is either a type_identifier or parameter_list (multi-return) + const goResult = node.childForFieldName?.('result'); + if (goResult) { + returnType = goResult.type === 'parameter_list' + ? goResult.text // multi-return: "(string, error)" + : goResult.text; // single return: "int" + } + + // Rust: 'return_type' field — the value IS the type node (e.g. primitive_type, type_identifier). + // Skip if the node is a type_annotation (TS/Python), which is handled by the generic loop below. + if (!returnType) { + const rustReturn = node.childForFieldName?.('return_type'); + if (rustReturn && rustReturn.type !== 'type_annotation') { + returnType = rustReturn.text; + } + } + + // C/C++: 'type' field on function_definition + if (!returnType) { + const cppType = node.childForFieldName?.('type'); + if (cppType && cppType.text !== 'void') { + returnType = cppType.text; + } + } + + // TS/Rust/Python/C#/Kotlin: type_annotation or return_type child + if (!returnType) { + for (const child of node.children) { + if (child.type === 'type_annotation' || child.type === 'return_type') { + const typeNode = child.children.find((c) => c.isNamed); + if (typeNode) returnType = typeNode.text; + } + } + } + + if (isVariadic) parameterCount = undefined; + + return { parameterCount, returnType }; +}; + +/** + * Count direct arguments for a call expression across common tree-sitter grammars. + * Returns undefined when the argument container cannot be located cheaply. + */ +export const countCallArguments = (callNode: SyntaxNode | null | undefined): number | undefined => { + if (!callNode) return undefined; + + // Direct field or direct child (most languages) + let argsNode: SyntaxNode | null | undefined = callNode.childForFieldName('arguments') + ?? callNode.children.find((child) => CALL_ARGUMENT_LIST_TYPES.has(child.type)); + + // Kotlin/Swift: call_expression → call_suffix → value_arguments + // Search one level deeper for languages that wrap arguments in a suffix node + if (!argsNode) { + for (const child of callNode.children) { + if (!child.isNamed) continue; + const nested = child.children.find((gc) => CALL_ARGUMENT_LIST_TYPES.has(gc.type)); + if (nested) { argsNode = nested; break; } + } + } + + if (!argsNode) return undefined; + + let count = 0; + for (const child of argsNode.children) { + if (!child.isNamed) continue; + if (child.type === 'comment') continue; + count++; + } + + return count; +}; + +// ── Call-form discrimination (Phase 1, Step D) ───────────────────────── + +/** + * AST node types that indicate a member-access wrapper around the callee name. + * When nameNode.parent.type is one of these, the call is a member call. + */ +const MEMBER_ACCESS_NODE_TYPES = new Set([ + 'member_expression', // TS/JS: obj.method() + 'attribute', // Python: obj.method() + 'member_access_expression', // C#: obj.Method() + 'field_expression', // Rust/C++: obj.method() / ptr->method() + 'selector_expression', // Go: obj.Method() + 'navigation_suffix', // Kotlin/Swift: obj.method() — nameNode sits inside navigation_suffix +]); + +/** + * Call node types that are inherently constructor invocations. + * Only includes patterns that the tree-sitter queries already capture as @call. + */ +const CONSTRUCTOR_CALL_NODE_TYPES = new Set([ + 'constructor_invocation', // Kotlin: Foo() + 'new_expression', // TS/JS/C++: new Foo() + 'object_creation_expression', // Java/C#/PHP: new Foo() + 'implicit_object_creation_expression', // C# 9: User u = new(...) + 'composite_literal', // Go: User{...} + 'struct_expression', // Rust: User { ... } +]); + +/** + * AST node types for scoped/qualified calls (e.g., Foo::new() in Rust, Foo::bar() in C++). + */ +const SCOPED_CALL_NODE_TYPES = new Set([ + 'scoped_identifier', // Rust: Foo::new() + 'qualified_identifier', // C++: ns::func() +]); + +type CallForm = 'free' | 'member' | 'constructor'; + +/** + * Infer whether a captured call site is a free call, member call, or constructor. + * Returns undefined if the form cannot be determined. + * + * Works by inspecting the AST structure between callNode (@call) and nameNode (@call.name). + * No tree-sitter query changes needed — the distinction is in the node types. + */ +export const inferCallForm = ( + callNode: SyntaxNode, + nameNode: SyntaxNode, +): CallForm | undefined => { + // 1. Constructor: callNode itself is a constructor invocation (Kotlin) + if (CONSTRUCTOR_CALL_NODE_TYPES.has(callNode.type)) { + return 'constructor'; + } + + // 2. Member call: nameNode's parent is a member-access wrapper + const nameParent = nameNode.parent; + if (nameParent && MEMBER_ACCESS_NODE_TYPES.has(nameParent.type)) { + return 'member'; + } + + // 3. PHP: the callNode itself distinguishes member vs free calls + if (callNode.type === 'member_call_expression' || callNode.type === 'nullsafe_member_call_expression') { + return 'member'; + } + if (callNode.type === 'scoped_call_expression') { + return 'member'; // static call Foo::bar() + } + + // 4. Java method_invocation: member if it has an 'object' field + if (callNode.type === 'method_invocation' && callNode.childForFieldName('object')) { + return 'member'; + } + + // 5. Scoped calls (Rust Foo::new(), C++ ns::func()): treat as free + // The receiver is a type, not an instance — handled differently in Phase 3 + if (nameParent && SCOPED_CALL_NODE_TYPES.has(nameParent.type)) { + return 'free'; + } + + // 6. Default: if nameNode is a direct child of callNode, it's a free call + if (nameNode.parent === callNode || nameParent?.parent === callNode) { + return 'free'; + } + + return undefined; +}; + +/** + * Extract the receiver identifier for member calls. + * Only captures simple identifiers — returns undefined for complex expressions + * like getUser().save() or arr[0].method(). + */ +const SIMPLE_RECEIVER_TYPES = new Set([ + 'identifier', + 'simple_identifier', + 'variable_name', // PHP $variable (tree-sitter-php) + 'name', // PHP name node + 'this', // TS/JS/Java/C# this.method() + 'self', // Rust/Python self.method() +]); + +export const extractReceiverName = ( + nameNode: SyntaxNode, +): string | undefined => { + const parent = nameNode.parent; + if (!parent) return undefined; + + // PHP: member_call_expression / nullsafe_member_call_expression — receiver is on the callNode + // Java: method_invocation — receiver is the 'object' field on callNode + // For these, parent of nameNode is the call itself, so check the call's object field + const callNode = parent.parent ?? parent; + + let receiver: SyntaxNode | null = null; + + // Try standard field names used across grammars + receiver = parent.childForFieldName('object') // TS/JS member_expression, Python attribute, PHP, Java + ?? parent.childForFieldName('value') // Rust field_expression + ?? parent.childForFieldName('operand') // Go selector_expression + ?? parent.childForFieldName('expression') // C# member_access_expression + ?? parent.childForFieldName('argument'); // C++ field_expression + + // Java method_invocation: 'object' field is on the callNode, not on nameNode's parent + if (!receiver && callNode.type === 'method_invocation') { + receiver = callNode.childForFieldName('object'); + } + + // PHP: member_call_expression has 'object' on the call node + if (!receiver && (callNode.type === 'member_call_expression' || callNode.type === 'nullsafe_member_call_expression')) { + receiver = callNode.childForFieldName('object'); + } + + // Kotlin/Swift: navigation_expression target is the first child + if (!receiver && parent.type === 'navigation_suffix') { + const navExpr = parent.parent; + if (navExpr?.type === 'navigation_expression') { + // First named child is the target (receiver) + for (const child of navExpr.children) { + if (child.isNamed && child !== parent) { + receiver = child; + break; + } + } + } + } + + if (!receiver) return undefined; + + // Only capture simple identifiers — refuse complex expressions + if (SIMPLE_RECEIVER_TYPES.has(receiver.type)) { + return receiver.text; + } + + return undefined; +}; + export const isVerboseIngestionEnabled = (): boolean => { const raw = process.env.GITNEXUS_VERBOSE; if (!raw) return false; const value = raw.toLowerCase(); return value === '1' || value === 'true' || value === 'yes'; }; + + + + diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index f95c0eb34d..0bad543f55 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -20,10 +20,24 @@ import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from '../constants.js const _require = createRequire(import.meta.url); let Swift: any = null; try { Swift = _require('tree-sitter-swift'); } catch {} -import { findSiblingChild, getLanguageFromFilename, FUNCTION_NODE_TYPES, extractFunctionName, isBuiltInOrNoise, DEFINITION_CAPTURE_KEYS, getDefinitionNodeFromCaptures } from '../utils.js'; +import { + getLanguageFromFilename, + FUNCTION_NODE_TYPES, + extractFunctionName, + isBuiltInOrNoise, + getDefinitionNodeFromCaptures, + findEnclosingClassId, + extractMethodSignature, + countCallArguments, + inferCallForm, + extractReceiverName +} from '../utils.js'; +import { buildTypeEnv, lookupTypeEnv } from '../type-env.js'; import { isNodeExported } from '../export-detection.js'; import { detectFrameworkFromAST } from '../framework-detection.js'; import { generateId } from '../../../lib/utils.js'; +import { extractNamedBindings } from '../named-binding-extraction.js'; +import { appendKotlinWildcard } from '../resolvers/index.js'; // ============================================================================ // Types for serializable results @@ -37,11 +51,13 @@ interface ParsedNode { filePath: string; startLine: number; endLine: number; - language: string; + language: SupportedLanguages; isExported: boolean; astFrameworkMultiplier?: number; astFrameworkReason?: string; description?: string; + parameterCount?: number; + returnType?: string; }; } @@ -49,7 +65,7 @@ interface ParsedRelationship { id: string; sourceId: string; targetId: string; - type: 'DEFINES'; + type: 'DEFINES' | 'HAS_METHOD'; confidence: number; reason: string; } @@ -59,12 +75,16 @@ interface ParsedSymbol { name: string; nodeId: string; type: string; + parameterCount?: number; + ownerId?: string; } export interface ExtractedImport { filePath: string; rawImportPath: string; - language: string; + language: SupportedLanguages; + /** Named bindings from the import (e.g., import {User as U} → [{local:'U', exported:'User'}]) */ + namedBindings?: { local: string; exported: string }[]; } export interface ExtractedCall { @@ -72,6 +92,13 @@ export interface ExtractedCall { calledName: string; /** generateId of enclosing function, or generateId('File', filePath) for top-level */ sourceId: string; + argCount?: number; + /** Discriminates free function calls from member/constructor calls */ + callForm?: 'free' | 'member' | 'constructor'; + /** Simple identifier of the receiver for member calls (e.g., 'user' in user.save()) */ + receiverName?: string; + /** Resolved type name of the receiver (e.g., 'User' for user.save() when user: User) */ + receiverTypeName?: string; } export interface ExtractedHeritage { @@ -197,18 +224,6 @@ const getLabelFromCaptures = (captureMap: Record): string | null => // DEFINITION_CAPTURE_KEYS and getDefinitionNodeFromCaptures imported from ../utils.js -/** - * Append .* to a Kotlin import path if the AST has a wildcard_import sibling node. - * Pure function — returns a new string without mutating the input. - */ -const appendKotlinWildcard = (importPath: string, importNode: any): string => { - for (let i = 0; i < importNode.childCount; i++) { - if (importNode.child(i)?.type === 'wildcard_import') { - return importPath.endsWith('.*') ? importPath : `${importPath}.*`; - } - } - return importPath; -}; // ============================================================================ // Process a batch of files @@ -806,6 +821,9 @@ const processFileGroup = ( result.fileCount++; onFileProcessed?.(); + // Build per-file TypeEnv from explicit type annotations (for receiver resolution) + const typeEnv = buildTypeEnv(tree, language); + let matches; try { matches = query.matches(tree.rootNode); @@ -825,10 +843,12 @@ const processFileGroup = ( const rawImportPath = language === SupportedLanguages.Kotlin ? appendKotlinWildcard(captureMap['import.source'].text.replace(/['"<>]/g, ''), captureMap['import']) : captureMap['import.source'].text.replace(/['"<>]/g, ''); + const namedBindings = extractNamedBindings(captureMap['import'], language); result.imports.push({ filePath: file.path, rawImportPath, language: language, + ...(namedBindings ? { namedBindings } : {}), }); continue; } @@ -842,7 +862,18 @@ const processFileGroup = ( const callNode = captureMap['call']; const sourceId = findEnclosingFunctionId(callNode, file.path) || generateId('File', file.path); - result.calls.push({ filePath: file.path, calledName, sourceId }); + const callForm = inferCallForm(callNode, callNameNode); + const receiverName = callForm === 'member' ? extractReceiverName(callNameNode) : undefined; + const receiverTypeName = receiverName ? lookupTypeEnv(typeEnv, receiverName, callNode) : undefined; + result.calls.push({ + filePath: file.path, + calledName, + sourceId, + argCount: countCallArguments(callNode), + ...(callForm !== undefined ? { callForm } : {}), + ...(receiverName !== undefined ? { receiverName } : {}), + ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), + }); } } continue; @@ -851,12 +882,21 @@ const processFileGroup = ( // Extract heritage (extends/implements) if (captureMap['heritage.class']) { if (captureMap['heritage.extends']) { - result.heritage.push({ - filePath: file.path, - className: captureMap['heritage.class'].text, - parentName: captureMap['heritage.extends'].text, - kind: 'extends', - }); + // Go struct embedding: the query matches ALL field_declarations with + // type_identifier, but only anonymous fields (no name) are embedded. + // Named fields like `Breed string` also match — skip them. + const extendsNode = captureMap['heritage.extends']; + const fieldDecl = extendsNode.parent; + const isNamedField = fieldDecl?.type === 'field_declaration' + && fieldDecl.childForFieldName('name'); + if (!isNamedField) { + result.heritage.push({ + filePath: file.path, + className: captureMap['heritage.class'].text, + parentName: captureMap['heritage.extends'].text, + kind: 'extends', + }); + } } if (captureMap['heritage.implements']) { result.heritage.push({ @@ -903,6 +943,14 @@ const processFileGroup = ( ? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300)) : null; + let parameterCount: number | undefined; + let returnType: string | undefined; + if (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') { + const sig = extractMethodSignature(definitionNode); + parameterCount = sig.parameterCount; + returnType = sig.returnType; + } + result.nodes.push({ id: nodeId, label: nodeLabel, @@ -918,14 +966,23 @@ const processFileGroup = ( astFrameworkReason: frameworkHint.reason, } : {}), ...(description !== undefined ? { description } : {}), + ...(parameterCount !== undefined ? { parameterCount } : {}), + ...(returnType !== undefined ? { returnType } : {}), }, }); + // Compute enclosing class for Method/Constructor/Property/Function — used for both ownerId and HAS_METHOD + // Function is included because Kotlin/Rust/Python capture class methods as Function nodes + const needsOwner = nodeLabel === 'Method' || nodeLabel === 'Constructor' || nodeLabel === 'Property' || nodeLabel === 'Function'; + const enclosingClassId = needsOwner ? findEnclosingClassId(nameNode || definitionNode, file.path) : null; + result.symbols.push({ filePath: file.path, name: nodeName, nodeId, type: nodeLabel, + ...(parameterCount !== undefined ? { parameterCount } : {}), + ...(enclosingClassId ? { ownerId: enclosingClassId } : {}), }); const fileId = generateId('File', file.path); @@ -938,6 +995,18 @@ const processFileGroup = ( confidence: 1.0, reason: '', }); + + // ── HAS_METHOD: link method/constructor/property to enclosing class ── + if (enclosingClassId) { + result.relationships.push({ + id: generateId('HAS_METHOD', `${enclosingClassId}->${nodeId}`), + sourceId: enclosingClassId, + targetId: nodeId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + } } // Extract Laravel routes from route files via procedural AST walk diff --git a/gitnexus/src/core/kuzu/csv-generator.ts b/gitnexus/src/core/kuzu/csv-generator.ts index 6a96190e2c..064162f305 100644 --- a/gitnexus/src/core/kuzu/csv-generator.ts +++ b/gitnexus/src/core/kuzu/csv-generator.ts @@ -232,7 +232,8 @@ export const streamAllCSVsToDisk = async ( const functionWriter = new BufferedCSVWriter(path.join(csvDir, 'function.csv'), codeElementHeader); const classWriter = new BufferedCSVWriter(path.join(csvDir, 'class.csv'), codeElementHeader); const interfaceWriter = new BufferedCSVWriter(path.join(csvDir, 'interface.csv'), codeElementHeader); - const methodWriter = new BufferedCSVWriter(path.join(csvDir, 'method.csv'), codeElementHeader); + const methodHeader = 'id,name,filePath,startLine,endLine,isExported,content,description,parameterCount,returnType'; + const methodWriter = new BufferedCSVWriter(path.join(csvDir, 'method.csv'), methodHeader); const codeElemWriter = new BufferedCSVWriter(path.join(csvDir, 'codeelement.csv'), codeElementHeader); const communityWriter = new BufferedCSVWriter(path.join(csvDir, 'community.csv'), 'id,label,heuristicLabel,keywords,description,enrichedBy,cohesion,symbolCount'); const processWriter = new BufferedCSVWriter(path.join(csvDir, 'process.csv'), 'id,label,heuristicLabel,processType,stepCount,communities,entryPointId,terminalId'); @@ -250,7 +251,6 @@ export const streamAllCSVsToDisk = async ( 'Function': functionWriter, 'Class': classWriter, 'Interface': interfaceWriter, - 'Method': methodWriter, 'CodeElement': codeElemWriter, }; @@ -308,8 +308,24 @@ export const streamAllCSVsToDisk = async ( ].join(',')); break; } + case 'Method': { + const content = await extractContent(node, contentCache); + await methodWriter.addRow([ + escapeCSVField(node.id), + escapeCSVField(node.properties.name || ''), + escapeCSVField(node.properties.filePath || ''), + escapeCSVNumber(node.properties.startLine, -1), + escapeCSVNumber(node.properties.endLine, -1), + node.properties.isExported ? 'true' : 'false', + escapeCSVField(content), + escapeCSVField((node.properties as any).description || ''), + escapeCSVNumber(node.properties.parameterCount, 0), + escapeCSVField(node.properties.returnType || ''), + ].join(',')); + break; + } default: { - // Code element nodes (Function, Class, Interface, Method, CodeElement) + // Code element nodes (Function, Class, Interface, CodeElement) const writer = codeWriterMap[node.label]; if (writer) { const content = await extractContent(node, contentCache); diff --git a/gitnexus/src/core/kuzu/kuzu-adapter.ts b/gitnexus/src/core/kuzu/kuzu-adapter.ts index 34347634ec..0473bad9b0 100644 --- a/gitnexus/src/core/kuzu/kuzu-adapter.ts +++ b/gitnexus/src/core/kuzu/kuzu-adapter.ts @@ -328,6 +328,9 @@ const getCopyQuery = (table: NodeTableName, filePath: string): string => { if (table === 'Process') { return `COPY ${t}(id, label, heuristicLabel, processType, stepCount, communities, entryPointId, terminalId) FROM "${filePath}" ${COPY_CSV_OPTS}`; } + if (table === 'Method') { + return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`; + } // TypeScript/JS code element tables have isExported; multi-language tables do not if (TABLES_WITH_EXPORTED.has(table)) { return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`; diff --git a/gitnexus/src/core/kuzu/schema.ts b/gitnexus/src/core/kuzu/schema.ts index 9989bd6c1e..a6fe76ae01 100644 --- a/gitnexus/src/core/kuzu/schema.ts +++ b/gitnexus/src/core/kuzu/schema.ts @@ -26,7 +26,7 @@ export type NodeTableName = typeof NODE_TABLES[number]; export const REL_TABLE_NAME = 'CodeRelation'; // Valid relation types -export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'MEMBER_OF', 'STEP_IN_PROCESS'] as const; +export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'OVERRIDES', 'MEMBER_OF', 'STEP_IN_PROCESS'] as const; export type RelType = typeof REL_TYPES[number]; // ============================================================================ @@ -104,6 +104,8 @@ CREATE NODE TABLE Method ( isExported BOOLEAN, content STRING, description STRING, + parameterCount INT32, + returnType STRING, PRIMARY KEY (id) )`; @@ -260,6 +262,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM Class TO \`Union\`, FROM Class TO \`Namespace\`, FROM Class TO \`Typedef\`, + FROM Class TO \`Property\`, FROM Method TO Function, FROM Method TO Method, FROM Method TO Class, @@ -295,6 +298,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM Interface TO \`TypeAlias\`, FROM Interface TO \`Struct\`, FROM Interface TO \`Constructor\`, + FROM Interface TO \`Property\`, FROM \`Struct\` TO Community, FROM \`Struct\` TO \`Trait\`, FROM \`Struct\` TO \`Struct\`, @@ -303,6 +307,8 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`Struct\` TO Function, FROM \`Struct\` TO Method, FROM \`Struct\` TO Interface, + FROM \`Struct\` TO \`Constructor\`, + FROM \`Struct\` TO \`Property\`, FROM \`Enum\` TO \`Enum\`, FROM \`Enum\` TO Community, FROM \`Enum\` TO Class, @@ -316,7 +322,13 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`Union\` TO Community, FROM \`Namespace\` TO Community, FROM \`Namespace\` TO \`Struct\`, + FROM \`Trait\` TO Method, + FROM \`Trait\` TO \`Constructor\`, + FROM \`Trait\` TO \`Property\`, FROM \`Trait\` TO Community, + FROM \`Impl\` TO Method, + FROM \`Impl\` TO \`Constructor\`, + FROM \`Impl\` TO \`Property\`, FROM \`Impl\` TO Community, FROM \`Impl\` TO \`Trait\`, FROM \`Impl\` TO \`Struct\`, @@ -327,6 +339,9 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`Const\` TO Community, FROM \`Static\` TO Community, FROM \`Property\` TO Community, + FROM \`Record\` TO Method, + FROM \`Record\` TO \`Constructor\`, + FROM \`Record\` TO \`Property\`, FROM \`Record\` TO Community, FROM \`Delegate\` TO Community, FROM \`Annotation\` TO Community, diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 9e2fe7eed4..8565c8bc01 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -78,7 +78,7 @@ SCHEMA: - Nodes: File, Folder, Function, Class, Interface, Method, CodeElement, Community, Process - Multi-language nodes (use backticks): \`Struct\`, \`Enum\`, \`Trait\`, \`Impl\`, etc. - All edges via single CodeRelation table with 'type' property -- Edge types: CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS +- Edge types: CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES, MEMBER_OF, STEP_IN_PROCESS - Edge properties: type (STRING), confidence (DOUBLE), reason (STRING), step (INT32) EXAMPLES: @@ -91,6 +91,15 @@ EXAMPLES: • Trace a process: MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) WHERE p.heuristicLabel = "UserLogin" RETURN s.name, r.step ORDER BY r.step +• Find all methods of a class: + MATCH (c:Class {name: "UserService"})-[r:CodeRelation {type: 'HAS_METHOD'}]->(m:Method) RETURN m.name, m.parameterCount, m.returnType + +• Find method overrides (MRO resolution): + MATCH (winner:Method)-[r:CodeRelation {type: 'OVERRIDES'}]->(loser:Method) RETURN winner.name, winner.filePath, loser.filePath, r.reason + +• Detect diamond inheritance: + MATCH (d:Class)-[:CodeRelation {type: 'EXTENDS'}]->(b1), (d)-[:CodeRelation {type: 'EXTENDS'}]->(b2), (b1)-[:CodeRelation {type: 'EXTENDS'}]->(a), (b2)-[:CodeRelation {type: 'EXTENDS'}]->(a) WHERE b1 <> b2 RETURN d.name, b1.name, b2.name, a.name + OUTPUT: Returns { markdown, row_count } — results formatted as a Markdown table for easy reading. TIPS: @@ -191,7 +200,7 @@ Depth groups: - d=2: LIKELY AFFECTED (indirect) - d=3: MAY NEED TESTING (transitive) -EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS +EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES Confidence: 1.0 = certain, <0.8 = fuzzy match`, inputSchema: { type: 'object', @@ -199,7 +208,7 @@ Confidence: 1.0 = certain, <0.8 = fuzzy match`, target: { type: 'string', description: 'Name of function, class, or file to analyze' }, direction: { type: 'string', description: 'upstream (what depends on this) or downstream (what this depends on)' }, maxDepth: { type: 'number', description: 'Max relationship depth (default: 3)', default: 3 }, - relationTypes: { type: 'array', items: { type: 'string' }, description: 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS (default: usage-based)' }, + relationTypes: { type: 'array', items: { type: 'string' }, description: 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES (default: usage-based)' }, includeTests: { type: 'boolean', description: 'Include test files (default: false)' }, minConfidence: { type: 'number', description: 'Minimum confidence 0-1 (default: 0.7)' }, repo: { type: 'string', description: 'Repository name or path. Omit if only one repo is indexed.' }, diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-ambiguous/handler_a.h b/gitnexus/test/fixtures/lang-resolution/cpp-ambiguous/handler_a.h new file mode 100644 index 0000000000..3ec03cd209 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-ambiguous/handler_a.h @@ -0,0 +1,6 @@ +#pragma once + +class Handler { +public: + void handle(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-ambiguous/handler_b.h b/gitnexus/test/fixtures/lang-resolution/cpp-ambiguous/handler_b.h new file mode 100644 index 0000000000..d8a81c3ea2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-ambiguous/handler_b.h @@ -0,0 +1,6 @@ +#pragma once + +class Handler { +public: + void process(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-ambiguous/processor.h b/gitnexus/test/fixtures/lang-resolution/cpp-ambiguous/processor.h new file mode 100644 index 0000000000..df7fab5e01 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-ambiguous/processor.h @@ -0,0 +1,8 @@ +#pragma once + +#include "handler_a.h" + +class Processor : public Handler { +public: + void run(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-calls/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-calls/main.cpp new file mode 100644 index 0000000000..6d1a1f2190 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-calls/main.cpp @@ -0,0 +1,6 @@ +#include "one.h" +#include "zero.h" + +void run() { + write_audit("hello"); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-calls/one.h b/gitnexus/test/fixtures/lang-resolution/cpp-calls/one.h new file mode 100644 index 0000000000..30fdb0b7ad --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-calls/one.h @@ -0,0 +1,3 @@ +inline const char* write_audit(const char* message) { + return message; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-calls/zero.h b/gitnexus/test/fixtures/lang-resolution/cpp-calls/zero.h new file mode 100644 index 0000000000..97e0c416e1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-calls/zero.h @@ -0,0 +1,3 @@ +inline const char* write_audit() { + return "zero"; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-constructor-calls/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-constructor-calls/app.cpp new file mode 100644 index 0000000000..3cc0a5ad19 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-constructor-calls/app.cpp @@ -0,0 +1,6 @@ +#include "user.h" + +void processUser(const std::string& name) { + auto user = new User(name); + user->save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-constructor-calls/user.h b/gitnexus/test/fixtures/lang-resolution/cpp-constructor-calls/user.h new file mode 100644 index 0000000000..44b48cd6d1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-constructor-calls/user.h @@ -0,0 +1,10 @@ +#pragma once +#include + +class User { +public: + User(const std::string& name) : name_(name) {} + bool save() { return true; } +private: + std::string name_; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-diamond/animal.h b/gitnexus/test/fixtures/lang-resolution/cpp-diamond/animal.h new file mode 100644 index 0000000000..e365771c83 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond/animal.h @@ -0,0 +1,7 @@ +#pragma once + +class Animal { +public: + virtual void speak(); + virtual void move(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-diamond/duck.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-diamond/duck.cpp new file mode 100644 index 0000000000..49fecb3ffd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond/duck.cpp @@ -0,0 +1,5 @@ +#include "duck.h" + +void Duck::speak() { + // quack +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-diamond/duck.h b/gitnexus/test/fixtures/lang-resolution/cpp-diamond/duck.h new file mode 100644 index 0000000000..62ec1f95f9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond/duck.h @@ -0,0 +1,8 @@ +#pragma once +#include "flyer.h" +#include "swimmer.h" + +class Duck : public Flyer, public Swimmer { +public: + void speak() override; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-diamond/flyer.h b/gitnexus/test/fixtures/lang-resolution/cpp-diamond/flyer.h new file mode 100644 index 0000000000..2f47174dd8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond/flyer.h @@ -0,0 +1,8 @@ +#pragma once +#include "animal.h" + +class Flyer : public Animal { +public: + void move() override; + void fly(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-diamond/swimmer.h b/gitnexus/test/fixtures/lang-resolution/cpp-diamond/swimmer.h new file mode 100644 index 0000000000..23f75ed432 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-diamond/swimmer.h @@ -0,0 +1,8 @@ +#pragma once +#include "animal.h" + +class Swimmer : public Animal { +public: + void move() override; + void swim(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-local-shadow/CMakeLists.txt b/gitnexus/test/fixtures/lang-resolution/cpp-local-shadow/CMakeLists.txt new file mode 100644 index 0000000000..b3badf8665 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-local-shadow/CMakeLists.txt @@ -0,0 +1,3 @@ +cmake_minimum_required(VERSION 3.10) +project(cpp-local-shadow) +add_executable(main src/main.cpp src/utils.cpp) diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-local-shadow/src/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-local-shadow/src/main.cpp new file mode 100644 index 0000000000..6784e2a047 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-local-shadow/src/main.cpp @@ -0,0 +1,15 @@ +#include "utils.h" + +// Local function shadows included save +void save(const char* data) { + printf("local save: %s\n", data); +} + +void run() { + save("test"); +} + +int main() { + run(); + return 0; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-local-shadow/src/utils.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-local-shadow/src/utils.cpp new file mode 100644 index 0000000000..40d970ec5f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-local-shadow/src/utils.cpp @@ -0,0 +1,6 @@ +#include "utils.h" +#include + +void save(const char* data) { + printf("utils save: %s\n", data); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-local-shadow/src/utils.h b/gitnexus/test/fixtures/lang-resolution/cpp-local-shadow/src/utils.h new file mode 100644 index 0000000000..648fd3547c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-local-shadow/src/utils.h @@ -0,0 +1,3 @@ +#pragma once + +void save(const char* data); diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-member-calls/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-member-calls/app.cpp new file mode 100644 index 0000000000..1803665ee1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-member-calls/app.cpp @@ -0,0 +1,6 @@ +#include "user.h" + +void processUser() { + User user; + user.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-member-calls/user.h b/gitnexus/test/fixtures/lang-resolution/cpp-member-calls/user.h new file mode 100644 index 0000000000..489f99f923 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-member-calls/user.h @@ -0,0 +1,6 @@ +#pragma once + +class User { +public: + bool save() { return true; } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-receiver-resolution/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-receiver-resolution/app.cpp new file mode 100644 index 0000000000..a52f3e5e5f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-receiver-resolution/app.cpp @@ -0,0 +1,9 @@ +#include "user.h" +#include "repo.h" + +void processEntities() { + User user; + Repo repo; + user.save(); + repo.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-receiver-resolution/repo.h b/gitnexus/test/fixtures/lang-resolution/cpp-receiver-resolution/repo.h new file mode 100644 index 0000000000..e57469f7c5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-receiver-resolution/repo.h @@ -0,0 +1,6 @@ +#pragma once + +class Repo { +public: + bool save() { return false; } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-receiver-resolution/user.h b/gitnexus/test/fixtures/lang-resolution/cpp-receiver-resolution/user.h new file mode 100644 index 0000000000..489f99f923 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-receiver-resolution/user.h @@ -0,0 +1,6 @@ +#pragma once + +class User { +public: + bool save() { return true; } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-variadic-resolution/logger.h b/gitnexus/test/fixtures/lang-resolution/cpp-variadic-resolution/logger.h new file mode 100644 index 0000000000..56ace35be0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-variadic-resolution/logger.h @@ -0,0 +1,14 @@ +#ifndef LOGGER_H +#define LOGGER_H + +#include +#include + +void log_entry(const char* fmt, ...) { + va_list args; + va_start(args, fmt); + vprintf(fmt, args); + va_end(args); +} + +#endif diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-variadic-resolution/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-variadic-resolution/main.cpp new file mode 100644 index 0000000000..302869d893 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-variadic-resolution/main.cpp @@ -0,0 +1,6 @@ +#include "logger.h" + +int main() { + log_entry("hello %s %s", "world", "test"); + return 0; +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-alias-imports/CsharpAlias.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-alias-imports/CsharpAlias.csproj new file mode 100644 index 0000000000..b14dd21d80 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-alias-imports/CsharpAlias.csproj @@ -0,0 +1,6 @@ + + + Library + net8.0 + + diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-alias-imports/Models/Repo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-alias-imports/Models/Repo.cs new file mode 100644 index 0000000000..ea0c7c08cf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-alias-imports/Models/Repo.cs @@ -0,0 +1,9 @@ +namespace Models +{ + public class Repo + { + public string Url { get; } + public Repo(string url) { Url = url; } + public bool Persist() => true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-alias-imports/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-alias-imports/Models/User.cs new file mode 100644 index 0000000000..2d8b435005 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-alias-imports/Models/User.cs @@ -0,0 +1,9 @@ +namespace Models +{ + public class User + { + public string Name { get; } + public User(string name) { Name = name; } + public bool Save() => true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-alias-imports/Services/Main.cs b/gitnexus/test/fixtures/lang-resolution/csharp-alias-imports/Services/Main.cs new file mode 100644 index 0000000000..3e34980e38 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-alias-imports/Services/Main.cs @@ -0,0 +1,16 @@ +using U = Models.User; +using R = Models.Repo; + +namespace Services +{ + public class Main + { + public void Run() + { + var u = new U("alice"); + var r = new R("https://example.com"); + u.Save(); + r.Persist(); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-ambiguous/Models/Handler.cs b/gitnexus/test/fixtures/lang-resolution/csharp-ambiguous/Models/Handler.cs new file mode 100644 index 0000000000..f15b0f2e2d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-ambiguous/Models/Handler.cs @@ -0,0 +1,7 @@ +namespace MyApp.Models +{ + public class Handler + { + public void Handle() {} + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-ambiguous/Models/IProcessor.cs b/gitnexus/test/fixtures/lang-resolution/csharp-ambiguous/Models/IProcessor.cs new file mode 100644 index 0000000000..afd54e61f3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-ambiguous/Models/IProcessor.cs @@ -0,0 +1,7 @@ +namespace MyApp.Models +{ + public interface IProcessor + { + void Run(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-ambiguous/Other/Handler.cs b/gitnexus/test/fixtures/lang-resolution/csharp-ambiguous/Other/Handler.cs new file mode 100644 index 0000000000..741dfba0a0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-ambiguous/Other/Handler.cs @@ -0,0 +1,7 @@ +namespace MyApp.Other +{ + public class Handler + { + public void Process() {} + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-ambiguous/Other/IProcessor.cs b/gitnexus/test/fixtures/lang-resolution/csharp-ambiguous/Other/IProcessor.cs new file mode 100644 index 0000000000..d96a3f6e0a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-ambiguous/Other/IProcessor.cs @@ -0,0 +1,7 @@ +namespace MyApp.Other +{ + public interface IProcessor + { + void Execute(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-ambiguous/Services/UserHandler.cs b/gitnexus/test/fixtures/lang-resolution/csharp-ambiguous/Services/UserHandler.cs new file mode 100644 index 0000000000..3135c077ee --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-ambiguous/Services/UserHandler.cs @@ -0,0 +1,9 @@ +using MyApp.Models; + +namespace MyApp.Services +{ + public class UserHandler : Handler, IProcessor + { + public void Run() {} + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-calls/CallProj.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-calls/CallProj.csproj new file mode 100644 index 0000000000..0acdb2bcc2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-calls/CallProj.csproj @@ -0,0 +1,6 @@ + + + net8.0 + CallProj + + diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-calls/Services/UserService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-calls/Services/UserService.cs new file mode 100644 index 0000000000..fc06399d0c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-calls/Services/UserService.cs @@ -0,0 +1,13 @@ +using static CallProj.Utils.OneArg; +using static CallProj.Utils.ZeroArg; + +namespace CallProj.Services +{ + public class UserService + { + public void CreateUser() + { + WriteAudit("hello"); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-calls/Utils/OneArg.cs b/gitnexus/test/fixtures/lang-resolution/csharp-calls/Utils/OneArg.cs new file mode 100644 index 0000000000..a5adb0b7a1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-calls/Utils/OneArg.cs @@ -0,0 +1,10 @@ +namespace CallProj.Utils +{ + public static class OneArg + { + public static string WriteAudit(string message) + { + return message; + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-calls/Utils/ZeroArg.cs b/gitnexus/test/fixtures/lang-resolution/csharp-calls/Utils/ZeroArg.cs new file mode 100644 index 0000000000..bde5bcd96e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-calls/Utils/ZeroArg.cs @@ -0,0 +1,10 @@ +namespace CallProj.Utils +{ + public static class ZeroArg + { + public static string WriteAudit() + { + return "zero"; + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-local-shadow/App/Main.cs b/gitnexus/test/fixtures/lang-resolution/csharp-local-shadow/App/Main.cs new file mode 100644 index 0000000000..0b6f352692 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-local-shadow/App/Main.cs @@ -0,0 +1,14 @@ +using Utils; + +namespace App { + public class Main { + // Local method shadows imported Logger.Save + public static void Save(string data) { + System.Console.WriteLine("local save: " + data); + } + + public static void Run() { + Save("test"); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-local-shadow/Utils/Logger.cs b/gitnexus/test/fixtures/lang-resolution/csharp-local-shadow/Utils/Logger.cs new file mode 100644 index 0000000000..47507696af --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-local-shadow/Utils/Logger.cs @@ -0,0 +1,7 @@ +namespace Utils { + public class Logger { + public static void Save(string data) { + System.Console.WriteLine("utils save: " + data); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-member-calls/MemberCallProj.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-member-calls/MemberCallProj.csproj new file mode 100644 index 0000000000..ec2cce1432 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-member-calls/MemberCallProj.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-member-calls/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-member-calls/Models/User.cs new file mode 100644 index 0000000000..2d2ffe30a8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-member-calls/Models/User.cs @@ -0,0 +1,9 @@ +namespace Models; + +public class User +{ + public bool Save() + { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-member-calls/Services/UserService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-member-calls/Services/UserService.cs new file mode 100644 index 0000000000..1d8e9e4db5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-member-calls/Services/UserService.cs @@ -0,0 +1,12 @@ +using Models; + +namespace Services; + +public class UserService +{ + public bool ProcessUser() + { + var user = new User(); + return user.Save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctors/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctors/App.cs new file mode 100644 index 0000000000..3439128be6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctors/App.cs @@ -0,0 +1,15 @@ +using Models; + +public class App +{ + public void Run() + { + // Explicit new + var user = new User("Alice", 30); + user.Save(); + + // Target-typed new (C# 9) + User user2 = new("Bob", 25); + user2.Save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctors/Models/Person.cs b/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctors/Models/Person.cs new file mode 100644 index 0000000000..1f3b109ac0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctors/Models/Person.cs @@ -0,0 +1,4 @@ +namespace Models; + +// C# 12 record with primary constructor +public record Person(string FirstName, string LastName); diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctors/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctors/Models/User.cs new file mode 100644 index 0000000000..334930e54b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctors/Models/User.cs @@ -0,0 +1,10 @@ +namespace Models; + +// C# 12 primary constructor +public class User(string name, int age) +{ + public string Name => name; + public int Age => age; + + public void Save() { } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-proj/Interfaces/IRepository.cs b/gitnexus/test/fixtures/lang-resolution/csharp-proj/Interfaces/IRepository.cs new file mode 100644 index 0000000000..56bcd4eaba --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-proj/Interfaces/IRepository.cs @@ -0,0 +1,13 @@ +namespace MyApp.Interfaces +{ + public interface IRepository + { + void Save(); + void Delete(); + } + + public interface ILogger + { + void Log(string message); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-proj/Models/BaseEntity.cs b/gitnexus/test/fixtures/lang-resolution/csharp-proj/Models/BaseEntity.cs new file mode 100644 index 0000000000..035b98620e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-proj/Models/BaseEntity.cs @@ -0,0 +1,11 @@ +namespace MyApp.Models +{ + public class BaseEntity + { + public int Id { get; set; } + + public virtual void Validate() + { + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-proj/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-proj/Models/User.cs new file mode 100644 index 0000000000..69d857633b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-proj/Models/User.cs @@ -0,0 +1,21 @@ +using MyApp.Interfaces; + +namespace MyApp.Models +{ + public class User : BaseEntity, IRepository + { + public string Name { get; set; } + + public void Save() + { + } + + public void Delete() + { + } + + public override void Validate() + { + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-proj/Services/UserService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-proj/Services/UserService.cs new file mode 100644 index 0000000000..f915194bfa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-proj/Services/UserService.cs @@ -0,0 +1,19 @@ +using MyApp.Models; +using MyApp.Interfaces; + +namespace MyApp.Services +{ + public class UserService + { + private readonly IRepository _repo; + private readonly ILogger _logger; + + public void CreateUser(string name) + { + var user = new User(); + user.Validate(); + _repo.Save(); + _logger.Log("User created"); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-receiver-resolution/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-receiver-resolution/App.cs new file mode 100644 index 0000000000..01aedaee22 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-receiver-resolution/App.cs @@ -0,0 +1,14 @@ +using Models; + +namespace App; + +public class AppService +{ + public void ProcessEntities() + { + User user = new User(); + Repo repo = new Repo(); + user.Save(); + repo.Save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-receiver-resolution/Models/Repo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-receiver-resolution/Models/Repo.cs new file mode 100644 index 0000000000..fc587e48a2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-receiver-resolution/Models/Repo.cs @@ -0,0 +1,9 @@ +namespace Models; + +public class Repo +{ + public bool Save() + { + return false; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-receiver-resolution/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-receiver-resolution/Models/User.cs new file mode 100644 index 0000000000..2d2ffe30a8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-receiver-resolution/Models/User.cs @@ -0,0 +1,9 @@ +namespace Models; + +public class User +{ + public bool Save() + { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-receiver-resolution/ReceiverProj.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-receiver-resolution/ReceiverProj.csproj new file mode 100644 index 0000000000..ec2cce1432 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-receiver-resolution/ReceiverProj.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-variadic-resolution/Services/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-variadic-resolution/Services/App.cs new file mode 100644 index 0000000000..b9f3d4768a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-variadic-resolution/Services/App.cs @@ -0,0 +1,12 @@ +using static VariadicProj.Utils.Logger; + +namespace VariadicProj.Services +{ + public class App + { + public void Execute() + { + Record("hello", "world"); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-variadic-resolution/Utils/Logger.cs b/gitnexus/test/fixtures/lang-resolution/csharp-variadic-resolution/Utils/Logger.cs new file mode 100644 index 0000000000..1d72a5c2b9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-variadic-resolution/Utils/Logger.cs @@ -0,0 +1,10 @@ +namespace VariadicProj.Utils +{ + public static class Logger + { + public static string Record(params string[] args) + { + return string.Join(", ", args); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-variadic-resolution/VariadicProj.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-variadic-resolution/VariadicProj.csproj new file mode 100644 index 0000000000..ec2cce1432 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-variadic-resolution/VariadicProj.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/gitnexus/test/fixtures/lang-resolution/go-ambiguous/go.mod b/gitnexus/test/fixtures/lang-resolution/go-ambiguous/go.mod new file mode 100644 index 0000000000..46f950fcfe --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-ambiguous/go.mod @@ -0,0 +1,3 @@ +module github.com/example/ambiguous + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-ambiguous/internal/models/handler.go b/gitnexus/test/fixtures/lang-resolution/go-ambiguous/internal/models/handler.go new file mode 100644 index 0000000000..0c75ac82dc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-ambiguous/internal/models/handler.go @@ -0,0 +1,7 @@ +package models + +type Handler struct { + Name string +} + +func (h *Handler) Handle() {} diff --git a/gitnexus/test/fixtures/lang-resolution/go-ambiguous/internal/other/handler.go b/gitnexus/test/fixtures/lang-resolution/go-ambiguous/internal/other/handler.go new file mode 100644 index 0000000000..af7931e920 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-ambiguous/internal/other/handler.go @@ -0,0 +1,7 @@ +package other + +type Handler struct { + ID int +} + +func (h *Handler) Process() {} diff --git a/gitnexus/test/fixtures/lang-resolution/go-ambiguous/internal/services/user.go b/gitnexus/test/fixtures/lang-resolution/go-ambiguous/internal/services/user.go new file mode 100644 index 0000000000..f47ffb4df6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-ambiguous/internal/services/user.go @@ -0,0 +1,9 @@ +package services + +import "github.com/example/ambiguous/internal/models" + +type UserHandler struct { + models.Handler +} + +func (u *UserHandler) Run() {} diff --git a/gitnexus/test/fixtures/lang-resolution/go-calls/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-calls/cmd/main.go new file mode 100644 index 0000000000..154d50d538 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-calls/cmd/main.go @@ -0,0 +1,10 @@ +package main + +import ( + . "example.com/go-calls/internal/onearg" + _ "example.com/go-calls/internal/zeroarg" +) + +func main() { + _ = WriteAudit("hello") +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-calls/go.mod b/gitnexus/test/fixtures/lang-resolution/go-calls/go.mod new file mode 100644 index 0000000000..0c02f08385 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-calls/go.mod @@ -0,0 +1,3 @@ +module example.com/go-calls + +go 1.22 diff --git a/gitnexus/test/fixtures/lang-resolution/go-calls/internal/onearg/log.go b/gitnexus/test/fixtures/lang-resolution/go-calls/internal/onearg/log.go new file mode 100644 index 0000000000..f92ba9871d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-calls/internal/onearg/log.go @@ -0,0 +1,5 @@ +package onearg + +func WriteAudit(message string) string { + return message +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-calls/internal/zeroarg/log.go b/gitnexus/test/fixtures/lang-resolution/go-calls/internal/zeroarg/log.go new file mode 100644 index 0000000000..f59538f6be --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-calls/internal/zeroarg/log.go @@ -0,0 +1,5 @@ +package zeroarg + +func WriteAudit() string { + return "zero" +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-local-shadow/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-local-shadow/cmd/main.go new file mode 100644 index 0000000000..f934f705dd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-local-shadow/cmd/main.go @@ -0,0 +1,12 @@ +package main + +import "go-local-shadow/internal/utils" + +func Save(data string) { + println("local save") +} + +func main() { + Save("test") + _ = utils.Save +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-local-shadow/go.mod b/gitnexus/test/fixtures/lang-resolution/go-local-shadow/go.mod new file mode 100644 index 0000000000..f0810679ee --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-local-shadow/go.mod @@ -0,0 +1,3 @@ +module go-local-shadow + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-local-shadow/internal/utils/utils.go b/gitnexus/test/fixtures/lang-resolution/go-local-shadow/internal/utils/utils.go new file mode 100644 index 0000000000..859b1ce0c9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-local-shadow/internal/utils/utils.go @@ -0,0 +1,5 @@ +package utils + +func Save(data string) { + println("saving from utils") +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-member-calls/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-member-calls/cmd/main.go new file mode 100644 index 0000000000..1ad13299dd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-member-calls/cmd/main.go @@ -0,0 +1,8 @@ +package main + +import "example.com/go-member-calls/models" + +func processUser() bool { + user := models.User{} + return user.Save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-member-calls/go.mod b/gitnexus/test/fixtures/lang-resolution/go-member-calls/go.mod new file mode 100644 index 0000000000..e2fa0a761f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-member-calls/go.mod @@ -0,0 +1,3 @@ +module example.com/go-member-calls + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-member-calls/models/user.go b/gitnexus/test/fixtures/lang-resolution/go-member-calls/models/user.go new file mode 100644 index 0000000000..7307a10d6a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-member-calls/models/user.go @@ -0,0 +1,7 @@ +package models + +type User struct{} + +func (u *User) Save() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-multi-assign/app.go b/gitnexus/test/fixtures/lang-resolution/go-multi-assign/app.go new file mode 100644 index 0000000000..43a26502ca --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-multi-assign/app.go @@ -0,0 +1,7 @@ +package main + +func process(name string, url string) { + user, repo := User{Name: name}, Repo{URL: url} + user.Save() + repo.Persist() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-multi-assign/models.go b/gitnexus/test/fixtures/lang-resolution/go-multi-assign/models.go new file mode 100644 index 0000000000..263b6dc5a2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-multi-assign/models.go @@ -0,0 +1,17 @@ +package main + +type User struct { + Name string +} + +func (u *User) Save() bool { + return true +} + +type Repo struct { + URL string +} + +func (r *Repo) Persist() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-pkg/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-pkg/cmd/main.go new file mode 100644 index 0000000000..1bb052eb61 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-pkg/cmd/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "github.com/example/gopkg/internal/auth" + "github.com/example/gopkg/internal/models" +) + +func main() { + user := auth.Authenticate("alice") + _ = models.NewUser("bob") + _ = models.NewAdmin("charlie", "superadmin") + _ = user +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-pkg/go.mod b/gitnexus/test/fixtures/lang-resolution/go-pkg/go.mod new file mode 100644 index 0000000000..41ba6c644d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-pkg/go.mod @@ -0,0 +1,3 @@ +module github.com/example/gopkg + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-pkg/internal/auth/service.go b/gitnexus/test/fixtures/lang-resolution/go-pkg/internal/auth/service.go new file mode 100644 index 0000000000..ce14c339f2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-pkg/internal/auth/service.go @@ -0,0 +1,12 @@ +package auth + +import "github.com/example/gopkg/internal/models" + +func Authenticate(name string) *models.User { + user := models.NewUser(name) + return user +} + +func ValidateToken(token string) bool { + return len(token) > 0 +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-pkg/internal/models/admin.go b/gitnexus/test/fixtures/lang-resolution/go-pkg/internal/models/admin.go new file mode 100644 index 0000000000..e94eaad65e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-pkg/internal/models/admin.go @@ -0,0 +1,10 @@ +package models + +type Admin struct { + User + Role string +} + +func NewAdmin(name string, role string) *Admin { + return &Admin{User: *NewUser(name), Role: role} +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-pkg/internal/models/repository.go b/gitnexus/test/fixtures/lang-resolution/go-pkg/internal/models/repository.go new file mode 100644 index 0000000000..59cd41ac87 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-pkg/internal/models/repository.go @@ -0,0 +1,6 @@ +package models + +type Repository interface { + Save(user *User) error + FindByID(id int) (*User, error) +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-pkg/internal/models/user.go b/gitnexus/test/fixtures/lang-resolution/go-pkg/internal/models/user.go new file mode 100644 index 0000000000..68540a7025 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-pkg/internal/models/user.go @@ -0,0 +1,10 @@ +package models + +type User struct { + ID int + Name string +} + +func NewUser(name string) *User { + return &User{Name: name} +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-receiver-resolution/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-receiver-resolution/cmd/main.go new file mode 100644 index 0000000000..720b177615 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-receiver-resolution/cmd/main.go @@ -0,0 +1,10 @@ +package main + +import "example.com/go-receiver-resolution/models" + +func processEntities() { + var user models.User + var repo models.Repo + user.Save() + repo.Save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-receiver-resolution/go.mod b/gitnexus/test/fixtures/lang-resolution/go-receiver-resolution/go.mod new file mode 100644 index 0000000000..7592619a31 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-receiver-resolution/go.mod @@ -0,0 +1,3 @@ +module example.com/go-receiver-resolution + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-receiver-resolution/models/repo.go b/gitnexus/test/fixtures/lang-resolution/go-receiver-resolution/models/repo.go new file mode 100644 index 0000000000..25e42eaf55 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-receiver-resolution/models/repo.go @@ -0,0 +1,7 @@ +package models + +type Repo struct{} + +func (r *Repo) Save() bool { + return false +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-receiver-resolution/models/user.go b/gitnexus/test/fixtures/lang-resolution/go-receiver-resolution/models/user.go new file mode 100644 index 0000000000..7307a10d6a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-receiver-resolution/models/user.go @@ -0,0 +1,7 @@ +package models + +type User struct{} + +func (u *User) Save() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-struct-literals/app.go b/gitnexus/test/fixtures/lang-resolution/go-struct-literals/app.go new file mode 100644 index 0000000000..627fe8e5d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-struct-literals/app.go @@ -0,0 +1,6 @@ +package main + +func processUser(name string) { + user := User{Name: name} + user.Save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-struct-literals/user.go b/gitnexus/test/fixtures/lang-resolution/go-struct-literals/user.go new file mode 100644 index 0000000000..b5e55c687c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-struct-literals/user.go @@ -0,0 +1,9 @@ +package main + +type User struct { + Name string +} + +func (u *User) Save() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-variadic-resolution/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-variadic-resolution/cmd/main.go new file mode 100644 index 0000000000..9915da6fbc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-variadic-resolution/cmd/main.go @@ -0,0 +1,7 @@ +package main + +import . "example.com/go-variadic-resolution/internal/logger" + +func main() { + Entry("hello", "world", "test") +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-variadic-resolution/go.mod b/gitnexus/test/fixtures/lang-resolution/go-variadic-resolution/go.mod new file mode 100644 index 0000000000..6a3adc9093 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-variadic-resolution/go.mod @@ -0,0 +1,3 @@ +module example.com/go-variadic-resolution + +go 1.22 diff --git a/gitnexus/test/fixtures/lang-resolution/go-variadic-resolution/internal/logger/logger.go b/gitnexus/test/fixtures/lang-resolution/go-variadic-resolution/internal/logger/logger.go new file mode 100644 index 0000000000..a3bd0779ef --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-variadic-resolution/internal/logger/logger.go @@ -0,0 +1,5 @@ +package logger + +func Entry(args ...interface{}) { + _ = args +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-ambiguous/models/Handler.java b/gitnexus/test/fixtures/lang-resolution/java-ambiguous/models/Handler.java new file mode 100644 index 0000000000..98d4fd4cd0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-ambiguous/models/Handler.java @@ -0,0 +1,5 @@ +package models; + +public class Handler { + public void handle() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-ambiguous/models/Processor.java b/gitnexus/test/fixtures/lang-resolution/java-ambiguous/models/Processor.java new file mode 100644 index 0000000000..3cc9502be4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-ambiguous/models/Processor.java @@ -0,0 +1,5 @@ +package models; + +public interface Processor { + void run(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-ambiguous/other/Handler.java b/gitnexus/test/fixtures/lang-resolution/java-ambiguous/other/Handler.java new file mode 100644 index 0000000000..486a645214 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-ambiguous/other/Handler.java @@ -0,0 +1,5 @@ +package other; + +public class Handler { + public void process() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-ambiguous/other/Processor.java b/gitnexus/test/fixtures/lang-resolution/java-ambiguous/other/Processor.java new file mode 100644 index 0000000000..56f7cf2669 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-ambiguous/other/Processor.java @@ -0,0 +1,5 @@ +package other; + +public interface Processor { + void execute(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-ambiguous/services/UserHandler.java b/gitnexus/test/fixtures/lang-resolution/java-ambiguous/services/UserHandler.java new file mode 100644 index 0000000000..8dac8e1b0e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-ambiguous/services/UserHandler.java @@ -0,0 +1,8 @@ +package services; + +import models.Handler; +import models.Processor; + +public class UserHandler extends Handler implements Processor { + public void run() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-calls/services/UserService.java b/gitnexus/test/fixtures/lang-resolution/java-calls/services/UserService.java new file mode 100644 index 0000000000..2a3436a9d1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-calls/services/UserService.java @@ -0,0 +1,10 @@ +package services; + +import static util.OneArg.writeAudit; +import static util.ZeroArg.writeAudit; + +public class UserService { + public void processUser() { + writeAudit("hello"); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-calls/util/OneArg.java b/gitnexus/test/fixtures/lang-resolution/java-calls/util/OneArg.java new file mode 100644 index 0000000000..f5ef77f3f6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-calls/util/OneArg.java @@ -0,0 +1,7 @@ +package util; + +public class OneArg { + public static String writeAudit(String message) { + return message; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-calls/util/ZeroArg.java b/gitnexus/test/fixtures/lang-resolution/java-calls/util/ZeroArg.java new file mode 100644 index 0000000000..4833f188d4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-calls/util/ZeroArg.java @@ -0,0 +1,7 @@ +package util; + +public class ZeroArg { + public static String writeAudit() { + return "zero"; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-constructor-calls/App.java b/gitnexus/test/fixtures/lang-resolution/java-constructor-calls/App.java new file mode 100644 index 0000000000..5546d70d45 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-constructor-calls/App.java @@ -0,0 +1,8 @@ +import models.User; + +public class App { + public static void processUser(String name) { + User user = new User(name); + user.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-constructor-calls/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-constructor-calls/models/User.java new file mode 100644 index 0000000000..9d605797e3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-constructor-calls/models/User.java @@ -0,0 +1,13 @@ +package models; + +public class User { + private String name; + + public User(String name) { + this.name = name; + } + + public boolean save() { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-heritage/interfaces/Serializable.java b/gitnexus/test/fixtures/lang-resolution/java-heritage/interfaces/Serializable.java new file mode 100644 index 0000000000..473baf6434 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-heritage/interfaces/Serializable.java @@ -0,0 +1,6 @@ +package interfaces; + +public interface Serializable { + String serialize(); + void deserialize(String data); +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-heritage/interfaces/Validatable.java b/gitnexus/test/fixtures/lang-resolution/java-heritage/interfaces/Validatable.java new file mode 100644 index 0000000000..82961b2cdf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-heritage/interfaces/Validatable.java @@ -0,0 +1,5 @@ +package interfaces; + +public interface Validatable { + boolean validate(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-heritage/models/BaseModel.java b/gitnexus/test/fixtures/lang-resolution/java-heritage/models/BaseModel.java new file mode 100644 index 0000000000..dfd2d41870 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-heritage/models/BaseModel.java @@ -0,0 +1,12 @@ +package models; + +public class BaseModel { + protected int id; + + public int getId() { + return id; + } + + public void save() { + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-heritage/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-heritage/models/User.java new file mode 100644 index 0000000000..2039f9fcc2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-heritage/models/User.java @@ -0,0 +1,20 @@ +package models; + +import interfaces.Serializable; +import interfaces.Validatable; + +public class User extends BaseModel implements Serializable, Validatable { + private String name; + + public String serialize() { + return name; + } + + public void deserialize(String data) { + this.name = data; + } + + public boolean validate() { + return name != null; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-heritage/services/UserService.java b/gitnexus/test/fixtures/lang-resolution/java-heritage/services/UserService.java new file mode 100644 index 0000000000..9695733857 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-heritage/services/UserService.java @@ -0,0 +1,12 @@ +package services; + +import models.User; +import interfaces.Serializable; + +public class UserService { + public void processUser(User user) { + user.validate(); + user.save(); + String data = user.serialize(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-local-shadow/src/main/java/com/example/app/Main.java b/gitnexus/test/fixtures/lang-resolution/java-local-shadow/src/main/java/com/example/app/Main.java new file mode 100644 index 0000000000..b262e6c940 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-local-shadow/src/main/java/com/example/app/Main.java @@ -0,0 +1,14 @@ +package com.example.app; + +import com.example.utils.Logger; + +public class Main { + // Local method shadows imported Logger.save + public static void save(String data) { + System.out.println("local save: " + data); + } + + public static void run() { + save("test"); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-local-shadow/src/main/java/com/example/utils/Logger.java b/gitnexus/test/fixtures/lang-resolution/java-local-shadow/src/main/java/com/example/utils/Logger.java new file mode 100644 index 0000000000..15a4005d26 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-local-shadow/src/main/java/com/example/utils/Logger.java @@ -0,0 +1,7 @@ +package com.example.utils; + +public class Logger { + public static void save(String data) { + System.out.println("utils save: " + data); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-member-calls/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-member-calls/models/User.java new file mode 100644 index 0000000000..e8dacc1360 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-member-calls/models/User.java @@ -0,0 +1,7 @@ +package models; + +public class User { + public boolean save() { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-member-calls/services/UserService.java b/gitnexus/test/fixtures/lang-resolution/java-member-calls/services/UserService.java new file mode 100644 index 0000000000..494b5af2aa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-member-calls/services/UserService.java @@ -0,0 +1,10 @@ +package services; + +import models.User; + +public class UserService { + public boolean processUser() { + User user = new User(); + return user.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-named-imports/com/example/app/Main.java b/gitnexus/test/fixtures/lang-resolution/java-named-imports/com/example/app/Main.java new file mode 100644 index 0000000000..0853aa88bb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-named-imports/com/example/app/Main.java @@ -0,0 +1,10 @@ +package com.example.app; + +import com.example.models.User; + +public class Main { + public void run() { + User user = new User(); + user.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-named-imports/com/example/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-named-imports/com/example/models/User.java new file mode 100644 index 0000000000..d404f7bbd6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-named-imports/com/example/models/User.java @@ -0,0 +1,7 @@ +package com.example.models; + +public class User { + public void save() { + System.out.println("models User save"); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-named-imports/com/example/other/User.java b/gitnexus/test/fixtures/lang-resolution/java-named-imports/com/example/other/User.java new file mode 100644 index 0000000000..c3d4403f86 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-named-imports/com/example/other/User.java @@ -0,0 +1,7 @@ +package com.example.other; + +public class User { + public void save() { + System.out.println("other User save"); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-receiver-resolution/App.java b/gitnexus/test/fixtures/lang-resolution/java-receiver-resolution/App.java new file mode 100644 index 0000000000..493b4226a8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-receiver-resolution/App.java @@ -0,0 +1,11 @@ +import models.User; +import models.Repo; + +public class App { + public static void processEntities() { + User user = new User(); + Repo repo = new Repo(); + user.save(); + repo.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-receiver-resolution/models/Repo.java b/gitnexus/test/fixtures/lang-resolution/java-receiver-resolution/models/Repo.java new file mode 100644 index 0000000000..cf3712bbca --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-receiver-resolution/models/Repo.java @@ -0,0 +1,7 @@ +package models; + +public class Repo { + public boolean save() { + return false; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-receiver-resolution/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-receiver-resolution/models/User.java new file mode 100644 index 0000000000..e8dacc1360 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-receiver-resolution/models/User.java @@ -0,0 +1,7 @@ +package models; + +public class User { + public boolean save() { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-variadic-resolution/com/example/app/Main.java b/gitnexus/test/fixtures/lang-resolution/java-variadic-resolution/com/example/app/Main.java new file mode 100644 index 0000000000..e32e3c07e2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-variadic-resolution/com/example/app/Main.java @@ -0,0 +1,10 @@ +package com.example.app; + +import com.example.util.Logger; + +public class Main { + public void run() { + Logger logger = new Logger(); + logger.record("hello", "world", "test"); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-variadic-resolution/com/example/util/Logger.java b/gitnexus/test/fixtures/lang-resolution/java-variadic-resolution/com/example/util/Logger.java new file mode 100644 index 0000000000..eebfb4c67b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-variadic-resolution/com/example/util/Logger.java @@ -0,0 +1,7 @@ +package com.example.util; + +public class Logger { + public void record(String... args) { + for (String a : args) System.out.println(a); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-alias-imports/app/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-alias-imports/app/App.kt new file mode 100644 index 0000000000..83241e19c4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-alias-imports/app/App.kt @@ -0,0 +1,11 @@ +package app + +import models.User as U +import models.Repo as R + +fun main() { + val u = U("alice") + val r = R("https://example.com") + u.save() + r.persist() +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-alias-imports/models/Models.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-alias-imports/models/Models.kt new file mode 100644 index 0000000000..3f89795886 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-alias-imports/models/Models.kt @@ -0,0 +1,9 @@ +package models + +class User(val name: String) { + fun save(): Boolean = true +} + +class Repo(val url: String) { + fun persist(): Boolean = true +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-ambiguous/models/Handler.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-ambiguous/models/Handler.kt new file mode 100644 index 0000000000..c1525f253d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-ambiguous/models/Handler.kt @@ -0,0 +1,5 @@ +package models + +open class Handler { + open fun handle() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-ambiguous/models/Runnable.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-ambiguous/models/Runnable.kt new file mode 100644 index 0000000000..0378321c93 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-ambiguous/models/Runnable.kt @@ -0,0 +1,5 @@ +package models + +interface Runnable { + fun run() +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-ambiguous/other/Handler.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-ambiguous/other/Handler.kt new file mode 100644 index 0000000000..a474c85811 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-ambiguous/other/Handler.kt @@ -0,0 +1,5 @@ +package other + +open class Handler { + open fun process() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-ambiguous/other/Runnable.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-ambiguous/other/Runnable.kt new file mode 100644 index 0000000000..33d135ea67 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-ambiguous/other/Runnable.kt @@ -0,0 +1,5 @@ +package other + +interface Runnable { + fun execute() +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-ambiguous/services/UserHandler.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-ambiguous/services/UserHandler.kt new file mode 100644 index 0000000000..67cfad116d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-ambiguous/services/UserHandler.kt @@ -0,0 +1,8 @@ +package services + +import models.Handler +import models.Runnable + +class UserHandler : Handler(), Runnable { + override fun run() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-calls/services/UserService.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-calls/services/UserService.kt new file mode 100644 index 0000000000..3db52664c8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-calls/services/UserService.kt @@ -0,0 +1,10 @@ +package services + +import util.OneArg.writeAudit +import util.ZeroArg.writeAudit + +class UserService { + fun processUser() { + writeAudit("hello") + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-calls/util/OneArg.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-calls/util/OneArg.kt new file mode 100644 index 0000000000..be0c8351f9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-calls/util/OneArg.kt @@ -0,0 +1,7 @@ +package util + +object OneArg { + fun writeAudit(message: String): String { + return message + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-calls/util/ZeroArg.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-calls/util/ZeroArg.kt new file mode 100644 index 0000000000..736c1ac6b6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-calls/util/ZeroArg.kt @@ -0,0 +1,7 @@ +package util + +object ZeroArg { + fun writeAudit(): String { + return "zero" + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-constructor-calls/app/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-constructor-calls/app/App.kt new file mode 100644 index 0000000000..ced26b2178 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-constructor-calls/app/App.kt @@ -0,0 +1,8 @@ +package app + +import models.User + +fun main() { + val user = User("alice") + user.save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-constructor-calls/models/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-constructor-calls/models/User.kt new file mode 100644 index 0000000000..7b3c396104 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-constructor-calls/models/User.kt @@ -0,0 +1,5 @@ +package models + +class User(val name: String) { + fun save(): Boolean = true +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-heritage/interfaces/Serializable.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-heritage/interfaces/Serializable.kt new file mode 100644 index 0000000000..f6fd0fe38e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-heritage/interfaces/Serializable.kt @@ -0,0 +1,5 @@ +package interfaces + +interface Serializable { + fun serialize(): String +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-heritage/interfaces/Validatable.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-heritage/interfaces/Validatable.kt new file mode 100644 index 0000000000..0e0f5fcc4e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-heritage/interfaces/Validatable.kt @@ -0,0 +1,5 @@ +package interfaces + +interface Validatable { + fun validate(): Boolean +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-heritage/models/BaseModel.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-heritage/models/BaseModel.kt new file mode 100644 index 0000000000..9ef344c322 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-heritage/models/BaseModel.kt @@ -0,0 +1,7 @@ +package models + +abstract class BaseModel { + fun save() { + // persist to storage + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-heritage/models/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-heritage/models/User.kt new file mode 100644 index 0000000000..306fb56292 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-heritage/models/User.kt @@ -0,0 +1,10 @@ +package models + +import interfaces.Serializable +import interfaces.Validatable + +data class User(val name: String) : BaseModel(), Serializable, Validatable { + override fun serialize(): String = name + + override fun validate(): Boolean = name.isNotEmpty() +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-heritage/services/UserService.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-heritage/services/UserService.kt new file mode 100644 index 0000000000..9795c6027b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-heritage/services/UserService.kt @@ -0,0 +1,11 @@ +package services + +import models.User +import interfaces.Serializable + +class UserService { + fun processUser(user: User) { + user.validate() + user.save() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-local-shadow/src/main/kotlin/app/Main.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-local-shadow/src/main/kotlin/app/Main.kt new file mode 100644 index 0000000000..237ad4cc45 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-local-shadow/src/main/kotlin/app/Main.kt @@ -0,0 +1,12 @@ +package app + +import utils.save + +// Local function shadows imported save +fun save(data: String) { + println("local save: $data") +} + +fun run() { + save("test") +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-local-shadow/src/main/kotlin/utils/Logger.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-local-shadow/src/main/kotlin/utils/Logger.kt new file mode 100644 index 0000000000..13984419f1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-local-shadow/src/main/kotlin/utils/Logger.kt @@ -0,0 +1,5 @@ +package utils + +fun save(data: String) { + println("utils save: $data") +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-member-calls/models/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-member-calls/models/User.kt new file mode 100644 index 0000000000..e606280377 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-member-calls/models/User.kt @@ -0,0 +1,7 @@ +package models + +class User { + fun save(): Boolean { + return true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-member-calls/services/UserService.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-member-calls/services/UserService.kt new file mode 100644 index 0000000000..6f5a885b36 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-member-calls/services/UserService.kt @@ -0,0 +1,10 @@ +package services + +import models.User + +class UserService { + fun processUser(): Boolean { + val user = User() + return user.save() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-receiver-resolution/models/Repo.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-receiver-resolution/models/Repo.kt new file mode 100644 index 0000000000..2d89c28155 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-receiver-resolution/models/Repo.kt @@ -0,0 +1,7 @@ +package models + +class Repo { + fun save(): Boolean { + return false + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-receiver-resolution/models/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-receiver-resolution/models/User.kt new file mode 100644 index 0000000000..e606280377 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-receiver-resolution/models/User.kt @@ -0,0 +1,7 @@ +package models + +class User { + fun save(): Boolean { + return true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-receiver-resolution/services/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-receiver-resolution/services/App.kt new file mode 100644 index 0000000000..40cdee0808 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-receiver-resolution/services/App.kt @@ -0,0 +1,13 @@ +package services + +import models.User +import models.Repo + +class AppService { + fun processEntities() { + val user: User = User() + val repo: Repo = Repo() + user.save() + repo.save() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-variadic-resolution/app/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-variadic-resolution/app/App.kt new file mode 100644 index 0000000000..884caf3654 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-variadic-resolution/app/App.kt @@ -0,0 +1,7 @@ +package app + +import util.logEntry + +fun main() { + logEntry("hello", "world", "test") +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-variadic-resolution/util/Logger.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-variadic-resolution/util/Logger.kt new file mode 100644 index 0000000000..b8a558f88d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-variadic-resolution/util/Logger.kt @@ -0,0 +1,5 @@ +package util + +fun logEntry(vararg messages: String) { + messages.forEach { println(it) } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-alias-imports/app/Models/Repo.php b/gitnexus/test/fixtures/lang-resolution/php-alias-imports/app/Models/Repo.php new file mode 100644 index 0000000000..3c4e3a4d6f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-alias-imports/app/Models/Repo.php @@ -0,0 +1,14 @@ +url = $url; + } + + public function persist(): bool { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-alias-imports/app/Models/User.php b/gitnexus/test/fixtures/lang-resolution/php-alias-imports/app/Models/User.php new file mode 100644 index 0000000000..3e6ff1179e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-alias-imports/app/Models/User.php @@ -0,0 +1,14 @@ +name = $name; + } + + public function save(): bool { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-alias-imports/app/Services/Main.php b/gitnexus/test/fixtures/lang-resolution/php-alias-imports/app/Services/Main.php new file mode 100644 index 0000000000..982bdb8eb6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-alias-imports/app/Services/Main.php @@ -0,0 +1,14 @@ +save(); + $r->persist(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-alias-imports/composer.json b/gitnexus/test/fixtures/lang-resolution/php-alias-imports/composer.json new file mode 100644 index 0000000000..386b0bd2d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-alias-imports/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "App\\": "app/" + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-ambiguous/app/Models/Dispatchable.php b/gitnexus/test/fixtures/lang-resolution/php-ambiguous/app/Models/Dispatchable.php new file mode 100644 index 0000000000..6075b12553 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-ambiguous/app/Models/Dispatchable.php @@ -0,0 +1,8 @@ + 'Administrator', + self::Editor => 'Editor', + self::Viewer => 'Viewer', + }; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-app/app/Models/BaseModel.php b/gitnexus/test/fixtures/lang-resolution/php-app/app/Models/BaseModel.php new file mode 100644 index 0000000000..24ce34fdf2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-app/app/Models/BaseModel.php @@ -0,0 +1,23 @@ +id; + } + + public function log(string $message): void + { + error_log($message); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-app/app/Models/User.php b/gitnexus/test/fixtures/lang-resolution/php-app/app/Models/User.php new file mode 100644 index 0000000000..531df4cd60 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-app/app/Models/User.php @@ -0,0 +1,29 @@ +name = $name; + $this->email = $email; + } + + public function getName(): string + { + return $this->name; + } + + public function getEmail(): string + { + return $this->email; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-app/app/Services/UserService.php b/gitnexus/test/fixtures/lang-resolution/php-app/app/Services/UserService.php new file mode 100644 index 0000000000..5ad074596b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-app/app/Services/UserService.php @@ -0,0 +1,38 @@ +users[$id] ?? null; + } + + public function save(mixed $entity): void + { + $this->users[$entity->getId()] = $entity; + } + + public function createUser(string $name, string $email): User + { + $user = new User($name, $email); + $this->save($user); + $user->log('User created: ' . $name); + $user?->touch(); + $defaultRole = UserRole::Viewer; + $label = $defaultRole->label(); + return $user; + } + + public static function instance(): self + { + return new self(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-app/app/Traits/HasTimestamps.php b/gitnexus/test/fixtures/lang-resolution/php-app/app/Traits/HasTimestamps.php new file mode 100644 index 0000000000..018a404eb8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-app/app/Traits/HasTimestamps.php @@ -0,0 +1,13 @@ +updatedAt = new \DateTimeImmutable(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-app/app/Traits/SoftDeletes.php b/gitnexus/test/fixtures/lang-resolution/php-app/app/Traits/SoftDeletes.php new file mode 100644 index 0000000000..bf5563e12c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-app/app/Traits/SoftDeletes.php @@ -0,0 +1,18 @@ +deletedAt = new \DateTimeImmutable(); + } + + public function restore(): void + { + $this->deletedAt = null; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-app/composer.json b/gitnexus/test/fixtures/lang-resolution/php-app/composer.json new file mode 100644 index 0000000000..386b0bd2d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-app/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "App\\": "app/" + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-calls/app/Services/UserService.php b/gitnexus/test/fixtures/lang-resolution/php-calls/app/Services/UserService.php new file mode 100644 index 0000000000..fa0a1b8bea --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-calls/app/Services/UserService.php @@ -0,0 +1,11 @@ +name = $name; + } + + public function save(): bool { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-constructor-calls/app.php b/gitnexus/test/fixtures/lang-resolution/php-constructor-calls/app.php new file mode 100644 index 0000000000..2265959019 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-constructor-calls/app.php @@ -0,0 +1,8 @@ +save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-grouped-imports/app/Models/Repo.php b/gitnexus/test/fixtures/lang-resolution/php-grouped-imports/app/Models/Repo.php new file mode 100644 index 0000000000..a4cd5c1a88 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-grouped-imports/app/Models/Repo.php @@ -0,0 +1,8 @@ +save(); + + $r = new R(); + $r->persist(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-grouped-imports/composer.json b/gitnexus/test/fixtures/lang-resolution/php-grouped-imports/composer.json new file mode 100644 index 0000000000..386b0bd2d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-grouped-imports/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "App\\": "app/" + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-local-shadow/app/Services/Main.php b/gitnexus/test/fixtures/lang-resolution/php-local-shadow/app/Services/Main.php new file mode 100644 index 0000000000..6e69a356a1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-local-shadow/app/Services/Main.php @@ -0,0 +1,13 @@ +save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-member-calls/composer.json b/gitnexus/test/fixtures/lang-resolution/php-member-calls/composer.json new file mode 100644 index 0000000000..386b0bd2d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-member-calls/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "App\\": "app/" + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-receiver-resolution/app/Models/Repo.php b/gitnexus/test/fixtures/lang-resolution/php-receiver-resolution/app/Models/Repo.php new file mode 100644 index 0000000000..d3670e2f51 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-receiver-resolution/app/Models/Repo.php @@ -0,0 +1,11 @@ +save(); + $repo->save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-receiver-resolution/composer.json b/gitnexus/test/fixtures/lang-resolution/php-receiver-resolution/composer.json new file mode 100644 index 0000000000..386b0bd2d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-receiver-resolution/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "App\\": "app/" + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-variadic-resolution/app/Services/AppService.php b/gitnexus/test/fixtures/lang-resolution/php-variadic-resolution/app/Services/AppService.php new file mode 100644 index 0000000000..6b44c47199 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-variadic-resolution/app/Services/AppService.php @@ -0,0 +1,10 @@ + bool { + true + } +} + +pub struct Repo { + pub url: String, +} + +impl Repo { + pub fn persist(&self) -> bool { + true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/main.rs new file mode 100644 index 0000000000..03142f9a79 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/main.rs @@ -0,0 +1,8 @@ +mod models; +mod other; +mod services; + +fn main() { + let h = services::create_handler(); + h.handle(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/models/handler.rs b/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/models/handler.rs new file mode 100644 index 0000000000..4e666bbf15 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/models/handler.rs @@ -0,0 +1,7 @@ +pub struct Handler { + pub name: String, +} + +impl Handler { + pub fn handle(&self) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/models/mod.rs b/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/models/mod.rs new file mode 100644 index 0000000000..ee6e88bd27 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod handler; +pub use handler::Handler; diff --git a/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/other/handler.rs b/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/other/handler.rs new file mode 100644 index 0000000000..bba33505f2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/other/handler.rs @@ -0,0 +1,7 @@ +pub struct Handler { + pub id: u32, +} + +impl Handler { + pub fn process(&self) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/other/mod.rs b/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/other/mod.rs new file mode 100644 index 0000000000..ee6e88bd27 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/other/mod.rs @@ -0,0 +1,2 @@ +pub mod handler; +pub use handler::Handler; diff --git a/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/services/mod.rs b/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/services/mod.rs new file mode 100644 index 0000000000..e137dea4ed --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-ambiguous/src/services/mod.rs @@ -0,0 +1,5 @@ +use crate::models::Handler; + +pub fn create_handler() -> Handler { + Handler { name: String::new() } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-calls/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-calls/src/main.rs new file mode 100644 index 0000000000..0159caf70c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-calls/src/main.rs @@ -0,0 +1,9 @@ +mod onearg; +mod zeroarg; + +use crate::onearg::write_audit; +use crate::zeroarg::write_audit as zero_write_audit; + +fn main() { + let _ = write_audit("hello"); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-calls/src/onearg/mod.rs b/gitnexus/test/fixtures/lang-resolution/rust-calls/src/onearg/mod.rs new file mode 100644 index 0000000000..a04f597ab8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-calls/src/onearg/mod.rs @@ -0,0 +1,3 @@ +pub fn write_audit(message: &str) -> &str { + message +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-calls/src/zeroarg/mod.rs b/gitnexus/test/fixtures/lang-resolution/rust-calls/src/zeroarg/mod.rs new file mode 100644 index 0000000000..8ab279e454 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-calls/src/zeroarg/mod.rs @@ -0,0 +1,3 @@ +pub fn write_audit() -> &'static str { + "zero" +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-grouped-imports/src/helpers/mod.rs b/gitnexus/test/fixtures/lang-resolution/rust-grouped-imports/src/helpers/mod.rs new file mode 100644 index 0000000000..fa1fcc54ba --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-grouped-imports/src/helpers/mod.rs @@ -0,0 +1,7 @@ +pub fn format_name(name: &str) -> String { + format!("Hello, {}", name) +} + +pub fn validate_email(email: &str) -> bool { + email.contains('@') +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-grouped-imports/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-grouped-imports/src/main.rs new file mode 100644 index 0000000000..f60a122eec --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-grouped-imports/src/main.rs @@ -0,0 +1,9 @@ +mod helpers; + +use crate::helpers::{format_name, validate_email}; + +fn main() { + let name = format_name("world"); + let valid = validate_email("test@example.com"); + println!("{} {}", name, valid); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-local-shadow/Cargo.toml b/gitnexus/test/fixtures/lang-resolution/rust-local-shadow/Cargo.toml new file mode 100644 index 0000000000..5b74cf8f8e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-local-shadow/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "rust-local-shadow" +version = "0.1.0" +edition = "2021" diff --git a/gitnexus/test/fixtures/lang-resolution/rust-local-shadow/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-local-shadow/src/main.rs new file mode 100644 index 0000000000..a5fc97b9eb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-local-shadow/src/main.rs @@ -0,0 +1,15 @@ +mod utils; +use utils::save; + +// Local function shadows imported save +fn save(data: &str) { + println!("local save: {}", data); +} + +fn run() { + save("test"); +} + +fn main() { + run(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-local-shadow/src/utils.rs b/gitnexus/test/fixtures/lang-resolution/rust-local-shadow/src/utils.rs new file mode 100644 index 0000000000..6bf79060e4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-local-shadow/src/utils.rs @@ -0,0 +1,3 @@ +pub fn save(data: &str) { + println!("utils save: {}", data); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-member-calls/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-member-calls/src/main.rs new file mode 100644 index 0000000000..7fc46f522e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-member-calls/src/main.rs @@ -0,0 +1,9 @@ +mod user; +use crate::user::User; + +fn process_user() -> bool { + let u = User; + u.save() +} + +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-member-calls/src/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-member-calls/src/user.rs new file mode 100644 index 0000000000..59bcfd2b23 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-member-calls/src/user.rs @@ -0,0 +1,7 @@ +pub struct User; + +impl User { + pub fn save(&self) -> bool { + true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-receiver-resolution/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-receiver-resolution/src/main.rs new file mode 100644 index 0000000000..ffb8cb32c8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-receiver-resolution/src/main.rs @@ -0,0 +1,13 @@ +mod user; +mod repo; +use crate::user::User; +use crate::repo::Repo; + +fn process_entities() { + let user: User = User; + let repo: Repo = Repo; + user.save(); + repo.save(); +} + +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-receiver-resolution/src/repo.rs b/gitnexus/test/fixtures/lang-resolution/rust-receiver-resolution/src/repo.rs new file mode 100644 index 0000000000..18a7862885 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-receiver-resolution/src/repo.rs @@ -0,0 +1,7 @@ +pub struct Repo; + +impl Repo { + pub fn save(&self) -> bool { + false + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-receiver-resolution/src/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-receiver-resolution/src/user.rs new file mode 100644 index 0000000000..59bcfd2b23 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-receiver-resolution/src/user.rs @@ -0,0 +1,7 @@ +pub struct User; + +impl User { + pub fn save(&self) -> bool { + true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-reexport-chain/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-reexport-chain/src/main.rs new file mode 100644 index 0000000000..2aa60c957a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-reexport-chain/src/main.rs @@ -0,0 +1,7 @@ +mod models; +use crate::models::Handler; + +fn main() { + let h = Handler { name: String::from("test") }; + h.process(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-reexport-chain/src/models/handler.rs b/gitnexus/test/fixtures/lang-resolution/rust-reexport-chain/src/models/handler.rs new file mode 100644 index 0000000000..0eb1e60c53 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-reexport-chain/src/models/handler.rs @@ -0,0 +1,9 @@ +pub struct Handler { + pub name: String, +} + +impl Handler { + pub fn process(&self) -> bool { + true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-reexport-chain/src/models/mod.rs b/gitnexus/test/fixtures/lang-resolution/rust-reexport-chain/src/models/mod.rs new file mode 100644 index 0000000000..ee6e88bd27 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-reexport-chain/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod handler; +pub use handler::Handler; diff --git a/gitnexus/test/fixtures/lang-resolution/rust-struct-literals/app.rs b/gitnexus/test/fixtures/lang-resolution/rust-struct-literals/app.rs new file mode 100644 index 0000000000..df112b8c8b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-struct-literals/app.rs @@ -0,0 +1,7 @@ +mod user; +use user::User; + +fn process_user(name: String) { + let user = User { name }; + user.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-struct-literals/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-struct-literals/user.rs new file mode 100644 index 0000000000..31e047e0e2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-struct-literals/user.rs @@ -0,0 +1,9 @@ +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) -> bool { + true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-traits/src/impls/button.rs b/gitnexus/test/fixtures/lang-resolution/rust-traits/src/impls/button.rs new file mode 100644 index 0000000000..37589863a0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-traits/src/impls/button.rs @@ -0,0 +1,25 @@ +use crate::traits::{Drawable, Clickable}; + +pub struct Button { + label: String, + enabled: bool, +} + +impl Drawable for Button { + fn draw(&self) { + println!("{}", self.label); + } + + fn resize(&self, width: u32, height: u32) { + } +} + +impl Clickable for Button { + fn on_click(&self) { + println!("clicked"); + } + + fn is_enabled(&self) -> bool { + self.enabled + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-traits/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-traits/src/main.rs new file mode 100644 index 0000000000..0970ca4ea4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-traits/src/main.rs @@ -0,0 +1,8 @@ +mod traits; +mod impls; + +use crate::impls::button::Button; + +fn main() { + let btn = Button { label: String::from("OK"), enabled: true }; +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-traits/src/traits/clickable.rs b/gitnexus/test/fixtures/lang-resolution/rust-traits/src/traits/clickable.rs new file mode 100644 index 0000000000..0fdd649252 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-traits/src/traits/clickable.rs @@ -0,0 +1,4 @@ +pub trait Clickable { + fn on_click(&self); + fn is_enabled(&self) -> bool; +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-traits/src/traits/drawable.rs b/gitnexus/test/fixtures/lang-resolution/rust-traits/src/traits/drawable.rs new file mode 100644 index 0000000000..e023eaf436 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-traits/src/traits/drawable.rs @@ -0,0 +1,4 @@ +pub trait Drawable { + fn draw(&self); + fn resize(&self, width: u32, height: u32); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-alias-imports/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-alias-imports/src/app.ts new file mode 100644 index 0000000000..11b9b54019 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-alias-imports/src/app.ts @@ -0,0 +1,8 @@ +import { User as U, Repo as R } from './models'; + +export function main() { + const u = new U('alice'); + const r = new R('https://example.com'); + u.save(); + r.persist(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-alias-imports/src/models.ts b/gitnexus/test/fixtures/lang-resolution/typescript-alias-imports/src/models.ts new file mode 100644 index 0000000000..3b69bb04e4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-alias-imports/src/models.ts @@ -0,0 +1,19 @@ +export class User { + name: string; + constructor(name: string) { + this.name = name; + } + save(): boolean { + return true; + } +} + +export class Repo { + url: string; + constructor(url: string) { + this.url = url; + } + persist(): boolean { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-ambiguous/src/logger.ts b/gitnexus/test/fixtures/lang-resolution/typescript-ambiguous/src/logger.ts new file mode 100644 index 0000000000..4263af297a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-ambiguous/src/logger.ts @@ -0,0 +1,7 @@ +import { ILogger } from './models'; + +export class ConsoleLogger implements ILogger { + log(message: string): void { + console.log(message); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-ambiguous/src/models.ts b/gitnexus/test/fixtures/lang-resolution/typescript-ambiguous/src/models.ts new file mode 100644 index 0000000000..fc690b3311 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-ambiguous/src/models.ts @@ -0,0 +1,11 @@ +export interface ILogger { + log(message: string): void; +} + +export class BaseService { + protected name: string = ''; + + getName(): string { + return this.name; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-ambiguous/src/service.ts b/gitnexus/test/fixtures/lang-resolution/typescript-ambiguous/src/service.ts new file mode 100644 index 0000000000..6bb6a51128 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-ambiguous/src/service.ts @@ -0,0 +1,13 @@ +import { BaseService, ILogger } from './models'; +import { ConsoleLogger } from './logger'; + +export class UserService extends BaseService implements ILogger { + log(message: string): void { + const logger = new ConsoleLogger(); + logger.log(message); + } + + getUsers(): string[] { + return []; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-calls/src/one.ts b/gitnexus/test/fixtures/lang-resolution/typescript-calls/src/one.ts new file mode 100644 index 0000000000..bd1af8d5a9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-calls/src/one.ts @@ -0,0 +1,3 @@ +export function writeAudit(message: string): string { + return message; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-calls/src/service.ts b/gitnexus/test/fixtures/lang-resolution/typescript-calls/src/service.ts new file mode 100644 index 0000000000..643b97c631 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-calls/src/service.ts @@ -0,0 +1,6 @@ +import { writeAudit } from './one'; +import { writeAudit as zeroWriteAudit } from './zero'; + +export function run(): string { + return writeAudit('hello'); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-calls/src/zero.ts b/gitnexus/test/fixtures/lang-resolution/typescript-calls/src/zero.ts new file mode 100644 index 0000000000..971fa9d8d4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-calls/src/zero.ts @@ -0,0 +1,3 @@ +export function writeAudit(): string { + return 'zero'; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-constructor-calls/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-constructor-calls/src/app.ts new file mode 100644 index 0000000000..96e0caef25 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-constructor-calls/src/app.ts @@ -0,0 +1,6 @@ +import { User } from './user'; + +export function processUser(name: string): void { + const user = new User(name); + user.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-constructor-calls/src/user.ts b/gitnexus/test/fixtures/lang-resolution/typescript-constructor-calls/src/user.ts new file mode 100644 index 0000000000..0b4d5f31ec --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-constructor-calls/src/user.ts @@ -0,0 +1,9 @@ +export class User { + name: string; + constructor(name: string) { + this.name = name; + } + save(): boolean { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-local-shadow/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-local-shadow/src/app.ts new file mode 100644 index 0000000000..2bffcd3554 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-local-shadow/src/app.ts @@ -0,0 +1,10 @@ +import { save } from './utils'; + +// Local definition shadows the imported one +function save(x: string): void { + console.log('local save:', x); +} + +function run(): void { + save('test'); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-local-shadow/src/utils.ts b/gitnexus/test/fixtures/lang-resolution/typescript-local-shadow/src/utils.ts new file mode 100644 index 0000000000..7b82ed99fa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-local-shadow/src/utils.ts @@ -0,0 +1,3 @@ +export function save(data: string): void { + console.log('utils save:', data); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-member-calls/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-member-calls/src/app.ts new file mode 100644 index 0000000000..0babfd37b7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-member-calls/src/app.ts @@ -0,0 +1,6 @@ +import { User } from './user'; + +export function processUser(): boolean { + const user = new User(); + return user.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-member-calls/src/user.ts b/gitnexus/test/fixtures/lang-resolution/typescript-member-calls/src/user.ts new file mode 100644 index 0000000000..e2af97ca77 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-member-calls/src/user.ts @@ -0,0 +1,5 @@ +export class User { + save(): boolean { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-named-imports/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-named-imports/src/app.ts new file mode 100644 index 0000000000..1205ec8af8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-named-imports/src/app.ts @@ -0,0 +1,5 @@ +import { formatData } from './format-upper'; + +export function processInput(): string { + return formatData('hello'); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-named-imports/src/format-prefix.ts b/gitnexus/test/fixtures/lang-resolution/typescript-named-imports/src/format-prefix.ts new file mode 100644 index 0000000000..617cf16dfb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-named-imports/src/format-prefix.ts @@ -0,0 +1,3 @@ +export function formatData(data: string, prefix: string): string { + return prefix + data; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-named-imports/src/format-upper.ts b/gitnexus/test/fixtures/lang-resolution/typescript-named-imports/src/format-upper.ts new file mode 100644 index 0000000000..7f688c57c3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-named-imports/src/format-upper.ts @@ -0,0 +1,3 @@ +export function formatData(data: string): string { + return data.toUpperCase(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-receiver-resolution/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-receiver-resolution/src/app.ts new file mode 100644 index 0000000000..228ddd50fb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-receiver-resolution/src/app.ts @@ -0,0 +1,9 @@ +import { User } from './user'; +import { Repo } from './repo'; + +export function processEntities(): void { + const user: User = new User(); + const repo: Repo = new Repo(); + user.save(); + repo.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-receiver-resolution/src/repo.ts b/gitnexus/test/fixtures/lang-resolution/typescript-receiver-resolution/src/repo.ts new file mode 100644 index 0000000000..19631246b7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-receiver-resolution/src/repo.ts @@ -0,0 +1,5 @@ +export class Repo { + save(): boolean { + return false; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-receiver-resolution/src/user.ts b/gitnexus/test/fixtures/lang-resolution/typescript-receiver-resolution/src/user.ts new file mode 100644 index 0000000000..e2af97ca77 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-receiver-resolution/src/user.ts @@ -0,0 +1,5 @@ +export class User { + save(): boolean { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-reexport-chain/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-reexport-chain/src/app.ts new file mode 100644 index 0000000000..d031fe5832 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-reexport-chain/src/app.ts @@ -0,0 +1,9 @@ +import { User, Repo } from './models'; + +function main(): void { + const user = new User(); + user.save(); + + const repo = new Repo(); + repo.persist(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-reexport-chain/src/base.ts b/gitnexus/test/fixtures/lang-resolution/typescript-reexport-chain/src/base.ts new file mode 100644 index 0000000000..b44acaacfd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-reexport-chain/src/base.ts @@ -0,0 +1,11 @@ +export class User { + save(): void { + console.log('saving user'); + } +} + +export class Repo { + persist(): void { + console.log('persisting repo'); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-reexport-chain/src/models.ts b/gitnexus/test/fixtures/lang-resolution/typescript-reexport-chain/src/models.ts new file mode 100644 index 0000000000..97eab7bc39 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-reexport-chain/src/models.ts @@ -0,0 +1,3 @@ +// Barrel re-export — no local definitions +export { User } from './base'; +export { Repo } from './base'; diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-reexport-type/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-reexport-type/src/app.ts new file mode 100644 index 0000000000..2ec38490b9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-reexport-type/src/app.ts @@ -0,0 +1,9 @@ +import type { User, Repo } from './models'; + +function main(): void { + const user = new User(); + user.save(); + + const repo = new Repo(); + repo.persist(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-reexport-type/src/base.ts b/gitnexus/test/fixtures/lang-resolution/typescript-reexport-type/src/base.ts new file mode 100644 index 0000000000..b44acaacfd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-reexport-type/src/base.ts @@ -0,0 +1,11 @@ +export class User { + save(): void { + console.log('saving user'); + } +} + +export class Repo { + persist(): void { + console.log('persisting repo'); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-reexport-type/src/models.ts b/gitnexus/test/fixtures/lang-resolution/typescript-reexport-type/src/models.ts new file mode 100644 index 0000000000..4bfe62a5da --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-reexport-type/src/models.ts @@ -0,0 +1,3 @@ +// Type-only re-exports +export type { User } from './base'; +export type { Repo } from './base'; diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-scoped-receiver/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-scoped-receiver/src/app.ts new file mode 100644 index 0000000000..be70c92b74 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-scoped-receiver/src/app.ts @@ -0,0 +1,10 @@ +import { User } from './user'; +import { Repo } from './repo'; + +export function handleUser(entity: User): void { + entity.save(); +} + +export function handleRepo(entity: Repo): void { + entity.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-scoped-receiver/src/repo.ts b/gitnexus/test/fixtures/lang-resolution/typescript-scoped-receiver/src/repo.ts new file mode 100644 index 0000000000..340937eb35 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-scoped-receiver/src/repo.ts @@ -0,0 +1,3 @@ +export class Repo { + save(): void {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-scoped-receiver/src/user.ts b/gitnexus/test/fixtures/lang-resolution/typescript-scoped-receiver/src/user.ts new file mode 100644 index 0000000000..a0a791e8e5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-scoped-receiver/src/user.ts @@ -0,0 +1,3 @@ +export class User { + save(): void {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-variadic-resolution/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-variadic-resolution/src/app.ts new file mode 100644 index 0000000000..dc3fb746f0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-variadic-resolution/src/app.ts @@ -0,0 +1,5 @@ +import { logEntry } from './logger'; + +export function processInput(): void { + logEntry('hello', 'world', 'test'); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-variadic-resolution/src/logger.ts b/gitnexus/test/fixtures/lang-resolution/typescript-variadic-resolution/src/logger.ts new file mode 100644 index 0000000000..4c8a7a6b71 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-variadic-resolution/src/logger.ts @@ -0,0 +1,3 @@ +export function logEntry(...messages: string[]): void { + console.log(messages.join(' ')); +} diff --git a/gitnexus/test/fixtures/sample-code/swift-extension.swift b/gitnexus/test/fixtures/sample-code/swift-extension.swift new file mode 100644 index 0000000000..7f7089f699 --- /dev/null +++ b/gitnexus/test/fixtures/sample-code/swift-extension.swift @@ -0,0 +1,16 @@ +protocol Greetable { + func greet() -> String +} + +class Person { + var name: String + init(name: String) { + self.name = name + } +} + +extension Person: Greetable { + func greet() -> String { + return "Hello, \(name)" + } +} diff --git a/gitnexus/test/integration/has-method.test.ts b/gitnexus/test/integration/has-method.test.ts new file mode 100644 index 0000000000..5e948e48d9 --- /dev/null +++ b/gitnexus/test/integration/has-method.test.ts @@ -0,0 +1,531 @@ +/** + * Integration tests for HAS_METHOD edge extraction. + * + * These tests exercise findEnclosingClassId against real tree-sitter ASTs + * produced by the actual parser pipeline (loadParser + loadLanguage + queries). + * Unlike the unit tests that test findEnclosingClassId in isolation with simple + * snippets, these focus on multi-class files, interface vs class disambiguation, + * and cross-language pipeline correctness. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import Parser from 'tree-sitter'; +import { loadParser, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js'; +import { LANGUAGE_QUERIES } from '../../src/core/ingestion/tree-sitter-queries.js'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; +import { + findEnclosingClassId, + DEFINITION_CAPTURE_KEYS, + getDefinitionNodeFromCaptures, +} from '../../src/core/ingestion/utils.js'; + +let parser: Parser; + +beforeAll(async () => { + parser = await loadParser(); +}); + +/** Parse code with given language, run definition queries, return matched definitions with their enclosing class IDs. */ +function parseAndExtractMethods( + code: string, + lang: SupportedLanguages, + filePath: string, +): { name: string; defType: string; enclosingClassId: string | null }[] { + const tree = parser.parse(code); + const query = new Parser.Query(parser.getLanguage(), LANGUAGE_QUERIES[lang]); + const matches = query.matches(tree.rootNode); + + const results: { name: string; defType: string; enclosingClassId: string | null }[] = []; + + for (const match of matches) { + const captureMap: Record = {}; + let nameNode: any = null; + + for (const capture of match.captures) { + captureMap[capture.name] = capture.node; + if (capture.name === 'name') { + nameNode = capture.node; + } + } + + const defNode = getDefinitionNodeFromCaptures(captureMap); + if (!defNode || !nameNode) continue; + + const defType = Object.keys(captureMap).find(k => k.startsWith('definition.')) || 'unknown'; + const enclosingClassId = findEnclosingClassId(nameNode, filePath); + + results.push({ + name: nameNode.text, + defType, + enclosingClassId, + }); + } + + return results; +} + +describe('HAS_METHOD integration — C#: class with interface', () => { + beforeAll(async () => { + await loadLanguage(SupportedLanguages.CSharp); + }); + + it('methods link to correct owner (interface vs class)', () => { + const code = ` +interface IRepository { + void FindById(int id); + void Save(object entity); +} + +class SqlRepository { + public void FindById(int id) {} + public void Save(object entity) {} + private void Connect() {} +} +`; + const results = parseAndExtractMethods(code, SupportedLanguages.CSharp, 'src/Repo.cs'); + + // Interface methods should be enclosed by the interface + const ifaceFindById = results.find(r => r.name === 'FindById' && r.enclosingClassId?.startsWith('Interface:')); + expect(ifaceFindById).toBeDefined(); + expect(ifaceFindById!.enclosingClassId).toBe('Interface:src/Repo.cs:IRepository'); + + const ifaceSave = results.find(r => r.name === 'Save' && r.enclosingClassId?.startsWith('Interface:')); + expect(ifaceSave).toBeDefined(); + expect(ifaceSave!.enclosingClassId).toBe('Interface:src/Repo.cs:IRepository'); + + // Class methods should be enclosed by the class + const classFindById = results.find(r => r.name === 'FindById' && r.enclosingClassId?.startsWith('Class:')); + expect(classFindById).toBeDefined(); + expect(classFindById!.enclosingClassId).toBe('Class:src/Repo.cs:SqlRepository'); + + const classConnect = results.find(r => r.name === 'Connect'); + expect(classConnect).toBeDefined(); + expect(classConnect!.enclosingClassId).toBe('Class:src/Repo.cs:SqlRepository'); + }); + + it('class/interface name captures point to their own container (self-referential)', () => { + const code = ` +interface IService { + void Execute(); +} + +class ServiceImpl { + public void Execute() {} +} +`; + const results = parseAndExtractMethods(code, SupportedLanguages.CSharp, 'src/Service.cs'); + + // The name node for IService sits inside the interface_declaration, so + // findEnclosingClassId returns the interface itself. This is expected — + // the pipeline uses defType (definition.interface vs definition.method) to + // distinguish container declarations from methods, not enclosingClassId. + const ifaceDecl = results.find(r => r.name === 'IService'); + expect(ifaceDecl).toBeDefined(); + expect(ifaceDecl!.defType).toBe('definition.interface'); + + const classDecl = results.find(r => r.name === 'ServiceImpl'); + expect(classDecl).toBeDefined(); + expect(classDecl!.defType).toBe('definition.class'); + + // Methods should still correctly reference their container + const execMethods = results.filter(r => r.name === 'Execute'); + expect(execMethods.length).toBe(2); + expect(execMethods.some(r => r.enclosingClassId === 'Interface:src/Service.cs:IService')).toBe(true); + expect(execMethods.some(r => r.enclosingClassId === 'Class:src/Service.cs:ServiceImpl')).toBe(true); + }); +}); + +describe('HAS_METHOD integration — Rust: impl + trait', () => { + beforeAll(async () => { + await loadLanguage(SupportedLanguages.Rust); + }); + + it('methods link to impl vs trait nodes', () => { + const code = ` +trait Drawable { + fn draw(&self); + fn resize(&self, w: u32, h: u32); +} + +struct Circle { + radius: f64, +} + +impl Circle { + fn new(radius: f64) -> Circle { + Circle { radius } + } + + fn area(&self) -> f64 { + 3.14 * self.radius * self.radius + } +} +`; + const results = parseAndExtractMethods(code, SupportedLanguages.Rust, 'src/shapes.rs'); + + // Trait methods should be enclosed by the trait + const traitDraw = results.find(r => r.name === 'draw'); + if (traitDraw) { + expect(traitDraw.enclosingClassId).toBe('Trait:src/shapes.rs:Drawable'); + } + + const traitResize = results.find(r => r.name === 'resize'); + if (traitResize) { + expect(traitResize.enclosingClassId).toBe('Trait:src/shapes.rs:Drawable'); + } + + // Impl methods should be enclosed by the impl block + const implNew = results.find(r => r.name === 'new'); + if (implNew) { + expect(implNew.enclosingClassId).toBe('Impl:src/shapes.rs:Circle'); + } + + const implArea = results.find(r => r.name === 'area'); + if (implArea) { + expect(implArea.enclosingClassId).toBe('Impl:src/shapes.rs:Circle'); + } + }); + + it('standalone functions do not get HAS_METHOD', () => { + const code = ` +fn helper() -> bool { + true +} + +struct Foo; + +impl Foo { + fn bar(&self) {} +} +`; + const results = parseAndExtractMethods(code, SupportedLanguages.Rust, 'src/lib.rs'); + + const helper = results.find(r => r.name === 'helper'); + expect(helper).toBeDefined(); + expect(helper!.enclosingClassId).toBeNull(); + + const bar = results.find(r => r.name === 'bar'); + if (bar) { + expect(bar.enclosingClassId).toBe('Impl:src/lib.rs:Foo'); + } + }); +}); + +describe('HAS_METHOD integration — Python: class methods vs standalone functions', () => { + beforeAll(async () => { + await loadLanguage(SupportedLanguages.Python); + }); + + it('methods link to class, standalone functions get null', () => { + const code = ` +def standalone_helper(): + return 42 + +class Calculator: + def __init__(self): + self.value = 0 + + def add(self, x): + self.value += x + return self + + def result(self): + return self.value + +def another_standalone(): + pass +`; + const results = parseAndExtractMethods(code, SupportedLanguages.Python, 'src/calc.py'); + + // Standalone functions should not be enclosed + const standaloneHelper = results.find(r => r.name === 'standalone_helper'); + expect(standaloneHelper).toBeDefined(); + expect(standaloneHelper!.enclosingClassId).toBeNull(); + + const anotherStandalone = results.find(r => r.name === 'another_standalone'); + expect(anotherStandalone).toBeDefined(); + expect(anotherStandalone!.enclosingClassId).toBeNull(); + + // Class methods should be enclosed + const init = results.find(r => r.name === '__init__'); + expect(init).toBeDefined(); + expect(init!.enclosingClassId).toBe('Class:src/calc.py:Calculator'); + + const add = results.find(r => r.name === 'add'); + expect(add).toBeDefined(); + expect(add!.enclosingClassId).toBe('Class:src/calc.py:Calculator'); + + const resultMethod = results.find(r => r.name === 'result'); + expect(resultMethod).toBeDefined(); + expect(resultMethod!.enclosingClassId).toBe('Class:src/calc.py:Calculator'); + }); +}); + +describe('HAS_METHOD integration — Multiple classes in one file', () => { + describe('TypeScript', () => { + beforeAll(async () => { + await loadLanguage(SupportedLanguages.TypeScript, 'multi.ts'); + }); + + it('methods associate with their owning class', () => { + const code = ` +class UserService { + findUser(id: number) { + return null; + } + deleteUser(id: number) {} +} + +class OrderService { + createOrder(data: any) { + return data; + } + cancelOrder(id: number) {} +} + +function topLevelUtil() { + return true; +} +`; + const results = parseAndExtractMethods(code, SupportedLanguages.TypeScript, 'src/services.ts'); + + // UserService methods + const findUser = results.find(r => r.name === 'findUser'); + expect(findUser).toBeDefined(); + expect(findUser!.enclosingClassId).toBe('Class:src/services.ts:UserService'); + + const deleteUser = results.find(r => r.name === 'deleteUser'); + expect(deleteUser).toBeDefined(); + expect(deleteUser!.enclosingClassId).toBe('Class:src/services.ts:UserService'); + + // OrderService methods + const createOrder = results.find(r => r.name === 'createOrder'); + expect(createOrder).toBeDefined(); + expect(createOrder!.enclosingClassId).toBe('Class:src/services.ts:OrderService'); + + const cancelOrder = results.find(r => r.name === 'cancelOrder'); + expect(cancelOrder).toBeDefined(); + expect(cancelOrder!.enclosingClassId).toBe('Class:src/services.ts:OrderService'); + + // Top-level function + const topLevelUtil = results.find(r => r.name === 'topLevelUtil'); + expect(topLevelUtil).toBeDefined(); + expect(topLevelUtil!.enclosingClassId).toBeNull(); + }); + }); + + describe('Java', () => { + beforeAll(async () => { + await loadLanguage(SupportedLanguages.Java); + }); + + it('methods associate with their owning class', () => { + const code = ` +class Logger { + public void info(String msg) {} + public void error(String msg) {} +} + +class Formatter { + public String format(String template) { return template; } + private String escape(String input) { return input; } +} +`; + const results = parseAndExtractMethods(code, SupportedLanguages.Java, 'src/util/Logging.java'); + + const info = results.find(r => r.name === 'info'); + expect(info).toBeDefined(); + expect(info!.enclosingClassId).toBe('Class:src/util/Logging.java:Logger'); + + const error = results.find(r => r.name === 'error'); + expect(error).toBeDefined(); + expect(error!.enclosingClassId).toBe('Class:src/util/Logging.java:Logger'); + + const format = results.find(r => r.name === 'format'); + expect(format).toBeDefined(); + expect(format!.enclosingClassId).toBe('Class:src/util/Logging.java:Formatter'); + + const escape = results.find(r => r.name === 'escape'); + expect(escape).toBeDefined(); + expect(escape!.enclosingClassId).toBe('Class:src/util/Logging.java:Formatter'); + }); + }); +}); + +describe('HAS_METHOD integration — Java: class with interface', () => { + beforeAll(async () => { + await loadLanguage(SupportedLanguages.Java); + }); + + it('methods link to correct owner (interface vs class)', () => { + const code = ` +interface Validator { + boolean validate(Object input); + String getMessage(); +} + +class EmailValidator { + public boolean validate(Object input) { return true; } + public String getMessage() { return "invalid email"; } + private boolean checkFormat(String email) { return true; } +} +`; + const results = parseAndExtractMethods(code, SupportedLanguages.Java, 'src/validation/Validator.java'); + + // Interface methods + const ifaceValidate = results.find(r => r.name === 'validate' && r.enclosingClassId?.startsWith('Interface:')); + expect(ifaceValidate).toBeDefined(); + expect(ifaceValidate!.enclosingClassId).toBe('Interface:src/validation/Validator.java:Validator'); + + const ifaceGetMessage = results.find(r => r.name === 'getMessage' && r.enclosingClassId?.startsWith('Interface:')); + expect(ifaceGetMessage).toBeDefined(); + expect(ifaceGetMessage!.enclosingClassId).toBe('Interface:src/validation/Validator.java:Validator'); + + // Class methods + const classValidate = results.find(r => r.name === 'validate' && r.enclosingClassId?.startsWith('Class:')); + expect(classValidate).toBeDefined(); + expect(classValidate!.enclosingClassId).toBe('Class:src/validation/Validator.java:EmailValidator'); + + const classCheckFormat = results.find(r => r.name === 'checkFormat'); + expect(classCheckFormat).toBeDefined(); + expect(classCheckFormat!.enclosingClassId).toBe('Class:src/validation/Validator.java:EmailValidator'); + }); + + it('class/interface declarations are captured with correct defType', () => { + const code = ` +interface Repository { + void save(Object entity); +} + +class UserRepository { + public void save(Object entity) {} +} +`; + const results = parseAndExtractMethods(code, SupportedLanguages.Java, 'src/repo/Repo.java'); + + // The pipeline distinguishes containers from methods via defType, not enclosingClassId + const repoDecl = results.find(r => r.name === 'Repository'); + expect(repoDecl).toBeDefined(); + expect(repoDecl!.defType).toBe('definition.interface'); + + const userRepoDecl = results.find(r => r.name === 'UserRepository'); + expect(userRepoDecl).toBeDefined(); + expect(userRepoDecl!.defType).toBe('definition.class'); + + // Methods associate correctly + const saveMethods = results.filter(r => r.name === 'save'); + expect(saveMethods.length).toBe(2); + expect(saveMethods.some(r => r.enclosingClassId === 'Interface:src/repo/Repo.java:Repository')).toBe(true); + expect(saveMethods.some(r => r.enclosingClassId === 'Class:src/repo/Repo.java:UserRepository')).toBe(true); + }); +}); + +describe('HAS_METHOD integration — C++ class methods', () => { + beforeAll(async () => { + await loadLanguage(SupportedLanguages.CPlusPlus); + }); + + it('inline methods link to their owning class_specifier', () => { + const code = ` +class Stack { +public: + void push(int val) { data[top++] = val; } + int pop() { return data[--top]; } + int size() { return top; } +private: + int data[100]; + int top; +}; + +class Queue { +public: + void enqueue(int val) {} + int dequeue() { return 0; } +}; +`; + const results = parseAndExtractMethods(code, SupportedLanguages.CPlusPlus, 'src/containers.h'); + + // Stack methods + const push = results.find(r => r.name === 'push'); + if (push) { + expect(push.enclosingClassId).toBe('Class:src/containers.h:Stack'); + } + + const pop = results.find(r => r.name === 'pop'); + if (pop) { + expect(pop.enclosingClassId).toBe('Class:src/containers.h:Stack'); + } + + const size = results.find(r => r.name === 'size'); + if (size) { + expect(size.enclosingClassId).toBe('Class:src/containers.h:Stack'); + } + + // Queue methods + const enqueue = results.find(r => r.name === 'enqueue'); + if (enqueue) { + expect(enqueue.enclosingClassId).toBe('Class:src/containers.h:Queue'); + } + + const dequeue = results.find(r => r.name === 'dequeue'); + if (dequeue) { + expect(dequeue.enclosingClassId).toBe('Class:src/containers.h:Queue'); + } + }); + + it('free functions have null enclosingClassId', () => { + const code = ` +void freeFunction() {} + +class Foo { +public: + void method() {} +}; +`; + const results = parseAndExtractMethods(code, SupportedLanguages.CPlusPlus, 'src/mixed.cpp'); + + const freeFn = results.find(r => r.name === 'freeFunction'); + if (freeFn) { + expect(freeFn.enclosingClassId).toBeNull(); + } + + const method = results.find(r => r.name === 'method'); + if (method) { + expect(method.enclosingClassId).toBe('Class:src/mixed.cpp:Foo'); + } + }); +}); + +describe('HAS_METHOD integration — C# struct and record', () => { + beforeAll(async () => { + await loadLanguage(SupportedLanguages.CSharp); + }); + + it('struct methods link to struct, record methods link to record', () => { + const code = ` +struct Vector2 { + public float Length() { return 0; } + public Vector2 Normalize() { return this; } +} + +record Person { + public string GetFullName() { return ""; } +} +`; + const results = parseAndExtractMethods(code, SupportedLanguages.CSharp, 'src/Types.cs'); + + const length = results.find(r => r.name === 'Length'); + if (length) { + expect(length.enclosingClassId).toBe('Struct:src/Types.cs:Vector2'); + } + + const normalize = results.find(r => r.name === 'Normalize'); + if (normalize) { + expect(normalize.enclosingClassId).toBe('Struct:src/Types.cs:Vector2'); + } + + const getFullName = results.find(r => r.name === 'GetFullName'); + if (getFullName) { + expect(getFullName.enclosingClassId).toBe('Record:src/Types.cs:Person'); + } + }); +}); diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts new file mode 100644 index 0000000000..863fc84427 --- /dev/null +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -0,0 +1,286 @@ +/** + * C++: diamond inheritance + include-based imports + ambiguous #include disambiguation + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, getRelationships, getNodesByLabel, edgeSet, + runPipelineFromRepo, type PipelineResult, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Heritage: diamond inheritance + include-based imports +// --------------------------------------------------------------------------- + +describe('C++ diamond inheritance', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-diamond'), + () => {}, + ); + }, 60000); + + it('detects exactly 4 classes in diamond hierarchy', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Animal', 'Duck', 'Flyer', 'Swimmer']); + }); + + it('emits exactly 4 EXTENDS edges for full diamond', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(4); + expect(edgeSet(extends_)).toEqual([ + 'Duck → Flyer', + 'Duck → Swimmer', + 'Flyer → Animal', + 'Swimmer → Animal', + ]); + }); + + it('resolves all 5 #include imports between header/source files', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(5); + expect(edgeSet(imports)).toEqual([ + 'duck.cpp → duck.h', + 'duck.h → flyer.h', + 'duck.h → swimmer.h', + 'flyer.h → animal.h', + 'swimmer.h → animal.h', + ]); + }); + + it('captures 1 Method node from duck.cpp (speak)', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toEqual(['speak']); + }); + + it('no OVERRIDES edges target Property nodes', () => { + const overrides = getRelationships(result, 'OVERRIDES'); + for (const edge of overrides) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.label).not.toBe('Property'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Ambiguous: two headers with same class name, #include disambiguates +// --------------------------------------------------------------------------- + +describe('C++ ambiguous symbol resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-ambiguous'), + () => {}, + ); + }, 60000); + + it('detects 2 Handler classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes.filter(n => n === 'Handler').length).toBe(2); + expect(classes).toContain('Processor'); + }); + + it('resolves EXTENDS to handler_a.h (not handler_b.h)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('Processor'); + expect(extends_[0].target).toBe('Handler'); + expect(extends_[0].targetFilePath).toBe('handler_a.h'); + }); + + it('#include resolves to handler_a.h', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(1); + expect(imports[0].targetFilePath).toBe('handler_a.h'); + }); + + it('all heritage edges point to real graph nodes', () => { + for (const edge of getRelationships(result, 'EXTENDS')) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.properties.name).toBe(edge.target); + } + }); +}); + +describe('C++ call resolution with arity filtering', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-calls'), + () => {}, + ); + }, 60000); + + it('resolves run → write_audit to one.h via arity narrowing', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.length).toBe(1); + expect(calls[0].source).toBe('run'); + expect(calls[0].target).toBe('write_audit'); + expect(calls[0].targetFilePath).toBe('one.h'); + expect(calls[0].rel.reason).toBe('import-resolved'); + }); +}); + +// --------------------------------------------------------------------------- +// Member-call resolution: obj.method() resolves through pipeline +// --------------------------------------------------------------------------- + +describe('C++ member-call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-member-calls'), + () => {}, + ); + }, 60000); + + it('resolves processUser → save as a member call on User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('processUser'); + expect(saveCall!.targetFilePath).toBe('user.h'); + }); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('emits HAS_METHOD edge from User to save', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const edge = hasMethod.find(e => e.source === 'User' && e.target === 'save'); + expect(edge).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Constructor resolution: new Foo() resolves to Class +// --------------------------------------------------------------------------- + +describe('C++ constructor-call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-constructor-calls'), + () => {}, + ); + }, 60000); + + it('resolves new User() as a CALLS edge to the User class', () => { + const calls = getRelationships(result, 'CALLS'); + const ctorCall = calls.find(c => c.target === 'User'); + expect(ctorCall).toBeDefined(); + expect(ctorCall!.source).toBe('processUser'); + expect(ctorCall!.targetLabel).toBe('Class'); + expect(ctorCall!.targetFilePath).toBe('user.h'); + expect(ctorCall!.rel.reason).toBe('import-resolved'); + }); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves #include import', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(1); + expect(imports[0].targetFilePath).toBe('user.h'); + }); +}); + +// --------------------------------------------------------------------------- +// Receiver-constrained resolution: typed variables disambiguate same-named methods +// --------------------------------------------------------------------------- + +describe('C++ receiver-constrained resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-receiver-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + 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', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + + const userSave = saveCalls.find(c => c.targetFilePath === 'user.h'); + const repoSave = saveCalls.find(c => c.targetFilePath === 'repo.h'); + + expect(userSave).toBeDefined(); + expect(repoSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + expect(repoSave!.source).toBe('processEntities'); + }); +}); + +// --------------------------------------------------------------------------- +// Variadic resolution: C-style variadic (...) doesn't get filtered by arity +// --------------------------------------------------------------------------- + +describe('C++ variadic call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-variadic-resolution'), + () => {}, + ); + }, 60000); + + it('resolves 3-arg call to variadic function log_entry(const char*, ...) in logger.h', () => { + const calls = getRelationships(result, 'CALLS'); + const logCall = calls.find(c => c.target === 'log_entry'); + expect(logCall).toBeDefined(); + expect(logCall!.source).toBe('main'); + expect(logCall!.targetFilePath).toBe('logger.h'); + }); +}); + +// --------------------------------------------------------------------------- +// Local shadow: same-file definition takes priority over imported name +// --------------------------------------------------------------------------- + +describe('C++ local definition shadows import', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-local-shadow'), + () => {}, + ); + }, 60000); + + it('resolves run → save to same-file definition, not the imported one', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'run'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('src/main.cpp'); + }); + + it('does NOT resolve save to utils.h', () => { + const calls = getRelationships(result, 'CALLS'); + const saveToUtils = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/utils.h'); + expect(saveToUtils).toBeUndefined(); + }); +}); + diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts new file mode 100644 index 0000000000..efe38749bb --- /dev/null +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -0,0 +1,388 @@ +/** + * C#: heritage resolution via base_list + ambiguous namespace-import refusal + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, getRelationships, getNodesByLabel, edgeSet, + runPipelineFromRepo, type PipelineResult, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Heritage: class + interface resolution via base_list +// --------------------------------------------------------------------------- + +describe('C# heritage resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-proj'), + () => {}, + ); + }, 60000); + + it('detects exactly 3 classes and 2 interfaces', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseEntity', 'User', 'UserService']); + expect(getNodesByLabel(result, 'Interface')).toEqual(['ILogger', 'IRepository']); + }); + + it('emits exactly 1 EXTENDS edge: User → BaseEntity', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseEntity'); + }); + + it('emits exactly 1 IMPLEMENTS edge: User → IRepository', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(1); + expect(implements_[0].source).toBe('User'); + expect(implements_[0].target).toBe('IRepository'); + }); + + it('emits CALLS edges from CreateUser (constructor + member calls)', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.length).toBe(4); + const targets = edgeSet(calls); + expect(targets).toContain('CreateUser → User'); // new User() constructor + expect(targets).toContain('CreateUser → Validate'); // user.Validate() — receiver-typed + expect(targets).toContain('CreateUser → Save'); // _repo.Save() — receiver-typed + expect(targets).toContain('CreateUser → Log'); // _logger.Log() — receiver-typed + }); + + it('resolves all CALLS from CreateUser via import-resolved or unique-global', () => { + const calls = getRelationships(result, 'CALLS'); + // C# non-aliased `using Namespace;` imports don't populate NamedImportMap + // (namespace-scoped imports can't bind to individual symbols). + // Calls resolve via directory-based PackageMap (import-resolved) when ambiguous, + // or via unique-global when the symbol name is globally unique. + for (const call of calls) { + expect(['import-resolved', 'unique-global']).toContain(call.rel.reason); + } + }); + + it('resolves new User() to the User class via constructor discrimination', () => { + const calls = getRelationships(result, 'CALLS'); + const ctorCall = calls.find(c => c.target === 'User'); + expect(ctorCall).toBeDefined(); + expect(ctorCall!.targetLabel).toBe('Class'); + }); + + it('detects 4 namespaces', () => { + const ns = getNodesByLabel(result, 'Namespace'); + expect(ns.length).toBe(4); + }); + + it('detects properties on classes', () => { + const props = getNodesByLabel(result, 'Property'); + expect(props).toContain('Id'); + expect(props).toContain('Name'); + }); + + it('no OVERRIDES edges target Property nodes', () => { + const overrides = getRelationships(result, 'OVERRIDES'); + for (const edge of overrides) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.label).not.toBe('Property'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Ambiguous: using-namespace can't disambiguate same-named types +// --------------------------------------------------------------------------- + +describe('C# ambiguous symbol resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-ambiguous'), + () => {}, + ); + }, 60000); + + it('detects 2 Handler classes and 2 IProcessor interfaces', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes.filter(n => n === 'Handler').length).toBe(2); + const ifaces = getNodesByLabel(result, 'Interface'); + expect(ifaces.filter(n => n === 'IProcessor').length).toBe(2); + }); + + it('heritage targets are synthetic (correct refusal for ambiguous namespace import)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const implements_ = getRelationships(result, 'IMPLEMENTS'); + + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('UserHandler'); + expect(implements_.length).toBe(1); + expect(implements_[0].source).toBe('UserHandler'); + + // The key invariant: no edge points to Other/ + if (extends_[0].targetFilePath) { + expect(extends_[0].targetFilePath).not.toMatch(/Other\//); + } + if (implements_[0].targetFilePath) { + expect(implements_[0].targetFilePath).not.toMatch(/Other\//); + } + }); +}); + +describe('C# call resolution with arity filtering', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-calls'), + () => {}, + ); + }, 60000); + + it('resolves CreateUser → WriteAudit to Utils/OneArg.cs via arity narrowing', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.length).toBe(1); + expect(calls[0].source).toBe('CreateUser'); + expect(calls[0].target).toBe('WriteAudit'); + expect(calls[0].targetFilePath).toBe('Utils/OneArg.cs'); + expect(calls[0].rel.reason).toBe('import-resolved'); + }); +}); + +// --------------------------------------------------------------------------- +// Member-call resolution: obj.Method() resolves through pipeline +// --------------------------------------------------------------------------- + +describe('C# member-call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-member-calls'), + () => {}, + ); + }, 60000); + + it('resolves ProcessUser → Save as a member call on User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'Save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('ProcessUser'); + expect(saveCall!.targetFilePath).toBe('Models/User.cs'); + }); + + it('detects User class and Save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('Save'); + }); + + it('emits HAS_METHOD edge from User to Save', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const edge = hasMethod.find(e => e.source === 'User' && e.target === 'Save'); + expect(edge).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Primary constructor resolution: class User(string name, int age) { } +// --------------------------------------------------------------------------- + +describe('C# primary constructor resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-primary-ctors'), + () => {}, + ); + }, 60000); + + it('detects Constructor nodes for primary constructors on class and record', () => { + const ctors = getNodesByLabel(result, 'Constructor'); + expect(ctors).toContain('User'); + expect(ctors).toContain('Person'); + }); + + it('primary constructor has correct parameter count', () => { + let userCtorParams: number | undefined; + let personCtorParams: number | undefined; + result.graph.forEachNode(n => { + if (n.label === 'Constructor' && n.properties.name === 'User') { + userCtorParams = n.properties.parameterCount as number; + } + if (n.label === 'Constructor' && n.properties.name === 'Person') { + personCtorParams = n.properties.parameterCount as number; + } + }); + expect(userCtorParams).toBe(2); + expect(personCtorParams).toBe(2); + }); + + it('resolves new User(...) as a CALLS edge to the Constructor node', () => { + const calls = getRelationships(result, 'CALLS'); + const ctorCall = calls.find(c => c.target === 'User'); + expect(ctorCall).toBeDefined(); + expect(ctorCall!.source).toBe('Run'); + expect(ctorCall!.targetLabel).toBe('Constructor'); + expect(ctorCall!.targetFilePath).toBe('Models/User.cs'); + }); + + it('also resolves user.Save() as a method call', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'Save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('Run'); + }); + + it('emits HAS_METHOD edge from User class to User constructor', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const edge = hasMethod.find(e => e.source === 'User' && e.target === 'User'); + expect(edge).toBeDefined(); + }); + + it('emits HAS_METHOD edge from Person record to Person constructor', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const edge = hasMethod.find(e => e.source === 'Person' && e.target === 'Person'); + expect(edge).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Receiver-constrained resolution: typed variables disambiguate same-named methods +// --------------------------------------------------------------------------- + +describe('C# receiver-constrained resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-receiver-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with Save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + 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', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'Save'); + expect(saveCalls.length).toBe(2); + + const userSave = saveCalls.find(c => c.targetFilePath === 'Models/User.cs'); + const repoSave = saveCalls.find(c => c.targetFilePath === 'Models/Repo.cs'); + + expect(userSave).toBeDefined(); + expect(repoSave).toBeDefined(); + expect(userSave!.source).toBe('ProcessEntities'); + expect(repoSave!.source).toBe('ProcessEntities'); + }); + + it('resolves constructor calls for both User and Repo', () => { + const calls = getRelationships(result, 'CALLS'); + const userCtor = calls.find(c => c.target === 'User'); + const repoCtor = calls.find(c => c.target === 'Repo'); + expect(userCtor).toBeDefined(); + expect(repoCtor).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Alias import resolution: using U = Models.User resolves U → User +// --------------------------------------------------------------------------- + +describe('C# alias import resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-alias-imports'), + () => {}, + ); + }, 60000); + + it('detects Main, Repo, and User classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Main', 'Repo', 'User']); + }); + + it('resolves u.Save() to User.cs and r.Persist() to Repo.cs via alias', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'Save'); + const persistCall = calls.find(c => c.target === 'Persist'); + + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('Run'); + expect(saveCall!.targetLabel).toBe('Method'); + expect(saveCall!.targetFilePath).toBe('Models/User.cs'); + + expect(persistCall).toBeDefined(); + expect(persistCall!.source).toBe('Run'); + expect(persistCall!.targetLabel).toBe('Method'); + expect(persistCall!.targetFilePath).toBe('Models/Repo.cs'); + }); + + it('emits exactly 2 IMPORTS edges via alias resolution', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(2); + expect(edgeSet(imports)).toEqual([ + 'Main.cs → Repo.cs', + 'Main.cs → User.cs', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Variadic resolution: params string[] doesn't get filtered by arity +// --------------------------------------------------------------------------- + +describe('C# variadic call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-variadic-resolution'), + () => {}, + ); + }, 60000); + + it('resolves call to params method Record(params string[]) in Logger.cs', () => { + const calls = getRelationships(result, 'CALLS'); + const logCall = calls.find(c => c.target === 'Record'); + expect(logCall).toBeDefined(); + expect(logCall!.source).toBe('Execute'); + expect(logCall!.targetFilePath).toBe('Utils/Logger.cs'); + }); +}); + +// --------------------------------------------------------------------------- +// Local shadow: same-file definition takes priority over imported name +// --------------------------------------------------------------------------- + +describe('C# local definition shadows import', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-local-shadow'), + () => {}, + ); + }, 60000); + + it('resolves Run → Save to same-file definition, not the imported one', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'Save' && c.source === 'Run'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('App/Main.cs'); + }); + + it('does NOT resolve Save to Logger.cs', () => { + const calls = getRelationships(result, 'CALLS'); + const saveToUtils = calls.find(c => c.target === 'Save' && c.targetFilePath === 'Utils/Logger.cs'); + expect(saveToUtils).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts new file mode 100644 index 0000000000..3bb51dbb9a --- /dev/null +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -0,0 +1,345 @@ +/** + * Go: package imports + cross-package calls + ambiguous struct disambiguation + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, getRelationships, getNodesByLabel, edgeSet, + runPipelineFromRepo, type PipelineResult, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Heritage: package imports + cross-package calls (exercises PackageMap) +// --------------------------------------------------------------------------- + +describe('Go package import & call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-pkg'), + () => {}, + ); + }, 60000); + + it('detects exactly 2 structs and 1 interface', () => { + expect(getNodesByLabel(result, 'Struct')).toEqual(['Admin', 'User']); + expect(getNodesByLabel(result, 'Interface')).toEqual(['Repository']); + }); + + it('detects exactly 5 functions', () => { + expect(getNodesByLabel(result, 'Function')).toEqual([ + 'Authenticate', 'NewAdmin', 'NewUser', 'ValidateToken', 'main', + ]); + }); + + it('emits exactly 7 CALLS edges (5 function + 2 struct literal)', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.length).toBe(7); + expect(edgeSet(calls)).toEqual([ + 'Authenticate → NewUser', + 'NewAdmin → Admin', + 'NewAdmin → NewUser', + 'NewUser → User', + 'main → Authenticate', + 'main → NewAdmin', + 'main → NewUser', + ]); + }); + + it('resolves exactly 7 IMPORTS edges across Go packages', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(7); + expect(edgeSet(imports)).toEqual([ + 'main.go → admin.go', + 'main.go → repository.go', + 'main.go → service.go', + 'main.go → user.go', + 'service.go → admin.go', + 'service.go → repository.go', + 'service.go → user.go', + ]); + }); + + it('emits exactly 1 EXTENDS edge for struct embedding: Admin → User', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('Admin'); + expect(extends_[0].target).toBe('User'); + }); + + it('does not emit IMPLEMENTS edges (Go uses structural typing)', () => { + expect(getRelationships(result, 'IMPLEMENTS').length).toBe(0); + }); + + it('no OVERRIDES edges target Property nodes', () => { + const overrides = getRelationships(result, 'OVERRIDES'); + for (const edge of overrides) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.label).not.toBe('Property'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Ambiguous: Handler struct in two packages, package import disambiguates +// --------------------------------------------------------------------------- + +describe('Go ambiguous symbol resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-ambiguous'), + () => {}, + ); + }, 60000); + + it('detects 2 Handler structs in separate packages', () => { + const structs: string[] = []; + result.graph.forEachNode(n => { + if (n.label === 'Struct') structs.push(`${n.properties.name}@${n.properties.filePath}`); + }); + const handlers = structs.filter(s => s.startsWith('Handler@')); + expect(handlers.length).toBe(2); + expect(handlers.some(h => h.includes('internal/models/'))).toBe(true); + expect(handlers.some(h => h.includes('internal/other/'))).toBe(true); + }); + + it('import resolves to internal/models/handler.go (not internal/other/)', () => { + const imports = getRelationships(result, 'IMPORTS'); + const modelsImport = imports.find(e => e.targetFilePath.includes('models')); + expect(modelsImport).toBeDefined(); + expect(modelsImport!.targetFilePath).toBe('internal/models/handler.go'); + }); + + it('no import edge to internal/other/', () => { + const imports = getRelationships(result, 'IMPORTS'); + for (const imp of imports) { + expect(imp.targetFilePath).not.toMatch(/internal\/other\//); + } + }); +}); + +describe('Go call resolution with arity filtering', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-calls'), + () => {}, + ); + }, 60000); + + it('resolves main → WriteAudit to internal/onearg/log.go via arity narrowing', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.length).toBe(1); + expect(calls[0].source).toBe('main'); + expect(calls[0].target).toBe('WriteAudit'); + expect(calls[0].targetFilePath).toBe('internal/onearg/log.go'); + expect(calls[0].rel.reason).toBe('import-resolved'); + }); +}); + +// --------------------------------------------------------------------------- +// Member-call resolution: obj.Method() resolves through pipeline +// --------------------------------------------------------------------------- + +describe('Go member-call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-member-calls'), + () => {}, + ); + }, 60000); + + it('resolves processUser → Save as a member call on User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'Save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('processUser'); + expect(saveCall!.targetFilePath).toBe('models/user.go'); + }); + + it('detects User struct and Save method', () => { + const structs: string[] = []; + result.graph.forEachNode(n => { + if (n.label === 'Struct') structs.push(n.properties.name); + }); + expect(structs).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('Save'); + }); +}); + +// --------------------------------------------------------------------------- +// Struct literal resolution: User{...} resolves to Struct node +// --------------------------------------------------------------------------- + +describe('Go struct literal resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-struct-literals'), + () => {}, + ); + }, 60000); + + it('resolves User{...} as a CALLS edge to the User struct', () => { + const calls = getRelationships(result, 'CALLS'); + const ctorCall = calls.find(c => c.target === 'User'); + expect(ctorCall).toBeDefined(); + expect(ctorCall!.source).toBe('processUser'); + expect(ctorCall!.targetLabel).toBe('Struct'); + expect(ctorCall!.targetFilePath).toBe('user.go'); + }); + + it('also resolves user.Save() as a member call', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'Save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('processUser'); + }); + + it('detects User struct, Save method, and processUser function', () => { + const structs: string[] = []; + result.graph.forEachNode(n => { + if (n.label === 'Struct') structs.push(n.properties.name); + }); + expect(structs).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('Save'); + expect(getNodesByLabel(result, 'Function')).toContain('processUser'); + }); +}); + +// --------------------------------------------------------------------------- +// Receiver-constrained resolution: typed variables disambiguate same-named methods +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Multi-assignment: user, repo := User{}, Repo{} — both sides captured in TypeEnv +// --------------------------------------------------------------------------- + +describe('Go multi-assignment short var declaration', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-multi-assign'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs with their methods', () => { + expect(getNodesByLabel(result, 'Struct')).toEqual(['Repo', 'User']); + expect(getNodesByLabel(result, 'Method')).toEqual(['Persist', 'Save']); + }); + + it('resolves both struct literals in multi-assignment: User{} and Repo{}', () => { + const calls = getRelationships(result, 'CALLS'); + const structCalls = calls.filter(c => c.targetLabel === 'Struct'); + expect(edgeSet(structCalls)).toEqual([ + 'process → Repo', + 'process → User', + ]); + }); + + it('resolves user.Save() to User.Save and repo.Persist() to Repo.Persist via receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'Save'); + const cloneCall = calls.find(c => c.target === 'Persist'); + + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('process'); + expect(saveCall!.targetFilePath).toBe('models.go'); + + expect(cloneCall).toBeDefined(); + expect(cloneCall!.source).toBe('process'); + expect(cloneCall!.targetFilePath).toBe('models.go'); + }); +}); + +describe('Go receiver-constrained resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-receiver-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs, both with Save methods', () => { + const structs: string[] = []; + result.graph.forEachNode(n => { + if (n.label === 'Struct') structs.push(n.properties.name); + }); + expect(structs).toContain('User'); + expect(structs).toContain('Repo'); + 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', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'Save'); + expect(saveCalls.length).toBe(2); + + const userSave = saveCalls.find(c => c.targetFilePath === 'models/user.go'); + const repoSave = saveCalls.find(c => c.targetFilePath === 'models/repo.go'); + + expect(userSave).toBeDefined(); + expect(repoSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + expect(repoSave!.source).toBe('processEntities'); + }); +}); + +// --------------------------------------------------------------------------- +// Variadic resolution: ...interface{} doesn't get filtered by arity +// --------------------------------------------------------------------------- + +describe('Go variadic call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-variadic-resolution'), + () => {}, + ); + }, 60000); + + it('resolves 3-arg call to variadic func Entry(...interface{}) in logger.go', () => { + const calls = getRelationships(result, 'CALLS'); + const logCall = calls.find(c => c.target === 'Entry'); + expect(logCall).toBeDefined(); + expect(logCall!.source).toBe('main'); + expect(logCall!.targetFilePath).toBe('internal/logger/logger.go'); + }); +}); + +// --------------------------------------------------------------------------- +// Local shadow: unqualified call resolves to local function, not imported package +// --------------------------------------------------------------------------- + +describe('Go local definition shadows import', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-local-shadow'), + () => {}, + ); + }, 60000); + + it('resolves Save("test") to local Save in main.go, not utils.go', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'Save' && c.source === 'main'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('cmd/main.go'); + }); +}); + diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts new file mode 100644 index 0000000000..79e3116df5 --- /dev/null +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -0,0 +1,54 @@ +/** + * Shared test helpers for language resolution integration tests. + */ +import path from 'path'; +import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js'; +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 type RelEdge = { + source: string; + target: string; + sourceLabel: string; + targetLabel: string; + sourceFilePath: string; + targetFilePath: string; + rel: GraphRelationship; +}; + +export function getRelationships(result: PipelineResult, type: string): RelEdge[] { + const edges: RelEdge[] = []; + for (const rel of result.graph.iterRelationships()) { + if (rel.type === type) { + const sourceNode = result.graph.getNode(rel.sourceId); + const targetNode = result.graph.getNode(rel.targetId); + edges.push({ + source: sourceNode?.properties.name ?? rel.sourceId, + target: targetNode?.properties.name ?? rel.targetId, + sourceLabel: sourceNode?.label ?? 'unknown', + targetLabel: targetNode?.label ?? 'unknown', + sourceFilePath: sourceNode?.properties.filePath ?? '', + targetFilePath: targetNode?.properties.filePath ?? '', + rel, + }); + } + } + return edges; +} + +export function getNodesByLabel(result: PipelineResult, label: string): string[] { + const names: string[] = []; + result.graph.forEachNode(n => { + if (n.label === label) names.push(n.properties.name); + }); + return names.sort(); +} + +export function edgeSet(edges: Array<{ source: string; target: string }>): string[] { + return edges.map(e => `${e.source} → ${e.target}`).sort(); +} + +export { runPipelineFromRepo }; +export type { PipelineResult }; diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts new file mode 100644 index 0000000000..d181b85ad7 --- /dev/null +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -0,0 +1,360 @@ +/** + * Java: class extends + implements multiple interfaces + ambiguous package disambiguation + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, getRelationships, getNodesByLabel, edgeSet, + runPipelineFromRepo, type PipelineResult, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Heritage: class extends + implements multiple interfaces +// --------------------------------------------------------------------------- + +describe('Java heritage resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-heritage'), + () => {}, + ); + }, 60000); + + it('detects exactly 3 classes and 2 interfaces', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User', 'UserService']); + expect(getNodesByLabel(result, 'Interface')).toEqual(['Serializable', 'Validatable']); + }); + + it('emits exactly 1 EXTENDS edge: User → BaseModel', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); + + it('emits exactly 2 IMPLEMENTS edges: User → Serializable, User → Validatable', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(2); + expect(edgeSet(implements_)).toEqual([ + 'User → Serializable', + 'User → Validatable', + ]); + }); + + it('resolves exactly 4 IMPORTS edges', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(4); + expect(edgeSet(imports)).toEqual([ + 'User.java → Serializable.java', + 'User.java → Validatable.java', + 'UserService.java → Serializable.java', + 'UserService.java → User.java', + ]); + }); + + it('does not emit EXTENDS edges to interfaces', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.some(e => e.target === 'Serializable')).toBe(false); + expect(extends_.some(e => e.target === 'Validatable')).toBe(false); + }); + + it('emits exactly 2 CALLS edges', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.length).toBe(2); + expect(edgeSet(calls)).toEqual([ + 'processUser → save', + 'processUser → validate', + ]); + }); + + it('no OVERRIDES edges target Property nodes', () => { + const overrides = getRelationships(result, 'OVERRIDES'); + for (const edge of overrides) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.label).not.toBe('Property'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Ambiguous: Handler + Processor in two packages, imports disambiguate +// --------------------------------------------------------------------------- + +describe('Java ambiguous symbol resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-ambiguous'), + () => {}, + ); + }, 60000); + + it('detects 2 Handler classes and 2 Processor interfaces', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes.filter(n => n === 'Handler').length).toBe(2); + expect(classes).toContain('UserHandler'); + const ifaces = getNodesByLabel(result, 'Interface'); + expect(ifaces.filter(n => n === 'Processor').length).toBe(2); + }); + + it('resolves EXTENDS to models/Handler (not other/Handler)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('UserHandler'); + expect(extends_[0].target).toBe('Handler'); + expect(extends_[0].targetFilePath).toBe('models/Handler.java'); + }); + + it('resolves IMPLEMENTS to models/Processor (not other/Processor)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(1); + expect(implements_[0].source).toBe('UserHandler'); + expect(implements_[0].target).toBe('Processor'); + expect(implements_[0].targetFilePath).toBe('models/Processor.java'); + }); + + it('import edges point to models/ not other/', () => { + const imports = getRelationships(result, 'IMPORTS'); + const targets = imports.map(e => e.target).sort(); + expect(targets).toContain('Handler.java'); + expect(targets).toContain('Processor.java'); + for (const imp of imports) { + expect(imp.targetFilePath).toMatch(/^models\//); + } + }); + + it('all heritage edges point to real graph nodes', () => { + for (const edge of [...getRelationships(result, 'EXTENDS'), ...getRelationships(result, 'IMPLEMENTS')]) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.properties.name).toBe(edge.target); + } + }); +}); + +describe('Java call resolution with arity filtering', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-calls'), + () => {}, + ); + }, 60000); + + it('resolves processUser → writeAudit to util/OneArg.java via arity narrowing', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.length).toBe(1); + expect(calls[0].source).toBe('processUser'); + expect(calls[0].target).toBe('writeAudit'); + expect(calls[0].targetFilePath).toBe('util/OneArg.java'); + expect(calls[0].rel.reason).toBe('import-resolved'); + }); +}); + +// --------------------------------------------------------------------------- +// Member-call resolution: obj.method() resolves through pipeline +// --------------------------------------------------------------------------- + +describe('Java member-call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-member-calls'), + () => {}, + ); + }, 60000); + + it('resolves processUser → save as a member call on User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('processUser'); + expect(saveCall!.targetFilePath).toBe('models/User.java'); + }); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('emits HAS_METHOD edge from User to save', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const edge = hasMethod.find(e => e.source === 'User' && e.target === 'save'); + expect(edge).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Constructor resolution: new Foo() resolves to Constructor/Class +// --------------------------------------------------------------------------- + +describe('Java constructor-call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-constructor-calls'), + () => {}, + ); + }, 60000); + + it('resolves new User() as a CALLS edge to the User constructor', () => { + const calls = getRelationships(result, 'CALLS'); + const ctorCall = calls.find(c => c.target === 'User'); + expect(ctorCall).toBeDefined(); + expect(ctorCall!.source).toBe('processUser'); + // Java has explicit constructor_declaration → Constructor node + expect(ctorCall!.targetLabel).toBe('Constructor'); + expect(ctorCall!.targetFilePath).toBe('models/User.java'); + }); + + it('also resolves user.save() as a member call', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('processUser'); + }); + + it('detects User class, User constructor, save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Constructor')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); +}); + +// --------------------------------------------------------------------------- +// Receiver-constrained resolution: typed variables disambiguate same-named methods +// --------------------------------------------------------------------------- + +describe('Java receiver-constrained resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-receiver-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + 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', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + + const userSave = saveCalls.find(c => c.targetFilePath === 'models/User.java'); + const repoSave = saveCalls.find(c => c.targetFilePath === 'models/Repo.java'); + + expect(userSave).toBeDefined(); + expect(repoSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + expect(repoSave!.source).toBe('processEntities'); + }); + + it('resolves constructor calls for both User and Repo', () => { + const calls = getRelationships(result, 'CALLS'); + const userCtor = calls.find(c => c.target === 'User'); + const repoCtor = calls.find(c => c.target === 'Repo'); + expect(userCtor).toBeDefined(); + expect(repoCtor).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Named import disambiguation: two User classes, import resolves to correct one +// --------------------------------------------------------------------------- + +describe('Java named import disambiguation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-named-imports'), + () => {}, + ); + }, 60000); + + it('detects two User classes in different packages', () => { + const users = getNodesByLabel(result, 'Class').filter(n => n === 'User'); + expect(users.length).toBe(2); + }); + + it('resolves user.save() to com/example/models/User.java via named import', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('run'); + expect(saveCall!.targetFilePath).toBe('com/example/models/User.java'); + }); + + it('resolves new User() to com/example/models/User.java, not other/', () => { + const calls = getRelationships(result, 'CALLS'); + const ctorCall = calls.find(c => c.target === 'User' && c.source === 'run'); + expect(ctorCall).toBeDefined(); + expect(ctorCall!.targetFilePath).toBe('com/example/models/User.java'); + }); +}); + +// --------------------------------------------------------------------------- +// Variadic resolution: String... doesn't get filtered by arity +// --------------------------------------------------------------------------- + +describe('Java variadic call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-variadic-resolution'), + () => {}, + ); + }, 60000); + + it('resolves 3-arg call to varargs method record(String...) in Logger.java', () => { + const calls = getRelationships(result, 'CALLS'); + const logCall = calls.find(c => c.target === 'record'); + expect(logCall).toBeDefined(); + expect(logCall!.source).toBe('run'); + expect(logCall!.targetFilePath).toBe('com/example/util/Logger.java'); + }); +}); + +// --------------------------------------------------------------------------- +// Local shadow: same-file definition takes priority over imported name +// --------------------------------------------------------------------------- + +describe('Java local definition shadows import', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-local-shadow'), + () => {}, + ); + }, 60000); + + it('resolves run → save to same-file definition, not the imported one', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'run'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('src/main/java/com/example/app/Main.java'); + }); + + it('does NOT resolve save to Logger.java', () => { + const calls = getRelationships(result, 'CALLS'); + const saveToUtils = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/main/java/com/example/utils/Logger.java'); + expect(saveToUtils).toBeUndefined(); + }); +}); + diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts new file mode 100644 index 0000000000..7173c2dd6d --- /dev/null +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -0,0 +1,382 @@ +/** + * Kotlin: data class extends + implements interfaces + ambiguous import disambiguation + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, getRelationships, getNodesByLabel, edgeSet, + runPipelineFromRepo, type PipelineResult, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Heritage: data class extends + implements interfaces (delegation specifiers) +// --------------------------------------------------------------------------- + +describe('Kotlin heritage resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-heritage'), + () => {}, + ); + }, 60000); + + it('detects exactly 3 classes and 2 interfaces', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User', 'UserService']); + expect(getNodesByLabel(result, 'Interface')).toEqual(['Serializable', 'Validatable']); + }); + + it('detects 6 functions (interface declarations + implementations + service)', () => { + expect(getNodesByLabel(result, 'Function')).toEqual([ + 'processUser', 'save', 'serialize', 'serialize', 'validate', 'validate', + ]); + }); + + it('emits exactly 1 EXTENDS edge: User → BaseModel', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); + + it('emits exactly 2 IMPLEMENTS edges via symbol table resolution', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(2); + expect(edgeSet(implements_)).toEqual([ + 'User → Serializable', + 'User → Validatable', + ]); + }); + + it('resolves exactly 4 IMPORTS edges (JVM-style package imports)', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(4); + expect(edgeSet(imports)).toEqual([ + 'User.kt → Serializable.kt', + 'User.kt → Validatable.kt', + 'UserService.kt → Serializable.kt', + 'UserService.kt → User.kt', + ]); + }); + + it('does not emit EXTENDS edges to interfaces', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.some(e => e.target === 'Serializable')).toBe(false); + expect(extends_.some(e => e.target === 'Validatable')).toBe(false); + }); + + it('resolves ambiguous validate() call through non-aliased import with import-resolved reason', () => { + const calls = getRelationships(result, 'CALLS'); + // validate is defined in both Validatable (interface) and User (override) → needs import scoping + const validateCall = calls.find(c => c.target === 'validate'); + expect(validateCall).toBeDefined(); + expect(validateCall!.source).toBe('processUser'); + expect(validateCall!.rel.reason).toBe('import-resolved'); + }); + + it('resolves unique save() call through non-aliased import', () => { + const calls = getRelationships(result, 'CALLS'); + // save is unique globally (only in BaseModel) → resolves as unique-global + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('processUser'); + }); + + it('no OVERRIDES edges target Property nodes', () => { + const overrides = getRelationships(result, 'OVERRIDES'); + for (const edge of overrides) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.label).not.toBe('Property'); + } + }); + + it('all heritage edges point to real graph nodes', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const implements_ = getRelationships(result, 'IMPLEMENTS'); + + for (const edge of [...extends_, ...implements_]) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.properties.name).toBe(edge.target); + } + }); +}); + +// --------------------------------------------------------------------------- +// Ambiguous: Handler + Runnable in two packages, explicit imports disambiguate +// --------------------------------------------------------------------------- + +describe('Kotlin ambiguous symbol resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-ambiguous'), + () => {}, + ); + }, 60000); + + it('detects 2 Handler classes and 2 Runnable interfaces', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes.filter(n => n === 'Handler').length).toBe(2); + const ifaces = getNodesByLabel(result, 'Interface'); + expect(ifaces.filter(n => n === 'Runnable').length).toBe(2); + }); + + it('resolves EXTENDS to models/Handler.kt (not other/Handler.kt)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('UserHandler'); + expect(extends_[0].target).toBe('Handler'); + expect(extends_[0].targetFilePath).toBe('models/Handler.kt'); + }); + + it('resolves IMPLEMENTS to models/Runnable.kt (not other/Runnable.kt)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(1); + expect(implements_[0].source).toBe('UserHandler'); + expect(implements_[0].target).toBe('Runnable'); + expect(implements_[0].targetFilePath).toBe('models/Runnable.kt'); + }); + + it('import edges point to models/ not other/', () => { + const imports = getRelationships(result, 'IMPORTS'); + for (const imp of imports) { + expect(imp.targetFilePath).toMatch(/^models\//); + } + }); + + it('all heritage edges point to real graph nodes', () => { + for (const edge of [...getRelationships(result, 'EXTENDS'), ...getRelationships(result, 'IMPLEMENTS')]) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.properties.name).toBe(edge.target); + } + }); +}); + +describe('Kotlin call resolution with arity filtering', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-calls'), + () => {}, + ); + }, 60000); + + it('resolves processUser → writeAudit to util/OneArg.kt via arity narrowing', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.length).toBe(1); + expect(calls[0].source).toBe('processUser'); + expect(calls[0].target).toBe('writeAudit'); + expect(calls[0].targetFilePath).toBe('util/OneArg.kt'); + expect(calls[0].rel.reason).toBe('import-resolved'); + }); +}); + +// --------------------------------------------------------------------------- +// Member-call resolution: obj.method() resolves through pipeline +// --------------------------------------------------------------------------- + +describe('Kotlin member-call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-member-calls'), + () => {}, + ); + }, 60000); + + it('resolves processUser → save as a member call on User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('processUser'); + expect(saveCall!.targetFilePath).toBe('models/User.kt'); + }); + + it('detects User class and save function (Kotlin fns are Function nodes)', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + // Kotlin tree-sitter captures all function_declaration as Function, including class methods + expect(getNodesByLabel(result, 'Function')).toContain('save'); + }); +}); + +// --------------------------------------------------------------------------- +// Receiver-constrained resolution: typed variables disambiguate same-named methods +// --------------------------------------------------------------------------- + +describe('Kotlin receiver-constrained resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-receiver-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save functions', () => { + 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); + }); + + it('resolves user.save() to User.save and repo.save() to Repo.save via receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + + const userSave = saveCalls.find(c => c.targetFilePath === 'models/User.kt'); + const repoSave = saveCalls.find(c => c.targetFilePath === 'models/Repo.kt'); + + expect(userSave).toBeDefined(); + expect(repoSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + expect(repoSave!.source).toBe('processEntities'); + }); +}); + +// --------------------------------------------------------------------------- +// Alias import resolution: import com.example.User as U resolves U → User +// --------------------------------------------------------------------------- + +describe('Kotlin alias import resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-alias-imports'), + () => {}, + ); + }, 60000); + + 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'); + }); + + it('resolves u.save() to models/Models.kt and r.persist() to models/Models.kt via alias', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + const persistCall = calls.find(c => c.target === 'persist'); + + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('main'); + expect(saveCall!.targetFilePath).toBe('models/Models.kt'); + + expect(persistCall).toBeDefined(); + expect(persistCall!.source).toBe('main'); + expect(persistCall!.targetFilePath).toBe('models/Models.kt'); + }); +}); + +// --------------------------------------------------------------------------- +// Constructor-call resolution: User("alice") resolves to User constructor +// --------------------------------------------------------------------------- + +describe('Kotlin constructor-call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-constructor-calls'), + () => {}, + ); + }, 60000); + + 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, 'Function')).toContain('main'); + }); + + it('resolves import from app/App.kt to models/User.kt', () => { + const imports = getRelationships(result, 'IMPORTS'); + const imp = imports.find(e => e.source === 'App.kt' && e.targetFilePath === 'models/User.kt'); + expect(imp).toBeDefined(); + }); + + it('emits HAS_METHOD from User class to save function', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const edge = hasMethod.find(e => e.source === 'User' && e.target === 'save'); + expect(edge).toBeDefined(); + expect(edge!.targetFilePath).toBe('models/User.kt'); + }); + + it('resolves user.save() as a method call to models/User.kt', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('main'); + expect(saveCall!.targetFilePath).toBe('models/User.kt'); + }); + + it('resolves calls via non-aliased import with import-resolved reason', () => { + const calls = getRelationships(result, 'CALLS'); + // Both User("alice") constructor and user.save() go through `import models.User` + for (const call of calls) { + expect(call.rel.reason).toBe('import-resolved'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Variadic resolution: vararg doesn't get filtered by arity +// --------------------------------------------------------------------------- + +describe('Kotlin variadic call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-variadic-resolution'), + () => {}, + ); + }, 60000); + + it('resolves 3-arg call to vararg function logEntry(vararg String) in Logger.kt', () => { + const calls = getRelationships(result, 'CALLS'); + const logCall = calls.find(c => c.target === 'logEntry'); + expect(logCall).toBeDefined(); + expect(logCall!.source).toBe('main'); + expect(logCall!.targetFilePath).toBe('util/Logger.kt'); + }); +}); + +// --------------------------------------------------------------------------- +// Local shadow: same-file definition takes priority over imported name +// --------------------------------------------------------------------------- + +describe('Kotlin local definition shadows import', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-local-shadow'), + () => {}, + ); + }, 60000); + + it('resolves run → save to same-file definition, not the imported one', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'run'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('src/main/kotlin/app/Main.kt'); + }); + + it('does NOT resolve save to Logger.kt', () => { + const calls = getRelationships(result, 'CALLS'); + const saveToUtils = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/main/kotlin/utils/Logger.kt'); + expect(saveToUtils).toBeUndefined(); + }); +}); + diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts new file mode 100644 index 0000000000..aaddb12dba --- /dev/null +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -0,0 +1,490 @@ +/** + * PHP: PSR-4 imports, extends, implements, trait use, enums, calls + ambiguous disambiguation + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, getRelationships, getNodesByLabel, edgeSet, + runPipelineFromRepo, type PipelineResult, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Heritage: PSR-4 imports, extends, implements, trait use, enums, calls +// --------------------------------------------------------------------------- + +describe('PHP heritage & import resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-app'), + () => {}, + ); + }, 60000); + + // --- Node detection --- + + it('detects 3 classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User', 'UserService']); + }); + + it('detects 2 interfaces', () => { + expect(getNodesByLabel(result, 'Interface')).toEqual(['Loggable', 'Repository']); + }); + + it('detects 2 traits', () => { + expect(getNodesByLabel(result, 'Trait')).toEqual(['HasTimestamps', 'SoftDeletes']); + }); + + it('detects 1 enum (PHP 8.1)', () => { + expect(getNodesByLabel(result, 'Enum')).toEqual(['UserRole']); + }); + + it('detects 8 namespaces across all files', () => { + const ns = getNodesByLabel(result, 'Namespace'); + expect(ns.length).toBe(8); + }); + + // --- Heritage edges --- + + it('emits exactly 1 EXTENDS edge: User → BaseModel', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); + + it('emits 4 IMPLEMENTS edges: class→interface + class→trait', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implements_)).toEqual([ + 'BaseModel → HasTimestamps', + 'BaseModel → Loggable', + 'User → SoftDeletes', + 'UserService → Repository', + ]); + }); + + // --- Import (use-statement) resolution via PSR-4 --- + + it('resolves 6 IMPORTS edges via PSR-4 composer.json', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(edgeSet(imports)).toEqual([ + 'BaseModel.php → HasTimestamps.php', + 'BaseModel.php → Loggable.php', + 'User.php → SoftDeletes.php', + 'UserService.php → Repository.php', + 'UserService.php → User.php', + 'UserService.php → UserRole.php', + ]); + }); + + // --- Method/function call edges --- + + it('emits CALLS edges from createUser', () => { + const calls = getRelationships(result, 'CALLS') + .filter(e => e.source === 'createUser'); + const targets = calls.map(c => c.target).sort(); + expect(targets).toContain('save'); + expect(targets).toContain('touch'); + expect(targets).toContain('label'); + }); + + it('emits CALLS edge: save → getId', () => { + const calls = getRelationships(result, 'CALLS') + .filter(e => e.source === 'save' && e.target === 'getId'); + expect(calls.length).toBe(1); + }); + + // --- Methods and properties --- + + it('detects methods on classes, interfaces, traits, and enums', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('getId'); + expect(methods).toContain('log'); + expect(methods).toContain('touch'); + expect(methods).toContain('softDelete'); + expect(methods).toContain('restore'); + expect(methods).toContain('find'); + expect(methods).toContain('save'); + expect(methods).toContain('createUser'); + expect(methods).toContain('instance'); + expect(methods).toContain('label'); + expect(methods).toContain('__construct'); + }); + + it('detects properties on classes and traits', () => { + const props = getNodesByLabel(result, 'Property'); + expect(props).toContain('id'); + expect(props).toContain('name'); + expect(props).toContain('email'); + expect(props).toContain('users'); + // $status defined in both HasTimestamps and SoftDeletes traits + expect(props.filter(p => p === 'status').length).toBe(2); + }); + + // --- Property OVERRIDES exclusion --- + + it('does not emit OVERRIDES for property name collisions ($status in both traits)', () => { + const overrides = getRelationships(result, 'OVERRIDES'); + // OVERRIDES should only target Method nodes, never Property nodes + for (const edge of overrides) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.label).not.toBe('Property'); + } + }); + + // --- MRO: OVERRIDES edge --- + + it('emits OVERRIDES edge for User overriding log (inherited from BaseModel)', () => { + const overrides = getRelationships(result, 'OVERRIDES'); + expect(overrides.length).toBeGreaterThanOrEqual(1); + const logOverride = overrides.find(e => e.source === 'User' && e.target === 'log'); + expect(logOverride).toBeDefined(); + }); + + // --- All heritage edges point to real graph nodes --- + + it('all heritage edges point to real graph nodes (no synthetic)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const implements_ = getRelationships(result, 'IMPLEMENTS'); + + for (const edge of [...extends_, ...implements_]) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.properties.name).toBe(edge.target); + } + }); +}); + +// --------------------------------------------------------------------------- +// Ambiguous: Handler + Dispatchable, PSR-4 use-imports disambiguate +// --------------------------------------------------------------------------- + +describe('PHP ambiguous symbol resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-ambiguous'), + () => {}, + ); + }, 60000); + + it('detects 2 Handler classes and 2 Dispatchable interfaces', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes.filter(n => n === 'Handler').length).toBe(2); + const ifaces = getNodesByLabel(result, 'Interface'); + expect(ifaces.filter(n => n === 'Dispatchable').length).toBe(2); + }); + + it('resolves EXTENDS to app/Models/Handler.php (not app/Other/)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('UserHandler'); + expect(extends_[0].target).toBe('Handler'); + expect(extends_[0].targetFilePath).toBe('app/Models/Handler.php'); + }); + + it('resolves IMPLEMENTS to app/Models/Dispatchable.php (not app/Other/)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(1); + expect(implements_[0].source).toBe('UserHandler'); + expect(implements_[0].target).toBe('Dispatchable'); + expect(implements_[0].targetFilePath).toBe('app/Models/Dispatchable.php'); + }); + + it('import edges point to app/Models/ not app/Other/', () => { + const imports = getRelationships(result, 'IMPORTS'); + for (const imp of imports) { + expect(imp.targetFilePath).toMatch(/^app\/Models\//); + } + }); + + it('all heritage edges point to real graph nodes', () => { + for (const edge of [...getRelationships(result, 'EXTENDS'), ...getRelationships(result, 'IMPLEMENTS')]) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.properties.name).toBe(edge.target); + } + }); +}); + +describe('PHP call resolution with arity filtering', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-calls'), + () => {}, + ); + }, 60000); + + it('resolves create_user → write_audit to app/Utils/OneArg/log.php via arity narrowing', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.length).toBe(1); + expect(calls[0].source).toBe('create_user'); + expect(calls[0].target).toBe('write_audit'); + expect(calls[0].targetFilePath).toBe('app/Utils/OneArg/log.php'); + expect(calls[0].rel.reason).toBe('import-resolved'); + }); +}); + +// --------------------------------------------------------------------------- +// Member-call resolution: $obj->method() resolves through pipeline +// --------------------------------------------------------------------------- + +describe('PHP member-call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-member-calls'), + () => {}, + ); + }, 60000); + + it('resolves processUser → save as a member call on User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('processUser'); + expect(saveCall!.targetFilePath).toBe('app/Models/User.php'); + }); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('emits HAS_METHOD edge from User to save', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const edge = hasMethod.find(e => e.source === 'User' && e.target === 'save'); + expect(edge).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Constructor resolution: new User() resolves to Class node +// --------------------------------------------------------------------------- + +describe('PHP constructor-call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-constructor-calls'), + () => {}, + ); + }, 60000); + + it('resolves new User() as a CALLS edge to the User class', () => { + const calls = getRelationships(result, 'CALLS'); + const ctorCall = calls.find(c => c.target === 'User'); + expect(ctorCall).toBeDefined(); + expect(ctorCall!.source).toBe('processUser'); + expect(ctorCall!.targetLabel).toBe('Class'); + expect(ctorCall!.targetFilePath).toBe('Models/User.php'); + expect(ctorCall!.rel.reason).toBe('import-resolved'); + }); + + it('also resolves $user->save() as a member call', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('processUser'); + }); + + it('detects User class, __construct method, and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('__construct'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); +}); + +// --------------------------------------------------------------------------- +// Receiver-constrained resolution: typed parameters disambiguate same-named methods +// --------------------------------------------------------------------------- + +describe('PHP receiver-constrained resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-receiver-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + 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', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + + const userSave = saveCalls.find(c => c.targetFilePath === 'app/Models/User.php'); + const repoSave = saveCalls.find(c => c.targetFilePath === 'app/Models/Repo.php'); + + expect(userSave).toBeDefined(); + expect(repoSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + expect(repoSave!.source).toBe('processEntities'); + }); +}); + +// --------------------------------------------------------------------------- +// Alias import resolution: use App\Models\User as U resolves U → User +// --------------------------------------------------------------------------- + +describe('PHP alias import resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-alias-imports'), + () => {}, + ); + }, 60000); + + it('detects Main, Repo, and User classes with save and persist methods', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Main', 'Repo', 'User']); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('persist'); + }); + + it('resolves $u->save() to User.php and $r->persist() to Repo.php via alias', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + const persistCall = calls.find(c => c.target === 'persist'); + + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('run'); + expect(saveCall!.targetLabel).toBe('Method'); + expect(saveCall!.targetFilePath).toBe('app/Models/User.php'); + + expect(persistCall).toBeDefined(); + expect(persistCall!.source).toBe('run'); + expect(persistCall!.targetLabel).toBe('Method'); + expect(persistCall!.targetFilePath).toBe('app/Models/Repo.php'); + }); + + it('emits exactly 2 IMPORTS edges via alias resolution', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(2); + expect(edgeSet(imports)).toEqual([ + 'Main.php → Repo.php', + 'Main.php → User.php', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Grouped import with alias: use App\Models\{User, Repo as R} +// --------------------------------------------------------------------------- + +describe('PHP grouped import with alias', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-grouped-imports'), + () => {}, + ); + }, 60000); + + it('detects Main, Repo, and User classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Main', 'Repo', 'User']); + }); + + it('resolves $r->persist() to Repo.php via grouped alias', () => { + const calls = getRelationships(result, 'CALLS'); + const persistCall = calls.find(c => c.target === 'persist'); + + expect(persistCall).toBeDefined(); + expect(persistCall!.source).toBe('run'); + expect(persistCall!.targetFilePath).toBe('app/Models/Repo.php'); + }); + + it('resolves $u->save() to User.php via grouped import', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('run'); + expect(saveCall!.targetFilePath).toBe('app/Models/User.php'); + }); + + it('resolves non-aliased User via NamedImportMap (not just the aliased Repo)', () => { + // Both User (non-aliased) and R→Repo (aliased) should resolve through grouped import + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'run'); + const persistCall = calls.find(c => c.target === 'persist' && c.source === 'run'); + expect(saveCall).toBeDefined(); + expect(persistCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('app/Models/User.php'); + expect(persistCall!.targetFilePath).toBe('app/Models/Repo.php'); + }); +}); + +// --------------------------------------------------------------------------- +// Variadic resolution: ...$args don't get filtered by arity +// --------------------------------------------------------------------------- + +describe('PHP variadic call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-variadic-resolution'), + () => {}, + ); + }, 60000); + + it('resolves run → Logger.record despite extra args (variadic)', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCall = calls.find(c => c.target === 'record'); + expect(recordCall).toBeDefined(); + expect(recordCall!.source).toBe('run'); + expect(recordCall!.targetFilePath).toBe('app/Utils/Logger.php'); + }); + + it('detects Logger class and record method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Logger'); + expect(getNodesByLabel(result, 'Method')).toContain('record'); + }); +}); + +// --------------------------------------------------------------------------- +// Local shadow: same-file definition takes priority over imported name +// --------------------------------------------------------------------------- + +describe('PHP local definition shadows import', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-local-shadow'), + () => {}, + ); + }, 60000); + + it('resolves run → save to same-file definition, not the imported one', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'run'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('app/Services/Main.php'); + }); + + it('does NOT resolve save to Logger.php', () => { + const calls = getRelationships(result, 'CALLS'); + const saveToUtils = calls.find(c => c.target === 'save' && c.targetFilePath === 'app/Utils/Logger.php'); + expect(saveToUtils).toBeUndefined(); + }); +}); + diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts new file mode 100644 index 0000000000..2c57db9c71 --- /dev/null +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -0,0 +1,383 @@ +/** + * Python: relative imports + class inheritance + ambiguous module disambiguation + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, getRelationships, getNodesByLabel, edgeSet, + runPipelineFromRepo, type PipelineResult, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Heritage: relative imports + class inheritance +// --------------------------------------------------------------------------- + +describe('Python relative import & heritage resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-pkg'), + () => {}, + ); + }, 60000); + + it('detects exactly 3 classes and 5 functions', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['AuthService', 'BaseModel', 'User']); + expect(getNodesByLabel(result, 'Function')).toEqual(['authenticate', 'get_name', 'process_model', 'save', 'validate']); + }); + + it('emits exactly 1 EXTENDS edge: User → BaseModel', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); + + it('resolves all 3 relative imports', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(3); + expect(edgeSet(imports)).toEqual([ + 'auth.py → user.py', + 'helpers.py → base.py', + 'user.py → base.py', + ]); + }); + + it('emits exactly 3 CALLS edges', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.length).toBe(3); + expect(edgeSet(calls)).toEqual([ + 'authenticate → validate', + 'process_model → save', + 'process_model → validate', + ]); + }); + + it('no OVERRIDES edges target Property nodes', () => { + const overrides = getRelationships(result, 'OVERRIDES'); + for (const edge of overrides) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.label).not.toBe('Property'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Ambiguous: Handler in two packages, relative import disambiguates +// --------------------------------------------------------------------------- + +describe('Python ambiguous symbol resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-ambiguous'), + () => {}, + ); + }, 60000); + + it('detects 2 Handler classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes.filter(n => n === 'Handler').length).toBe(2); + expect(classes).toContain('UserHandler'); + }); + + it('resolves EXTENDS to models/handler.py (not other/handler.py)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('UserHandler'); + expect(extends_[0].target).toBe('Handler'); + expect(extends_[0].targetFilePath).toBe('models/handler.py'); + }); + + it('import edge points to models/ not other/', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(1); + expect(imports[0].targetFilePath).toBe('models/handler.py'); + }); + + it('all heritage edges point to real graph nodes', () => { + for (const edge of getRelationships(result, 'EXTENDS')) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + } + }); +}); + +describe('Python call resolution with arity filtering', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-calls'), + () => {}, + ); + }, 60000); + + it('resolves run → write_audit to one.py via arity narrowing', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.length).toBe(1); + expect(calls[0].source).toBe('run'); + expect(calls[0].target).toBe('write_audit'); + expect(calls[0].targetFilePath).toBe('one.py'); + expect(calls[0].rel.reason).toBe('import-resolved'); + }); +}); + +// --------------------------------------------------------------------------- +// Member-call resolution: obj.method() resolves through pipeline +// --------------------------------------------------------------------------- + +describe('Python member-call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-member-calls'), + () => {}, + ); + }, 60000); + + it('resolves process_user → save as a member call on User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('process_user'); + expect(saveCall!.targetFilePath).toBe('user.py'); + }); + + it('detects User class and save function (Python methods are Function nodes)', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + // Python tree-sitter captures all function_definitions as Function, including methods + expect(getNodesByLabel(result, 'Function')).toContain('save'); + }); +}); + +// --------------------------------------------------------------------------- +// Receiver-constrained resolution: typed variables disambiguate same-named methods +// --------------------------------------------------------------------------- + +describe('Python receiver-constrained resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-receiver-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save functions', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + // Python tree-sitter captures all function_definitions as Function + const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save'); + expect(saveFns.length).toBe(2); + }); + + it('resolves user.save() to User.save and repo.save() to Repo.save via receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + + const userSave = saveCalls.find(c => c.targetFilePath === 'user.py'); + const repoSave = saveCalls.find(c => c.targetFilePath === 'repo.py'); + + expect(userSave).toBeDefined(); + expect(repoSave).toBeDefined(); + expect(userSave!.source).toBe('process_entities'); + expect(repoSave!.source).toBe('process_entities'); + }); +}); + +// --------------------------------------------------------------------------- +// Named import disambiguation: two modules export same name, from-import resolves +// --------------------------------------------------------------------------- + +describe('Python named import disambiguation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-named-imports'), + () => {}, + ); + }, 60000); + + it('resolves process_input → format_data to format_upper.py via from-import', () => { + const calls = getRelationships(result, 'CALLS'); + const formatCall = calls.find(c => c.target === 'format_data'); + expect(formatCall).toBeDefined(); + expect(formatCall!.source).toBe('process_input'); + expect(formatCall!.targetFilePath).toBe('format_upper.py'); + }); + + it('emits IMPORTS edge to format_upper.py', () => { + const imports = getRelationships(result, 'IMPORTS'); + const appImport = imports.find(e => e.source === 'app.py'); + expect(appImport).toBeDefined(); + expect(appImport!.targetFilePath).toBe('format_upper.py'); + }); +}); + +// --------------------------------------------------------------------------- +// Variadic resolution: *args don't get filtered by arity +// --------------------------------------------------------------------------- + +describe('Python variadic call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-variadic-resolution'), + () => {}, + ); + }, 60000); + + it('resolves process_input → log_entry to logger.py despite 3 args vs *args', () => { + const calls = getRelationships(result, 'CALLS'); + const logCall = calls.find(c => c.target === 'log_entry'); + expect(logCall).toBeDefined(); + expect(logCall!.source).toBe('process_input'); + expect(logCall!.targetFilePath).toBe('logger.py'); + }); +}); + +// --------------------------------------------------------------------------- +// Alias import resolution: from x import User as U resolves U → User +// --------------------------------------------------------------------------- + +describe('Python alias import resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-alias-imports'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); + }); + + it('resolves u.save() to models.py and r.persist() to models.py via alias', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + const persistCall = calls.find(c => c.target === 'persist'); + + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('main'); + expect(saveCall!.targetFilePath).toBe('models.py'); + + expect(persistCall).toBeDefined(); + expect(persistCall!.source).toBe('main'); + expect(persistCall!.targetFilePath).toBe('models.py'); + }); + + it('emits exactly 1 IMPORTS edge: app.py → models.py', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(1); + expect(imports[0].sourceFilePath).toBe('app.py'); + expect(imports[0].targetFilePath).toBe('models.py'); + }); +}); + +// --------------------------------------------------------------------------- +// Re-export chain: from .base import X barrel pattern via __init__.py +// --------------------------------------------------------------------------- + +describe('Python re-export chain resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-reexport-chain'), + () => {}, + ); + }, 60000); + + it('resolves user.save() through __init__.py barrel to models/base.py', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('main'); + expect(saveCall!.targetFilePath).toBe('models/base.py'); + }); + + it('resolves repo.persist() through __init__.py barrel to models/base.py', () => { + const calls = getRelationships(result, 'CALLS'); + const persistCall = calls.find(c => c.target === 'persist'); + expect(persistCall).toBeDefined(); + expect(persistCall!.source).toBe('main'); + expect(persistCall!.targetFilePath).toBe('models/base.py'); + }); +}); + +// --------------------------------------------------------------------------- +// Local shadow: same-file definition takes priority over imported name +// --------------------------------------------------------------------------- + +describe('Python local definition shadows import', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-local-shadow'), + () => {}, + ); + }, 60000); + + it('resolves save("test") to local save in app.py, not utils.py', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'main'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('app.py'); + }); +}); + +// --------------------------------------------------------------------------- +// Constructor-call resolution: User("alice") resolves to User class +// --------------------------------------------------------------------------- + +describe('Python constructor-call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-constructor-calls'), + () => {}, + ); + }, 60000); + + it('detects User class with __init__ and save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Function')).toContain('__init__'); + expect(getNodesByLabel(result, 'Function')).toContain('save'); + expect(getNodesByLabel(result, 'Function')).toContain('process'); + }); + + it('resolves import from app.py to models.py', () => { + const imports = getRelationships(result, 'IMPORTS'); + const imp = imports.find(e => e.source === 'app.py' && e.targetFilePath === 'models.py'); + expect(imp).toBeDefined(); + }); + + it('emits HAS_METHOD from User class to __init__ and save', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const initEdge = hasMethod.find(e => e.source === 'User' && e.target === '__init__'); + const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'save'); + expect(initEdge).toBeDefined(); + expect(saveEdge).toBeDefined(); + }); + + it('resolves user.save() as a method call to models.py', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('process'); + expect(saveCall!.targetFilePath).toBe('models.py'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts new file mode 100644 index 0000000000..9e5c8501f4 --- /dev/null +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -0,0 +1,409 @@ +/** + * Rust: trait implementations + ambiguous module import disambiguation + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, getRelationships, getNodesByLabel, edgeSet, + runPipelineFromRepo, type PipelineResult, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Heritage: trait implementations +// --------------------------------------------------------------------------- + +describe('Rust trait implementation resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-traits'), + () => {}, + ); + }, 60000); + + it('detects exactly 1 struct and 2 traits', () => { + expect(getNodesByLabel(result, 'Struct')).toEqual(['Button']); + expect(getNodesByLabel(result, 'Trait')).toEqual(['Clickable', 'Drawable']); + }); + + it('emits exactly 2 IMPLEMENTS edges with reason trait-impl', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(2); + expect(edgeSet(implements_)).toEqual([ + 'Button → Clickable', + 'Button → Drawable', + ]); + for (const edge of implements_) { + expect(edge.rel.reason).toBe('trait-impl'); + } + }); + + it('does not emit any EXTENDS edges for trait impls', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(0); + }); + + it('resolves exactly 1 IMPORTS edge: main.rs → button.rs', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(1); + expect(imports[0].source).toBe('main.rs'); + expect(imports[0].target).toBe('button.rs'); + }); + + it('detects 2 modules and 4 functions', () => { + expect(getNodesByLabel(result, 'Module')).toEqual(['impls', 'traits']); + expect(getNodesByLabel(result, 'Function')).toEqual(['draw', 'is_enabled', 'main', 'on_click', 'resize']); + }); + + it('no OVERRIDES edges target Property nodes', () => { + const overrides = getRelationships(result, 'OVERRIDES'); + for (const edge of overrides) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.label).not.toBe('Property'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Ambiguous: Handler struct in two modules, crate:: import disambiguates +// --------------------------------------------------------------------------- + +describe('Rust ambiguous symbol resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-ambiguous'), + () => {}, + ); + }, 60000); + + it('detects 2 Handler structs in separate modules', () => { + const structs: string[] = []; + result.graph.forEachNode(n => { + if (n.label === 'Struct') structs.push(`${n.properties.name}@${n.properties.filePath}`); + }); + const handlers = structs.filter(s => s.startsWith('Handler@')); + expect(handlers.length).toBe(2); + expect(handlers.some(h => h.includes('src/models/'))).toBe(true); + expect(handlers.some(h => h.includes('src/other/'))).toBe(true); + }); + + it('import resolves to src/models/mod.rs (not src/other/mod.rs)', () => { + const imports = getRelationships(result, 'IMPORTS'); + const modelsImport = imports.find(e => e.targetFilePath.includes('models')); + expect(modelsImport).toBeDefined(); + expect(modelsImport!.targetFilePath).toBe('src/models/mod.rs'); + }); + + it('no import edge to src/other/', () => { + const imports = getRelationships(result, 'IMPORTS'); + for (const imp of imports) { + expect(imp.targetFilePath).not.toMatch(/src\/other\//); + } + }); +}); + +describe('Rust call resolution with arity filtering', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-calls'), + () => {}, + ); + }, 60000); + + it('resolves main → write_audit to src/onearg/mod.rs via arity narrowing', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.length).toBe(1); + expect(calls[0].source).toBe('main'); + expect(calls[0].target).toBe('write_audit'); + expect(calls[0].targetFilePath).toBe('src/onearg/mod.rs'); + expect(calls[0].rel.reason).toBe('import-resolved'); + }); +}); + +// --------------------------------------------------------------------------- +// Member-call resolution: obj.method() resolves through pipeline +// --------------------------------------------------------------------------- + +describe('Rust member-call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-member-calls'), + () => {}, + ); + }, 60000); + + it('resolves process_user → save as a member call on User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('process_user'); + expect(saveCall!.targetFilePath).toBe('src/user.rs'); + }); + + it('detects User struct and save function (Rust impl fns are Function nodes)', () => { + const structs: string[] = []; + result.graph.forEachNode(n => { + if (n.label === 'Struct') structs.push(n.properties.name); + }); + expect(structs).toContain('User'); + // Rust tree-sitter captures all function_item as Function, including impl methods + expect(getNodesByLabel(result, 'Function')).toContain('save'); + }); +}); + +// --------------------------------------------------------------------------- +// Struct literal resolution: User { ... } resolves to Struct node +// --------------------------------------------------------------------------- + +describe('Rust struct literal resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-struct-literals'), + () => {}, + ); + }, 60000); + + it('resolves User { ... } as a CALLS edge to the User struct', () => { + const calls = getRelationships(result, 'CALLS'); + const ctorCall = calls.find(c => c.target === 'User'); + expect(ctorCall).toBeDefined(); + expect(ctorCall!.source).toBe('process_user'); + expect(ctorCall!.targetLabel).toBe('Struct'); + expect(ctorCall!.targetFilePath).toBe('user.rs'); + expect(ctorCall!.rel.reason).toBe('import-resolved'); + }); + + it('also resolves user.save() as a member call', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('process_user'); + }); + + it('detects User struct and process_user function', () => { + const structs: string[] = []; + result.graph.forEachNode(n => { + if (n.label === 'Struct') structs.push(n.properties.name); + }); + expect(structs).toContain('User'); + expect(getNodesByLabel(result, 'Function')).toContain('process_user'); + }); +}); + +// --------------------------------------------------------------------------- +// Receiver-constrained resolution: typed variables disambiguate same-named methods +// --------------------------------------------------------------------------- + +describe('Rust receiver-constrained resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-receiver-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs, both with save functions', () => { + const structs: string[] = []; + result.graph.forEachNode(n => { + if (n.label === 'Struct') structs.push(n.properties.name); + }); + expect(structs).toContain('User'); + expect(structs).toContain('Repo'); + // Rust tree-sitter captures impl fns as Function nodes + const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save'); + expect(saveFns.length).toBe(2); + }); + + it('resolves user.save() to User.save and repo.save() to Repo.save via receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + + const userSave = saveCalls.find(c => c.targetFilePath === 'src/user.rs'); + const repoSave = saveCalls.find(c => c.targetFilePath === 'src/repo.rs'); + + expect(userSave).toBeDefined(); + expect(repoSave).toBeDefined(); + expect(userSave!.source).toBe('process_entities'); + expect(repoSave!.source).toBe('process_entities'); + }); +}); + +// --------------------------------------------------------------------------- +// Alias import resolution: use crate::models::User as U resolves U → User +// --------------------------------------------------------------------------- + +describe('Rust alias import resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-alias-imports'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs with their methods', () => { + const structs: string[] = []; + result.graph.forEachNode(n => { + if (n.label === 'Struct') structs.push(n.properties.name); + }); + expect(structs).toContain('User'); + expect(structs).toContain('Repo'); + expect(getNodesByLabel(result, 'Function')).toContain('save'); + expect(getNodesByLabel(result, 'Function')).toContain('persist'); + }); + + it('resolves u.save() to src/models.rs and r.persist() to src/models.rs via alias', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + const persistCall = calls.find(c => c.target === 'persist'); + + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('main'); + expect(saveCall!.targetFilePath).toBe('src/models.rs'); + + expect(persistCall).toBeDefined(); + expect(persistCall!.source).toBe('main'); + expect(persistCall!.targetFilePath).toBe('src/models.rs'); + }); + + it('emits exactly 1 IMPORTS edge: src/main.rs → src/models.rs', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(1); + expect(imports[0].sourceFilePath).toBe('src/main.rs'); + expect(imports[0].targetFilePath).toBe('src/models.rs'); + }); +}); + +// --------------------------------------------------------------------------- +// Local shadow: same-file definition takes priority over imported name +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Re-export chain: pub use in mod.rs followed through to definition file +// --------------------------------------------------------------------------- + +describe('Rust re-export chain resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-reexport-chain'), + () => {}, + ); + }, 60000); + + it('detects Handler struct in handler.rs', () => { + const structs: string[] = []; + result.graph.forEachNode(n => { + if (n.label === 'Struct') structs.push(`${n.properties.name}@${n.properties.filePath}`); + }); + expect(structs).toContain('Handler@src/models/handler.rs'); + }); + + it('resolves Handler { ... } to src/models/handler.rs via re-export chain, not mod.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const ctorCall = calls.find(c => c.target === 'Handler'); + expect(ctorCall).toBeDefined(); + expect(ctorCall!.source).toBe('main'); + expect(ctorCall!.targetLabel).toBe('Struct'); + expect(ctorCall!.targetFilePath).toBe('src/models/handler.rs'); + }); + + it('resolves h.process() to src/models/handler.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const processCall = calls.find(c => c.target === 'process'); + expect(processCall).toBeDefined(); + expect(processCall!.source).toBe('main'); + expect(processCall!.targetFilePath).toBe('src/models/handler.rs'); + }); +}); + +// --------------------------------------------------------------------------- +// Local shadow: same-file definition takes priority over imported name +// --------------------------------------------------------------------------- + +describe('Rust local definition shadows import', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-local-shadow'), + () => {}, + ); + }, 60000); + + it('resolves run → save to same-file definition, not the imported one', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'run'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('src/main.rs'); + }); + + it('does NOT resolve save to utils.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const saveToUtils = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/utils.rs'); + expect(saveToUtils).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Grouped imports: use crate::helpers::{func_a, func_b} +// Verifies no spurious binding for the path prefix (e.g. "helpers") +// --------------------------------------------------------------------------- + +describe('Rust grouped import resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-grouped-imports'), + () => {}, + ); + }, 60000); + + it('resolves main → format_name to src/helpers/mod.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const call = calls.find(c => c.target === 'format_name'); + expect(call).toBeDefined(); + expect(call!.source).toBe('main'); + expect(call!.targetFilePath).toBe('src/helpers/mod.rs'); + expect(call!.rel.reason).toBe('import-resolved'); + }); + + it('resolves main → validate_email to src/helpers/mod.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const call = calls.find(c => c.target === 'validate_email'); + expect(call).toBeDefined(); + expect(call!.source).toBe('main'); + expect(call!.targetFilePath).toBe('src/helpers/mod.rs'); + expect(call!.rel.reason).toBe('import-resolved'); + }); + + it('does not create a spurious CALLS edge for the path prefix "helpers"', () => { + const calls = getRelationships(result, 'CALLS'); + const spurious = calls.find(c => c.target === 'helpers' || c.source === 'helpers'); + expect(spurious).toBeUndefined(); + }); + + it('emits exactly 1 IMPORTS edge: main.rs → helpers/mod.rs', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(1); + expect(imports[0].source).toBe('main.rs'); + expect(imports[0].target).toBe('mod.rs'); + expect(imports[0].targetFilePath).toBe('src/helpers/mod.rs'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts new file mode 100644 index 0000000000..2e85eca82f --- /dev/null +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -0,0 +1,508 @@ +/** + * TypeScript: heritage resolution + ambiguous symbol disambiguation + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, getRelationships, getNodesByLabel, edgeSet, + runPipelineFromRepo, type PipelineResult, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Heritage: class extends + implements interface +// --------------------------------------------------------------------------- + +describe('TypeScript heritage resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-ambiguous'), + () => {}, + ); + }, 60000); + + it('detects exactly 3 classes and 1 interface', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseService', 'ConsoleLogger', 'UserService']); + expect(getNodesByLabel(result, 'Interface')).toEqual(['ILogger']); + }); + + it('emits exactly 3 IMPORTS edges', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(3); + expect(edgeSet(imports)).toEqual([ + 'logger.ts → models.ts', + 'service.ts → logger.ts', + 'service.ts → models.ts', + ]); + }); + + it('emits exactly 1 EXTENDS edge: UserService → BaseService', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('UserService'); + expect(extends_[0].target).toBe('BaseService'); + }); + + it('emits exactly 2 IMPLEMENTS edges', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(2); + expect(edgeSet(implements_)).toEqual([ + 'ConsoleLogger → ILogger', + 'UserService → ILogger', + ]); + }); + + it('emits HAS_METHOD edges linking methods to classes', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + expect(hasMethod.length).toBe(4); + expect(edgeSet(hasMethod)).toEqual([ + 'BaseService → getName', + 'ConsoleLogger → log', + 'UserService → getUsers', + 'UserService → log', + ]); + }); + + it('no OVERRIDES edges target Property nodes', () => { + const overrides = getRelationships(result, 'OVERRIDES'); + for (const edge of overrides) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.label).not.toBe('Property'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Ambiguous: multiple definitions, imports disambiguate +// --------------------------------------------------------------------------- + +describe('TypeScript ambiguous symbol resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-ambiguous'), + () => {}, + ); + }, 60000); + + it('UserService has exactly 1 EXTENDS + 1 IMPLEMENTS', () => { + const extends_ = getRelationships(result, 'EXTENDS').filter(e => e.source === 'UserService'); + const implements_ = getRelationships(result, 'IMPLEMENTS').filter(e => e.source === 'UserService'); + expect(extends_.length).toBe(1); + expect(implements_.length).toBe(1); + }); + + it('ConsoleLogger has exactly 1 IMPLEMENTS and 0 EXTENDS', () => { + const extends_ = getRelationships(result, 'EXTENDS').filter(e => e.source === 'ConsoleLogger'); + const implements_ = getRelationships(result, 'IMPLEMENTS').filter(e => e.source === 'ConsoleLogger'); + expect(extends_.length).toBe(0); + expect(implements_.length).toBe(1); + expect(implements_[0].target).toBe('ILogger'); + }); + + it('all heritage edges point to real graph nodes', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const implements_ = getRelationships(result, 'IMPLEMENTS'); + + for (const edge of [...extends_, ...implements_]) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.properties.name).toBe(edge.target); + } + }); +}); + +describe('TypeScript call resolution with arity filtering', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-calls'), + () => {}, + ); + }, 60000); + + it('resolves run → writeAudit to src/one.ts via arity narrowing', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.length).toBe(1); + expect(calls[0].source).toBe('run'); + expect(calls[0].target).toBe('writeAudit'); + expect(calls[0].targetFilePath).toBe('src/one.ts'); + expect(calls[0].rel.reason).toBe('import-resolved'); + }); +}); + +// --------------------------------------------------------------------------- +// Member-call resolution: obj.method() resolves through pipeline +// --------------------------------------------------------------------------- + +describe('TypeScript member-call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-member-calls'), + () => {}, + ); + }, 60000); + + it('resolves processUser → save as a member call on User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('processUser'); + expect(saveCall!.targetFilePath).toBe('src/user.ts'); + }); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('emits HAS_METHOD edge from User to save', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const edge = hasMethod.find(e => e.source === 'User' && e.target === 'save'); + expect(edge).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Constructor resolution: new Foo() resolves to Class/Constructor +// --------------------------------------------------------------------------- + +describe('TypeScript constructor-call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-constructor-calls'), + () => {}, + ); + }, 60000); + + it('resolves new User() as a CALLS edge to the User class', () => { + const calls = getRelationships(result, 'CALLS'); + const ctorCall = calls.find(c => c.target === 'User'); + expect(ctorCall).toBeDefined(); + expect(ctorCall!.source).toBe('processUser'); + expect(ctorCall!.targetLabel).toBe('Class'); + expect(ctorCall!.targetFilePath).toBe('src/user.ts'); + }); + + it('also resolves user.save() as a member call', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('processUser'); + }); + + it('detects User class, save method, and processUser function', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Function')).toContain('processUser'); + }); +}); + +// --------------------------------------------------------------------------- +// Receiver-constrained resolution: typed variables disambiguate same-named methods +// --------------------------------------------------------------------------- + +describe('TypeScript receiver-constrained resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-receiver-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + 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', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + + const userSave = saveCalls.find(c => c.targetFilePath === 'src/user.ts'); + const repoSave = saveCalls.find(c => c.targetFilePath === 'src/repo.ts'); + + expect(userSave).toBeDefined(); + expect(repoSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + expect(repoSave!.source).toBe('processEntities'); + }); + + it('resolves constructor calls for both User and Repo', () => { + const calls = getRelationships(result, 'CALLS'); + const userCtor = calls.find(c => c.target === 'User' && c.targetLabel === 'Class'); + const repoCtor = calls.find(c => c.target === 'Repo' && c.targetLabel === 'Class'); + expect(userCtor).toBeDefined(); + expect(repoCtor).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Scoped receiver resolution: same variable name in different functions +// resolves to different types via scope-aware TypeEnv +// --------------------------------------------------------------------------- + +describe('TypeScript scoped receiver resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-scoped-receiver'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves entity.save() in handleUser to User.save and in handleRepo to Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + + const userSave = saveCalls.find(c => c.targetFilePath === 'src/user.ts'); + const repoSave = saveCalls.find(c => c.targetFilePath === 'src/repo.ts'); + + expect(userSave).toBeDefined(); + expect(repoSave).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Named import disambiguation: two files export same name, import resolves +// --------------------------------------------------------------------------- + +describe('TypeScript named import disambiguation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-named-imports'), + () => {}, + ); + }, 60000); + + it('resolves processInput → formatData to src/format-upper.ts via named import', () => { + const calls = getRelationships(result, 'CALLS'); + const formatCall = calls.find(c => c.target === 'formatData'); + expect(formatCall).toBeDefined(); + expect(formatCall!.source).toBe('processInput'); + expect(formatCall!.targetFilePath).toBe('src/format-upper.ts'); + }); + + it('emits IMPORTS edge to format-upper.ts', () => { + const imports = getRelationships(result, 'IMPORTS'); + const appImport = imports.find(e => e.source === 'app.ts'); + expect(appImport).toBeDefined(); + expect(appImport!.targetFilePath).toBe('src/format-upper.ts'); + }); +}); + +// --------------------------------------------------------------------------- +// Alias import resolution: import { User as U } resolves U → User +// --------------------------------------------------------------------------- + +describe('TypeScript alias import resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-alias-imports'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes with their methods', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('persist'); + }); + + it('resolves new U() to User class and new R() to Repo class via alias', () => { + const calls = getRelationships(result, 'CALLS'); + const userCtor = calls.find(c => c.target === 'User' && c.targetLabel === 'Class'); + const repoCtor = calls.find(c => c.target === 'Repo' && c.targetLabel === 'Class'); + + expect(userCtor).toBeDefined(); + expect(userCtor!.source).toBe('main'); + expect(userCtor!.targetFilePath).toBe('src/models.ts'); + + expect(repoCtor).toBeDefined(); + expect(repoCtor!.source).toBe('main'); + expect(repoCtor!.targetFilePath).toBe('src/models.ts'); + }); + + it('resolves u.save() and r.persist() as member calls', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + const persistCall = calls.find(c => c.target === 'persist'); + + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('main'); + + expect(persistCall).toBeDefined(); + expect(persistCall!.source).toBe('main'); + }); + + it('emits IMPORTS edge from app.ts to models.ts', () => { + const imports = getRelationships(result, 'IMPORTS'); + const appImport = imports.find(e => e.sourceFilePath === 'src/app.ts'); + expect(appImport).toBeDefined(); + expect(appImport!.targetFilePath).toBe('src/models.ts'); + }); +}); + +// --------------------------------------------------------------------------- +// Re-export chain: export { X } from './base' barrel pattern +// --------------------------------------------------------------------------- + +describe('TypeScript re-export chain resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-reexport-chain'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes in base.ts', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); + }); + + it('resolves new User() through re-export chain to base.ts', () => { + const calls = getRelationships(result, 'CALLS'); + const userCtor = calls.find(c => c.target === 'User' && c.targetLabel === 'Class'); + expect(userCtor).toBeDefined(); + expect(userCtor!.source).toBe('main'); + expect(userCtor!.targetFilePath).toBe('src/base.ts'); + }); + + it('resolves user.save() through re-export chain to base.ts', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('main'); + expect(saveCall!.targetFilePath).toBe('src/base.ts'); + }); + + it('resolves new Repo() through re-export chain to base.ts', () => { + const calls = getRelationships(result, 'CALLS'); + const repoCtor = calls.find(c => c.target === 'Repo' && c.targetLabel === 'Class'); + expect(repoCtor).toBeDefined(); + expect(repoCtor!.source).toBe('main'); + expect(repoCtor!.targetFilePath).toBe('src/base.ts'); + }); + + it('resolves repo.persist() through re-export chain to base.ts', () => { + const calls = getRelationships(result, 'CALLS'); + const persistCall = calls.find(c => c.target === 'persist'); + expect(persistCall).toBeDefined(); + expect(persistCall!.source).toBe('main'); + expect(persistCall!.targetFilePath).toBe('src/base.ts'); + }); +}); + +// --------------------------------------------------------------------------- +// Re-export type chain: export type { X } from './base' barrel pattern +// --------------------------------------------------------------------------- + +describe('TypeScript export type re-export chain resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-reexport-type'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes in base.ts', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); + }); + + it('resolves new User() through export type re-export chain to base.ts', () => { + const calls = getRelationships(result, 'CALLS'); + const userCtor = calls.find(c => c.target === 'User' && c.targetLabel === 'Class'); + expect(userCtor).toBeDefined(); + expect(userCtor!.source).toBe('main'); + expect(userCtor!.targetFilePath).toBe('src/base.ts'); + }); + + it('resolves user.save() through export type re-export chain to base.ts', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('main'); + expect(saveCall!.targetFilePath).toBe('src/base.ts'); + }); +}); + +// --------------------------------------------------------------------------- +// Local shadow: same-file definition takes priority over imported name +// --------------------------------------------------------------------------- + +describe('TypeScript local definition shadows import', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-local-shadow'), + () => {}, + ); + }, 60000); + + it('resolves run → save to same-file definition, not the imported one', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'run'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('src/app.ts'); + }); + + it('does NOT resolve save to utils.ts', () => { + const calls = getRelationships(result, 'CALLS'); + const saveToUtils = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/utils.ts'); + expect(saveToUtils).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Variadic resolution: rest params don't get filtered by arity +// --------------------------------------------------------------------------- + +describe('TypeScript variadic call resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-variadic-resolution'), + () => {}, + ); + }, 60000); + + it('resolves processInput → logEntry to src/logger.ts despite 3 args vs rest param', () => { + const calls = getRelationships(result, 'CALLS'); + const logCall = calls.find(c => c.target === 'logEntry'); + expect(logCall).toBeDefined(); + expect(logCall!.source).toBe('processInput'); + expect(logCall!.targetFilePath).toBe('src/logger.ts'); + }); +}); + diff --git a/gitnexus/test/unit/call-form.test.ts b/gitnexus/test/unit/call-form.test.ts new file mode 100644 index 0000000000..37d59530bb --- /dev/null +++ b/gitnexus/test/unit/call-form.test.ts @@ -0,0 +1,419 @@ +import { describe, it, expect } from 'vitest'; +import { inferCallForm, extractReceiverName, type SyntaxNode } from '../../src/core/ingestion/utils.js'; +import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import Python from 'tree-sitter-python'; +import Java from 'tree-sitter-java'; +import CSharp from 'tree-sitter-c-sharp'; +import Kotlin from 'tree-sitter-kotlin'; +import Go from 'tree-sitter-go'; +import Rust from 'tree-sitter-rust'; +import CPP from 'tree-sitter-cpp'; +import PHP from 'tree-sitter-php'; +import { LANGUAGE_QUERIES } from '../../src/core/ingestion/tree-sitter-queries.js'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; + +/** + * Helper: parse code, run the language query, and return all @call captures + * as { callNode, nameNode } pairs. + */ +function extractCallCaptures( + parser: Parser, + code: string, + language: string, +): Array<{ callNode: SyntaxNode; nameNode: SyntaxNode; calledName: string }> { + const queryStr = LANGUAGE_QUERIES[language]; + if (!queryStr) throw new Error(`No query for ${language}`); + + const tree = parser.parse(code); + const lang = parser.getLanguage(); + const query = new Parser.Query(lang, queryStr); + const matches = query.matches(tree.rootNode); + + const results: Array<{ callNode: SyntaxNode; nameNode: SyntaxNode; calledName: string }> = []; + + for (const match of matches) { + const captureMap: Record = {}; + for (const c of match.captures) { + captureMap[c.name] = c.node; + } + if (captureMap['call'] && captureMap['call.name']) { + results.push({ + callNode: captureMap['call'], + nameNode: captureMap['call.name'], + calledName: captureMap['call.name'].text, + }); + } + } + + return results; +} + +describe('inferCallForm', () => { + const parser = new Parser(); + + describe('TypeScript', () => { + it('detects free call', () => { + parser.setLanguage(TypeScript.typescript); + const captures = extractCallCaptures(parser, 'doStuff()', SupportedLanguages.TypeScript); + const match = captures.find(c => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free'); + }); + + it('detects member call', () => { + parser.setLanguage(TypeScript.typescript); + const captures = extractCallCaptures(parser, 'user.save()', SupportedLanguages.TypeScript); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member'); + }); + }); + + describe('Python', () => { + it('detects free call', () => { + parser.setLanguage(Python); + const captures = extractCallCaptures(parser, 'print_result()', SupportedLanguages.Python); + const match = captures.find(c => c.calledName === 'print_result'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free'); + }); + + it('detects member call', () => { + parser.setLanguage(Python); + const captures = extractCallCaptures(parser, 'self.save()', SupportedLanguages.Python); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member'); + }); + }); + + describe('Java', () => { + it('detects free call (no object)', () => { + parser.setLanguage(Java); + const code = `class Foo { void run() { doStuff(); } }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.Java); + const match = captures.find(c => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free'); + }); + + it('detects member call (with object)', () => { + parser.setLanguage(Java); + const code = `class Foo { void run() { user.save(); } }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.Java); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member'); + }); + }); + + describe('C#', () => { + it('detects free call', () => { + parser.setLanguage(CSharp); + const code = `class Foo { void Run() { DoStuff(); } }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.CSharp); + const match = captures.find(c => c.calledName === 'DoStuff'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free'); + }); + + it('detects member call', () => { + parser.setLanguage(CSharp); + const code = `class Foo { void Run() { user.Save(); } }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.CSharp); + const match = captures.find(c => c.calledName === 'Save'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member'); + }); + }); + + describe('Go', () => { + it('detects free call', () => { + parser.setLanguage(Go); + const code = `package main\nfunc main() { doStuff() }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.Go); + const match = captures.find(c => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free'); + }); + + it('detects member call via selector', () => { + parser.setLanguage(Go); + const code = `package main\nfunc main() { user.Save() }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.Go); + const match = captures.find(c => c.calledName === 'Save'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member'); + }); + }); + + describe('Rust', () => { + it('detects free call', () => { + parser.setLanguage(Rust); + const code = `fn main() { do_stuff(); }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.Rust); + const match = captures.find(c => c.calledName === 'do_stuff'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free'); + }); + + it('detects member call via field_expression', () => { + parser.setLanguage(Rust); + const code = `fn main() { user.save(); }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.Rust); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member'); + }); + + it('detects scoped call as free (Foo::new)', () => { + parser.setLanguage(Rust); + const code = `fn main() { Foo::new(); }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.Rust); + const match = captures.find(c => c.calledName === 'new'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free'); + }); + }); + + describe('C++', () => { + it('detects free call', () => { + parser.setLanguage(CPP); + const code = `void main() { doStuff(); }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.CPlusPlus); + const match = captures.find(c => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free'); + }); + + it('detects member call via field_expression', () => { + parser.setLanguage(CPP); + const code = `void main() { obj.run(); }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.CPlusPlus); + const match = captures.find(c => c.calledName === 'run'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member'); + }); + }); + + describe('PHP', () => { + it('detects free function call', () => { + parser.setLanguage(PHP.php); + const code = ``; + const captures = extractCallCaptures(parser, code, SupportedLanguages.PHP); + const match = captures.find(c => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free'); + }); + + it('detects member call', () => { + parser.setLanguage(PHP.php); + const code = `save(); ?>`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.PHP); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member'); + }); + + it('detects static call as member', () => { + parser.setLanguage(PHP.php); + const code = ``; + const captures = extractCallCaptures(parser, code, SupportedLanguages.PHP); + const match = captures.find(c => c.calledName === 'bar'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member'); + }); + }); + + describe('Kotlin', () => { + it('detects free call', () => { + parser.setLanguage(Kotlin); + const code = `fun main() { doStuff() }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.Kotlin); + const match = captures.find(c => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free'); + }); + + it('detects member call via navigation_expression', () => { + parser.setLanguage(Kotlin); + const code = `fun main() { user.save() }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.Kotlin); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member'); + }); + + it('Foo() is a free call (constructor_invocation only in heritage context)', () => { + parser.setLanguage(Kotlin); + const code = `fun main() { val x = Foo() }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.Kotlin); + const match = captures.find(c => c.calledName === 'Foo'); + expect(match).toBeDefined(); + // Kotlin Foo() is syntactically a call_expression, not constructor_invocation + // Constructor discrimination happens in Phase 2 via symbol kind matching + expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free'); + }); + + it('detects constructor_invocation in heritage delegation as constructor', () => { + parser.setLanguage(Kotlin); + const code = `open class Base\nclass Derived : Base()`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.Kotlin); + const match = captures.find(c => c.calledName === 'Base'); + // constructor_invocation is captured by heritage queries, not call queries + // If it happens to be captured, it should be 'constructor' + if (match) { + expect(inferCallForm(match.callNode, match.nameNode)).toBe('constructor'); + } + }); + }); +}); + +describe('extractReceiverName', () => { + const parser = new Parser(); + + describe('TypeScript', () => { + it('extracts simple identifier receiver', () => { + parser.setLanguage(TypeScript.typescript); + const captures = extractCallCaptures(parser, 'user.save()', SupportedLanguages.TypeScript); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(extractReceiverName(match!.nameNode)).toBe('user'); + }); + + it('extracts "this" as receiver', () => { + parser.setLanguage(TypeScript.typescript); + const code = `class Foo { run() { this.save(); } }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.TypeScript); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(extractReceiverName(match!.nameNode)).toBe('this'); + }); + + it('returns undefined for chained call receiver', () => { + parser.setLanguage(TypeScript.typescript); + const captures = extractCallCaptures(parser, 'getUser().save()', SupportedLanguages.TypeScript); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(extractReceiverName(match!.nameNode)).toBeUndefined(); + }); + + it('returns undefined for free call', () => { + parser.setLanguage(TypeScript.typescript); + const captures = extractCallCaptures(parser, 'doStuff()', SupportedLanguages.TypeScript); + const match = captures.find(c => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + expect(extractReceiverName(match!.nameNode)).toBeUndefined(); + }); + }); + + describe('Python', () => { + it('extracts simple identifier receiver', () => { + parser.setLanguage(Python); + const captures = extractCallCaptures(parser, 'user.save()', SupportedLanguages.Python); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(extractReceiverName(match!.nameNode)).toBe('user'); + }); + + it('extracts "self" as receiver', () => { + parser.setLanguage(Python); + const captures = extractCallCaptures(parser, 'self.save()', SupportedLanguages.Python); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(extractReceiverName(match!.nameNode)).toBe('self'); + }); + }); + + describe('Java', () => { + it('extracts receiver from method_invocation', () => { + parser.setLanguage(Java); + const code = `class Foo { void run() { user.save(); } }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.Java); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(extractReceiverName(match!.nameNode)).toBe('user'); + }); + }); + + describe('Go', () => { + it('extracts receiver from selector_expression', () => { + parser.setLanguage(Go); + const code = `package main\nfunc main() { user.Save() }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.Go); + const match = captures.find(c => c.calledName === 'Save'); + expect(match).toBeDefined(); + expect(extractReceiverName(match!.nameNode)).toBe('user'); + }); + }); + + describe('Rust', () => { + it('extracts receiver from field_expression', () => { + parser.setLanguage(Rust); + const code = `fn main() { user.save(); }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.Rust); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(extractReceiverName(match!.nameNode)).toBe('user'); + }); + }); + + describe('C#', () => { + it('extracts receiver from member_access_expression', () => { + parser.setLanguage(CSharp); + const code = `class Foo { void Run() { user.Save(); } }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.CSharp); + const match = captures.find(c => c.calledName === 'Save'); + expect(match).toBeDefined(); + expect(extractReceiverName(match!.nameNode)).toBe('user'); + }); + }); + + describe('Kotlin', () => { + it('extracts receiver from navigation_expression', () => { + parser.setLanguage(Kotlin); + const code = `fun main() { user.save() }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.Kotlin); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(extractReceiverName(match!.nameNode)).toBe('user'); + }); + }); +}); + +describe('ownerId on SymbolDefinition', () => { + it('is set for Method symbols via symbolTable.add()', () => { + const st = createSymbolTable(); + st.add('src/foo.ts', 'save', 'Method:src/foo.ts:save', 'Method', { + parameterCount: 1, + ownerId: 'Class:src/foo.ts:User', + }); + + const def = st.lookupExactFull('src/foo.ts', 'save'); + expect(def).toBeDefined(); + expect(def!.ownerId).toBe('Class:src/foo.ts:User'); + expect(def!.parameterCount).toBe(1); + }); + + it('is undefined for Function symbols (no owner)', () => { + const st = createSymbolTable(); + st.add('src/foo.ts', 'helper', 'Function:src/foo.ts:helper', 'Function'); + + const def = st.lookupExactFull('src/foo.ts', 'helper'); + expect(def).toBeDefined(); + expect(def!.ownerId).toBeUndefined(); + }); + + it('propagates ownerId through lookupFuzzy', () => { + const st = createSymbolTable(); + st.add('src/foo.ts', 'save', 'Method:src/foo.ts:save', 'Method', { + ownerId: 'Class:src/foo.ts:User', + }); + + const defs = st.lookupFuzzy('save'); + expect(defs).toHaveLength(1); + expect(defs[0].ownerId).toBe('Class:src/foo.ts:User'); + }); +}); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index 17866fb8fc..5d5cf17512 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -31,7 +31,7 @@ describe('processCallsFromExtracted', () => { expect(rels).toHaveLength(1); expect(rels[0].sourceId).toBe('Function:src/index.ts:main'); expect(rels[0].targetId).toBe('Function:src/index.ts:helper'); - expect(rels[0].confidence).toBe(0.85); + expect(rels[0].confidence).toBe(0.95); expect(rels[0].reason).toBe('same-file'); }); @@ -53,7 +53,7 @@ describe('processCallsFromExtracted', () => { expect(rels[0].reason).toBe('import-resolved'); }); - it('uses fuzzy-global with higher confidence for unique symbols', async () => { + it('resolves unique global symbol with moderate confidence', async () => { symbolTable.add('src/other.ts', 'uniqueFunc', 'Function:src/other.ts:uniqueFunc', 'Function'); const calls: ExtractedCall[] = [{ @@ -67,10 +67,10 @@ describe('processCallsFromExtracted', () => { const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); expect(rels[0].confidence).toBe(0.5); - expect(rels[0].reason).toBe('fuzzy-global'); + expect(rels[0].reason).toBe('unique-global'); }); - it('uses lower confidence for ambiguous fuzzy-global symbols', async () => { + it('refuses ambiguous global symbols — no CALLS edge created', async () => { symbolTable.add('src/a.ts', 'render', 'Function:src/a.ts:render', 'Function'); symbolTable.add('src/b.ts', 'render', 'Function:src/b.ts:render', 'Function'); @@ -82,9 +82,9 @@ describe('processCallsFromExtracted', () => { await processCallsFromExtracted(graph, calls, symbolTable, importMap); + // Ambiguous matches are refused — a wrong edge is worse than no edge const rels = graph.relationships.filter(r => r.type === 'CALLS'); - expect(rels).toHaveLength(1); - expect(rels[0].confidence).toBe(0.3); + expect(rels).toHaveLength(0); }); it('skips unresolvable calls', async () => { @@ -98,6 +98,47 @@ describe('processCallsFromExtracted', () => { expect(graph.relationshipCount).toBe(0); }); + it('refuses non-callable symbols even when the name resolves', async () => { + symbolTable.add('src/index.ts', 'Widget', 'Class:src/index.ts:Widget', 'Class'); + + const calls: ExtractedCall[] = [{ + filePath: 'src/index.ts', + calledName: 'Widget', + sourceId: 'Function:src/index.ts:main', + }]; + + await processCallsFromExtracted(graph, calls, symbolTable, importMap); + expect(graph.relationshipCount).toBe(0); + }); + + it('refuses CALLS edges to Interface symbols', async () => { + symbolTable.add('src/types.ts', 'Serializable', 'Interface:src/types.ts:Serializable', 'Interface'); + importMap.set('src/index.ts', new Set(['src/types.ts'])); + + const calls: ExtractedCall[] = [{ + filePath: 'src/index.ts', + calledName: 'Serializable', + sourceId: 'Function:src/index.ts:main', + }]; + + await processCallsFromExtracted(graph, calls, symbolTable, importMap); + expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(0); + }); + + it('refuses CALLS edges to Enum symbols', async () => { + symbolTable.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); + importMap.set('src/index.ts', new Set(['src/status.ts'])); + + const calls: ExtractedCall[] = [{ + filePath: 'src/index.ts', + calledName: 'Status', + sourceId: 'Function:src/index.ts:main', + }]; + + await processCallsFromExtracted(graph, calls, symbolTable, importMap); + expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(0); + }); + it('prefers same-file over import-resolved', async () => { // Symbol exists both locally and in imported file symbolTable.add('src/index.ts', 'render', 'Function:src/index.ts:render', 'Function'); @@ -132,6 +173,42 @@ describe('processCallsFromExtracted', () => { expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(2); }); + it('uses arity to disambiguate import-scoped callable candidates', async () => { + symbolTable.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { parameterCount: 0 }); + symbolTable.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { parameterCount: 1 }); + importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts'])); + + const calls: ExtractedCall[] = [{ + filePath: 'src/index.ts', + calledName: 'log', + sourceId: 'Function:src/index.ts:main', + argCount: 1, + }]; + + await processCallsFromExtracted(graph, calls, symbolTable, importMap); + + const rels = graph.relationships.filter(r => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe('Function:src/formatter.ts:log'); + expect(rels[0].reason).toBe('import-resolved'); + }); + + it('refuses ambiguous call targets when arity does not produce a unique match', async () => { + symbolTable.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { parameterCount: 1 }); + symbolTable.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { parameterCount: 1 }); + importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts'])); + + const calls: ExtractedCall[] = [{ + filePath: 'src/index.ts', + calledName: 'log', + sourceId: 'Function:src/index.ts:main', + argCount: 1, + }]; + + await processCallsFromExtracted(graph, calls, symbolTable, importMap); + expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(0); + }); + it('calls progress callback', async () => { symbolTable.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); @@ -140,7 +217,7 @@ describe('processCallsFromExtracted', () => { ]; const onProgress = vi.fn(); - await processCallsFromExtracted(graph, calls, symbolTable, importMap, onProgress); + await processCallsFromExtracted(graph, calls, symbolTable, importMap, undefined, onProgress); // Final progress call expect(onProgress).toHaveBeenCalledWith(1, 1); @@ -150,4 +227,126 @@ describe('processCallsFromExtracted', () => { await processCallsFromExtracted(graph, [], symbolTable, importMap); expect(graph.relationshipCount).toBe(0); }); + + // ---- Constructor-aware resolution (Phase 2) ---- + + it('resolves constructor call to Class when no Constructor node exists', async () => { + symbolTable.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + importMap.set('src/index.ts', new Set(['src/models.ts'])); + + const calls: ExtractedCall[] = [{ + filePath: 'src/index.ts', + calledName: 'User', + sourceId: 'Function:src/index.ts:main', + callForm: 'constructor', + }]; + + await processCallsFromExtracted(graph, calls, symbolTable, importMap); + + const rels = graph.relationships.filter(r => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe('Class:src/models.ts:User'); + expect(rels[0].reason).toBe('import-resolved'); + }); + + it('resolves constructor call to Constructor node over Class node', async () => { + symbolTable.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + symbolTable.add('src/models.ts', 'User', 'Constructor:src/models.ts:User', 'Constructor', { parameterCount: 1 }); + importMap.set('src/index.ts', new Set(['src/models.ts'])); + + const calls: ExtractedCall[] = [{ + filePath: 'src/index.ts', + calledName: 'User', + sourceId: 'Function:src/index.ts:main', + argCount: 1, + callForm: 'constructor', + }]; + + await processCallsFromExtracted(graph, calls, symbolTable, importMap); + + const rels = graph.relationships.filter(r => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe('Constructor:src/models.ts:User'); + }); + + it('refuses Class target without callForm=constructor (existing behavior)', async () => { + symbolTable.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + importMap.set('src/index.ts', new Set(['src/models.ts'])); + + const calls: ExtractedCall[] = [{ + filePath: 'src/index.ts', + calledName: 'User', + sourceId: 'Function:src/index.ts:main', + // no callForm — treated as regular call + }]; + + await processCallsFromExtracted(graph, calls, symbolTable, importMap); + + // Without constructor callForm, Class is not in CALLABLE_SYMBOL_TYPES → refused + const rels = graph.relationships.filter(r => r.type === 'CALLS'); + expect(rels).toHaveLength(0); + }); + + it('constructor call falls back to callable types when no Constructor/Class found', async () => { + // Edge case: calledName matches a Function, not a Class/Constructor + symbolTable.add('src/utils.ts', 'Widget', 'Function:src/utils.ts:Widget', 'Function'); + importMap.set('src/index.ts', new Set(['src/utils.ts'])); + + const calls: ExtractedCall[] = [{ + filePath: 'src/index.ts', + calledName: 'Widget', + sourceId: 'Function:src/index.ts:main', + callForm: 'constructor', + }]; + + await processCallsFromExtracted(graph, calls, symbolTable, importMap); + + // Falls back to callable filtering — Function is callable + const rels = graph.relationships.filter(r => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe('Function:src/utils.ts:Widget'); + }); + + it('constructor arity filtering narrows overloaded constructors', async () => { + symbolTable.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(0)', 'Constructor', { parameterCount: 0 }); + symbolTable.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(2)', 'Constructor', { parameterCount: 2 }); + importMap.set('src/index.ts', new Set(['src/models.ts'])); + + const calls: ExtractedCall[] = [{ + filePath: 'src/index.ts', + calledName: 'User', + sourceId: 'Function:src/index.ts:main', + argCount: 2, + callForm: 'constructor', + }]; + + await processCallsFromExtracted(graph, calls, symbolTable, importMap); + + const rels = graph.relationships.filter(r => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe('Constructor:src/models.ts:User(2)'); + }); + + it('cannot discriminate same-arity overloads by parameter type (known limitation)', async () => { + // Java: save(User u) vs save(Repo r) — both have parameterCount: 1 + // The system counts arguments, not their types, so both candidates match equally. + // With parameter type capture, receiver-typed calls could be discriminated. + symbolTable.add('src/UserDao.ts', 'save', 'Function:src/UserDao.ts:save', 'Function', { parameterCount: 1 }); + symbolTable.add('src/RepoDao.ts', 'save', 'Function:src/RepoDao.ts:save', 'Function', { parameterCount: 1 }); + importMap.set('src/index.ts', new Set(['src/UserDao.ts', 'src/RepoDao.ts'])); + + const calls: ExtractedCall[] = [{ + filePath: 'src/index.ts', + calledName: 'save', + sourceId: 'Function:src/index.ts:main', + argCount: 1, + }]; + + await processCallsFromExtracted(graph, calls, symbolTable, importMap); + const rels = graph.relationships.filter(r => r.type === 'CALLS'); + + // Both candidates match (same name, same arity) — ambiguous → no edge emitted + // Discriminating by parameter type would require capturing type annotations at call sites + expect(rels).toHaveLength(0); + }); }); diff --git a/gitnexus/test/unit/has-method.test.ts b/gitnexus/test/unit/has-method.test.ts new file mode 100644 index 0000000000..ba758e2fb0 --- /dev/null +++ b/gitnexus/test/unit/has-method.test.ts @@ -0,0 +1,434 @@ +import { describe, it, expect } from 'vitest'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import Python from 'tree-sitter-python'; +import Java from 'tree-sitter-java'; +import CPP from 'tree-sitter-cpp'; +import Rust from 'tree-sitter-rust'; +import CSharp from 'tree-sitter-c-sharp'; +import Go from 'tree-sitter-go'; +import { findEnclosingClassId, CLASS_CONTAINER_TYPES, CONTAINER_TYPE_TO_LABEL } from '../../src/core/ingestion/utils.js'; + +function parseCode(language: any, code: string): Parser.Tree { + const parser = new Parser(); + parser.setLanguage(language); + return parser.parse(code); +} + +/** Find the first descendant node matching a predicate (BFS). */ +function findNode(root: Parser.SyntaxNode, predicate: (n: Parser.SyntaxNode) => boolean): Parser.SyntaxNode | null { + const queue: Parser.SyntaxNode[] = [root]; + while (queue.length > 0) { + const node = queue.shift()!; + if (predicate(node)) return node; + for (let i = 0; i < node.childCount; i++) { + queue.push(node.child(i)!); + } + } + return null; +} + +describe('CLASS_CONTAINER_TYPES', () => { + it('contains expected class-like AST node types', () => { + expect(CLASS_CONTAINER_TYPES.has('class_declaration')).toBe(true); + expect(CLASS_CONTAINER_TYPES.has('interface_declaration')).toBe(true); + expect(CLASS_CONTAINER_TYPES.has('struct_declaration')).toBe(true); + expect(CLASS_CONTAINER_TYPES.has('impl_item')).toBe(true); + expect(CLASS_CONTAINER_TYPES.has('class_specifier')).toBe(true); + expect(CLASS_CONTAINER_TYPES.has('class_definition')).toBe(true); + }); + + it('does not contain function types', () => { + expect(CLASS_CONTAINER_TYPES.has('function_declaration')).toBe(false); + expect(CLASS_CONTAINER_TYPES.has('function_definition')).toBe(false); + }); +}); + +describe('CONTAINER_TYPE_TO_LABEL', () => { + it('maps class-like types to correct labels', () => { + expect(CONTAINER_TYPE_TO_LABEL['class_declaration']).toBe('Class'); + expect(CONTAINER_TYPE_TO_LABEL['interface_declaration']).toBe('Interface'); + expect(CONTAINER_TYPE_TO_LABEL['struct_declaration']).toBe('Struct'); + expect(CONTAINER_TYPE_TO_LABEL['impl_item']).toBe('Impl'); + expect(CONTAINER_TYPE_TO_LABEL['trait_item']).toBe('Trait'); + expect(CONTAINER_TYPE_TO_LABEL['record_declaration']).toBe('Record'); + expect(CONTAINER_TYPE_TO_LABEL['protocol_declaration']).toBe('Interface'); + }); +}); + +describe('findEnclosingClassId', () => { + const filePath = 'test/example.ts'; + + describe('TypeScript', () => { + it('finds enclosing class for a method', () => { + const tree = parseCode(TypeScript.typescript, ` +class MyService { + getData() { + return 42; + } +} +`); + // Find the method_definition node for getData + const methodNode = findNode(tree.rootNode, n => n.type === 'method_definition'); + expect(methodNode).not.toBeNull(); + + // Find the identifier 'getData' inside the method + const nameNode = findNode(methodNode!, n => n.type === 'property_identifier' && n.text === 'getData'); + expect(nameNode).not.toBeNull(); + + const result = findEnclosingClassId(nameNode!, filePath); + expect(result).toBe('Class:test/example.ts:MyService'); + }); + + it('finds enclosing interface for a method signature', () => { + const tree = parseCode(TypeScript.typescript, ` +interface MyInterface { + doSomething(): void; +} +`); + // In TS, interface methods are method_signature nodes — find method name + const nameNode = findNode(tree.rootNode, n => n.type === 'property_identifier' && n.text === 'doSomething'); + if (nameNode) { + const result = findEnclosingClassId(nameNode, filePath); + expect(result).toBe('Interface:test/example.ts:MyInterface'); + } + }); + + it('returns null for a top-level function', () => { + const tree = parseCode(TypeScript.typescript, ` +function topLevel() { + return 1; +} +`); + const nameNode = findNode(tree.rootNode, n => n.type === 'identifier' && n.text === 'topLevel'); + expect(nameNode).not.toBeNull(); + + const result = findEnclosingClassId(nameNode!, filePath); + expect(result).toBeNull(); + }); + + it('returns null when node has no parent', () => { + // Root node's parent is null + const tree = parseCode(TypeScript.typescript, 'const x = 1;'); + const result = findEnclosingClassId(tree.rootNode, filePath); + expect(result).toBeNull(); + }); + }); + + describe('Python', () => { + it('finds enclosing class for a method', () => { + const tree = parseCode(Python, ` +class Calculator: + def add(self, a, b): + return a + b +`); + const methodNode = findNode(tree.rootNode, n => n.type === 'function_definition'); + expect(methodNode).not.toBeNull(); + + const nameNode = findNode(methodNode!, n => n.type === 'identifier' && n.text === 'add'); + expect(nameNode).not.toBeNull(); + + const result = findEnclosingClassId(nameNode!, 'test/calc.py'); + expect(result).toBe('Class:test/calc.py:Calculator'); + }); + }); + + describe('Java', () => { + it('finds enclosing class for a method', () => { + const tree = parseCode(Java, ` +class UserService { + public void save(User user) {} +} +`); + const methodNode = findNode(tree.rootNode, n => n.type === 'method_declaration'); + expect(methodNode).not.toBeNull(); + + const nameNode = findNode(methodNode!, n => n.type === 'identifier' && n.text === 'save'); + expect(nameNode).not.toBeNull(); + + const result = findEnclosingClassId(nameNode!, 'test/UserService.java'); + expect(result).toBe('Class:test/UserService.java:UserService'); + }); + + it('finds enclosing interface for a method', () => { + const tree = parseCode(Java, ` +interface Repository { + void findById(int id); +} +`); + const methodNode = findNode(tree.rootNode, n => n.type === 'method_declaration'); + expect(methodNode).not.toBeNull(); + + const nameNode = findNode(methodNode!, n => n.type === 'identifier' && n.text === 'findById'); + expect(nameNode).not.toBeNull(); + + const result = findEnclosingClassId(nameNode!, 'test/Repository.java'); + expect(result).toBe('Interface:test/Repository.java:Repository'); + }); + }); + + describe('C++', () => { + it('finds enclosing class_specifier for a method', () => { + const tree = parseCode(CPP, ` +class Vector { +public: + int size() { return _size; } +private: + int _size; +}; +`); + // In C++ tree-sitter, the class is a class_specifier + const classNode = findNode(tree.rootNode, n => n.type === 'class_specifier'); + expect(classNode).not.toBeNull(); + + // Find a function_definition inside the class + const funcDef = findNode(classNode!, n => n.type === 'function_definition'); + expect(funcDef).not.toBeNull(); + + const nameNode = findNode(funcDef!, n => n.type === 'identifier' && n.text === 'size'); + if (nameNode) { + const result = findEnclosingClassId(nameNode, 'test/vector.h'); + expect(result).toBe('Class:test/vector.h:Vector'); + } + }); + + it('finds enclosing struct_specifier for a method', () => { + const tree = parseCode(CPP, ` +struct Point { + double distance() { return 0; } +}; +`); + const funcDef = findNode(tree.rootNode, n => n.type === 'function_definition'); + if (funcDef) { + const nameNode = findNode(funcDef, n => n.type === 'identifier' && n.text === 'distance'); + if (nameNode) { + const result = findEnclosingClassId(nameNode, 'test/point.h'); + expect(result).toBe('Struct:test/point.h:Point'); + } + } + }); + }); + + describe('Rust', () => { + it('finds enclosing impl_item for a method', () => { + const tree = parseCode(Rust, ` +struct Counter { + count: u32, +} +impl Counter { + fn increment(&mut self) { + self.count += 1; + } +} +`); + const implNode = findNode(tree.rootNode, n => n.type === 'impl_item'); + expect(implNode).not.toBeNull(); + + const funcItem = findNode(implNode!, n => n.type === 'function_item'); + expect(funcItem).not.toBeNull(); + + const nameNode = findNode(funcItem!, n => n.type === 'identifier' && n.text === 'increment'); + expect(nameNode).not.toBeNull(); + + const result = findEnclosingClassId(nameNode!, 'test/counter.rs'); + expect(result).toBe('Impl:test/counter.rs:Counter'); + }); + + it('picks struct name (not trait name) for impl Trait for Struct', () => { + const tree = parseCode(Rust, ` +struct MyStruct { + value: i32, +} + +trait MyTrait { + fn do_something(&self); +} + +impl MyTrait for MyStruct { + fn do_something(&self) { + println!("{}", self.value); + } +} +`); + // Find the impl_item that has a `for` keyword (impl Trait for Struct) + const implNode = findNode(tree.rootNode, n => + n.type === 'impl_item' && n.children?.some((c: any) => c.text === 'for') + ); + expect(implNode).not.toBeNull(); + + const funcItem = findNode(implNode!, n => n.type === 'function_item'); + expect(funcItem).not.toBeNull(); + + const nameNode = findNode(funcItem!, n => n.type === 'identifier' && n.text === 'do_something'); + expect(nameNode).not.toBeNull(); + + const result = findEnclosingClassId(nameNode!, 'test/my_struct.rs'); + // Should resolve to MyStruct (the implementing type), NOT MyTrait + expect(result).toBe('Impl:test/my_struct.rs:MyStruct'); + }); + + it('still picks struct name for plain impl Struct (no trait)', () => { + const tree = parseCode(Rust, ` +struct Counter { + count: u32, +} +impl Counter { + fn increment(&mut self) { + self.count += 1; + } +} +`); + const implNode = findNode(tree.rootNode, n => n.type === 'impl_item'); + expect(implNode).not.toBeNull(); + + const funcItem = findNode(implNode!, n => n.type === 'function_item'); + expect(funcItem).not.toBeNull(); + + const nameNode = findNode(funcItem!, n => n.type === 'identifier' && n.text === 'increment'); + expect(nameNode).not.toBeNull(); + + const result = findEnclosingClassId(nameNode!, 'test/counter.rs'); + expect(result).toBe('Impl:test/counter.rs:Counter'); + }); + + it('finds enclosing trait_item for a method', () => { + const tree = parseCode(Rust, ` +trait Drawable { + fn draw(&self); +} +`); + const traitNode = findNode(tree.rootNode, n => n.type === 'trait_item'); + expect(traitNode).not.toBeNull(); + + const funcItem = findNode(traitNode!, n => n.type === 'function_signature_item' || n.type === 'function_item'); + if (funcItem) { + const nameNode = findNode(funcItem, n => n.type === 'identifier' && n.text === 'draw'); + if (nameNode) { + const result = findEnclosingClassId(nameNode, 'test/draw.rs'); + expect(result).toBe('Trait:test/draw.rs:Drawable'); + } + } + }); + }); + + describe('C#', () => { + it('finds enclosing class for a method', () => { + const tree = parseCode(CSharp, ` +class OrderService { + public void Process() {} +} +`); + const methodNode = findNode(tree.rootNode, n => n.type === 'method_declaration'); + expect(methodNode).not.toBeNull(); + + const nameNode = findNode(methodNode!, n => n.type === 'identifier' && n.text === 'Process'); + expect(nameNode).not.toBeNull(); + + const result = findEnclosingClassId(nameNode!, 'test/OrderService.cs'); + expect(result).toBe('Class:test/OrderService.cs:OrderService'); + }); + + it('finds enclosing record for a method', () => { + const tree = parseCode(CSharp, ` +record Person { + public string GetName() { return ""; } +} +`); + const methodNode = findNode(tree.rootNode, n => n.type === 'method_declaration'); + if (methodNode) { + const nameNode = findNode(methodNode, n => n.type === 'identifier' && n.text === 'GetName'); + if (nameNode) { + const result = findEnclosingClassId(nameNode, 'test/Person.cs'); + expect(result).toBe('Record:test/Person.cs:Person'); + } + } + }); + + it('finds enclosing struct for a method', () => { + const tree = parseCode(CSharp, ` +struct Vector2 { + public float Length() { return 0; } +} +`); + const methodNode = findNode(tree.rootNode, n => n.type === 'method_declaration'); + if (methodNode) { + const nameNode = findNode(methodNode, n => n.type === 'identifier' && n.text === 'Length'); + if (nameNode) { + const result = findEnclosingClassId(nameNode, 'test/Vector2.cs'); + expect(result).toBe('Struct:test/Vector2.cs:Vector2'); + } + } + }); + }); + + describe('Go', () => { + it('returns receiver struct ID for Go methods', () => { + // Go methods have receiver parameter: func (s *Server) Start() {} + // findEnclosingClassId extracts the receiver type to link method → struct + const tree = parseCode(Go, ` +package main + +type Server struct { + port int +} + +func (s *Server) Start() {} +`); + const methodNode = findNode(tree.rootNode, n => n.type === 'method_declaration'); + expect(methodNode).not.toBeNull(); + + const nameNode = findNode(methodNode!, n => n.type === 'field_identifier' && n.text === 'Start'); + if (nameNode) { + const result = findEnclosingClassId(nameNode, 'test/server.go'); + expect(result).not.toBeNull(); + // Should generate a Struct ID for "Server" + expect(result).toContain('Server'); + } + }); + }); + + describe('edge cases', () => { + it('handles nested classes — returns innermost enclosing class', () => { + const tree = parseCode(TypeScript.typescript, ` +class Outer { + inner = class Inner { + doWork() {} + } +} +`); + const methodNode = findNode(tree.rootNode, n => n.type === 'method_definition'); + if (methodNode) { + const nameNode = findNode(methodNode, n => n.type === 'property_identifier' && n.text === 'doWork'); + if (nameNode) { + const result = findEnclosingClassId(nameNode, filePath); + // Should find the innermost class (Inner, which is a class node) + expect(result).not.toBeNull(); + // The result should reference the inner class, not the outer + } + } + }); + + it('returns null for a node without parent', () => { + // Simulate a node with null parent + const fakeNode = { parent: null }; + const result = findEnclosingClassId(fakeNode, filePath); + expect(result).toBeNull(); + }); + + it('skips containers without a name node', () => { + // Simulate AST nodes where the class container has no name + const fakeClassNode = { + type: 'class_declaration', + childForFieldName: () => null, + children: [], + parent: null, + }; + const fakeChild = { + parent: fakeClassNode, + }; + const result = findEnclosingClassId(fakeChild, filePath); + // The class has no name, so should return null + expect(result).toBeNull(); + }); + }); +}); diff --git a/gitnexus/test/unit/heritage-processor.test.ts b/gitnexus/test/unit/heritage-processor.test.ts index 0fbdc7803f..20af0c4959 100644 --- a/gitnexus/test/unit/heritage-processor.test.ts +++ b/gitnexus/test/unit/heritage-processor.test.ts @@ -2,15 +2,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { processHeritageFromExtracted } from '../../src/core/ingestion/heritage-processor.js'; import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import { createImportMap } from '../../src/core/ingestion/import-processor.js'; +import type { ImportMap } from '../../src/core/ingestion/import-processor.js'; import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js'; describe('processHeritageFromExtracted', () => { let graph: ReturnType; let symbolTable: ReturnType; + let importMap: ImportMap; beforeEach(() => { graph = createKnowledgeGraph(); symbolTable = createSymbolTable(); + importMap = createImportMap(); }); describe('extends', () => { @@ -25,7 +29,7 @@ describe('processHeritageFromExtracted', () => { kind: 'extends', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable); + await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); const rels = graph.relationships.filter(r => r.type === 'EXTENDS'); expect(rels).toHaveLength(1); @@ -42,7 +46,7 @@ describe('processHeritageFromExtracted', () => { kind: 'extends', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable); + await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); const rels = graph.relationships.filter(r => r.type === 'EXTENDS'); expect(rels).toHaveLength(1); @@ -60,7 +64,7 @@ describe('processHeritageFromExtracted', () => { kind: 'extends', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable); + await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); expect(graph.relationshipCount).toBe(0); }); }); @@ -77,7 +81,7 @@ describe('processHeritageFromExtracted', () => { kind: 'implements', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable); + await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); const rels = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); expect(rels).toHaveLength(1); @@ -97,7 +101,7 @@ describe('processHeritageFromExtracted', () => { kind: 'trait-impl', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable); + await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); const rels = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); expect(rels).toHaveLength(1); @@ -105,6 +109,171 @@ describe('processHeritageFromExtracted', () => { }); }); + describe('C# interface resolution from extends captures', () => { + it('emits IMPLEMENTS when parent is an Interface in symbol table', async () => { + symbolTable.add('src/Service.cs', 'UserService', 'Class:src/Service.cs:UserService', 'Class'); + symbolTable.add('src/IService.cs', 'IService', 'Interface:src/IService.cs:IService', 'Interface'); + + const heritage: ExtractedHeritage[] = [{ + filePath: 'src/Service.cs', + className: 'UserService', + parentName: 'IService', + kind: 'extends', // C# base_list always sends extends + }]; + + await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + + const impls = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); + const exts = graph.relationships.filter(r => r.type === 'EXTENDS'); + expect(impls).toHaveLength(1); + expect(exts).toHaveLength(0); + expect(impls[0].sourceId).toBe('Class:src/Service.cs:UserService'); + expect(impls[0].targetId).toBe('Interface:src/IService.cs:IService'); + }); + + it('emits EXTENDS when parent is a Class in symbol table', async () => { + symbolTable.add('src/Admin.cs', 'AdminUser', 'Class:src/Admin.cs:AdminUser', 'Class'); + symbolTable.add('src/User.cs', 'User', 'Class:src/User.cs:User', 'Class'); + + const heritage: ExtractedHeritage[] = [{ + filePath: 'src/Admin.cs', + className: 'AdminUser', + parentName: 'User', + kind: 'extends', + }]; + + await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + + const exts = graph.relationships.filter(r => r.type === 'EXTENDS'); + const impls = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); + expect(exts).toHaveLength(1); + expect(impls).toHaveLength(0); + }); + + it('uses I[A-Z] heuristic for unresolved interface names in C#', async () => { + // IDisposable is not in symbol table (external .NET type) + const heritage: ExtractedHeritage[] = [{ + filePath: 'src/Resource.cs', + className: 'Resource', + parentName: 'IDisposable', + kind: 'extends', + }]; + + await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + + const impls = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); + expect(impls).toHaveLength(1); + expect(impls[0].targetId).toContain('IDisposable'); + }); + + it('does not apply I[A-Z] heuristic for TypeScript — unresolved IFoo should be EXTENDS', async () => { + // The I[A-Z] convention is C#/Java-specific; TypeScript files should not be affected + const heritage: ExtractedHeritage[] = [{ + filePath: 'src/service.ts', + className: 'MyService', + parentName: 'IFoo', + kind: 'extends', + }]; + + await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + + const exts = graph.relationships.filter(r => r.type === 'EXTENDS'); + const impls = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); + expect(exts).toHaveLength(1); + expect(impls).toHaveLength(0); + }); + + it('does not misclassify non-I-prefixed unresolved names as interfaces', async () => { + const heritage: ExtractedHeritage[] = [{ + filePath: 'src/Derived.cs', + className: 'Derived', + parentName: 'BaseClass', + kind: 'extends', + }]; + + await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + + const exts = graph.relationships.filter(r => r.type === 'EXTENDS'); + const impls = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); + expect(exts).toHaveLength(1); + expect(impls).toHaveLength(0); + }); + + it('does not match single-letter I names like "I" or "Id"', async () => { + const heritage: ExtractedHeritage[] = [{ + filePath: 'src/Thing.cs', + className: 'Thing', + parentName: 'Id', + kind: 'extends', + }]; + + await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + + // "Id" starts with I but second char is lowercase — should be EXTENDS + const exts = graph.relationships.filter(r => r.type === 'EXTENDS'); + expect(exts).toHaveLength(1); + }); + + it('handles mixed class + interface base_list from C#', async () => { + symbolTable.add('src/Repo.cs', 'UserRepo', 'Class:src/Repo.cs:UserRepo', 'Class'); + symbolTable.add('src/Base.cs', 'BaseRepository', 'Class:src/Base.cs:BaseRepository', 'Class'); + symbolTable.add('src/IRepo.cs', 'IRepository', 'Interface:src/IRepo.cs:IRepository', 'Interface'); + symbolTable.add('src/IDisp.cs', 'IDisposable', 'Interface:src/IDisp.cs:IDisposable', 'Interface'); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/Repo.cs', className: 'UserRepo', parentName: 'BaseRepository', kind: 'extends' }, + { filePath: 'src/Repo.cs', className: 'UserRepo', parentName: 'IRepository', kind: 'extends' }, + { filePath: 'src/Repo.cs', className: 'UserRepo', parentName: 'IDisposable', kind: 'extends' }, + ]; + + await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + + const exts = graph.relationships.filter(r => r.type === 'EXTENDS'); + const impls = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); + expect(exts).toHaveLength(1); // BaseRepository + expect(impls).toHaveLength(2); // IRepository + IDisposable + }); + }); + + describe('Swift protocol conformance from extends captures', () => { + it('defaults unresolved PascalCase protocol names to IMPLEMENTS for Swift', async () => { + // Codable, Hashable, Equatable etc. are protocols — no I-prefix convention in Swift + const heritage: ExtractedHeritage[] = [{ + filePath: 'src/Model.swift', + className: 'User', + parentName: 'Codable', + kind: 'extends', + }]; + + await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + + const impls = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); + const exts = graph.relationships.filter(r => r.type === 'EXTENDS'); + expect(impls).toHaveLength(1); + expect(exts).toHaveLength(0); + expect(impls[0].targetId).toContain('Codable'); + }); + + it('still uses symbol table authoritatively for Swift (Tier 1 takes precedence)', async () => { + // When the parent is in the symbol table as a Class, EXTENDS wins even in Swift + symbolTable.add('src/Animal.swift', 'Animal', 'Class:src/Animal.swift:Animal', 'Class'); + + const heritage: ExtractedHeritage[] = [{ + filePath: 'src/Dog.swift', + className: 'Dog', + parentName: 'Animal', + kind: 'extends', + }]; + + await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + + const exts = graph.relationships.filter(r => r.type === 'EXTENDS'); + const impls = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); + expect(exts).toHaveLength(1); + expect(impls).toHaveLength(0); + }); + }); + it('handles multiple heritage entries', async () => { const heritage: ExtractedHeritage[] = [ { filePath: 'src/a.ts', className: 'A', parentName: 'B', kind: 'extends' }, @@ -112,7 +281,7 @@ describe('processHeritageFromExtracted', () => { { filePath: 'src/e.rs', className: 'E', parentName: 'F', kind: 'trait-impl' }, ]; - await processHeritageFromExtracted(graph, heritage, symbolTable); + await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); expect(graph.relationships.filter(r => r.type === 'EXTENDS')).toHaveLength(1); expect(graph.relationships.filter(r => r.type === 'IMPLEMENTS')).toHaveLength(2); }); @@ -123,12 +292,12 @@ describe('processHeritageFromExtracted', () => { ]; const onProgress = vi.fn(); - await processHeritageFromExtracted(graph, heritage, symbolTable, onProgress); + await processHeritageFromExtracted(graph, heritage, symbolTable, importMap, undefined, onProgress); expect(onProgress).toHaveBeenCalledWith(1, 1); }); it('handles empty heritage array', async () => { - await processHeritageFromExtracted(graph, [], symbolTable); + await processHeritageFromExtracted(graph, [], symbolTable, importMap); expect(graph.relationshipCount).toBe(0); }); }); diff --git a/gitnexus/test/unit/method-signature.test.ts b/gitnexus/test/unit/method-signature.test.ts new file mode 100644 index 0000000000..c39bb57586 --- /dev/null +++ b/gitnexus/test/unit/method-signature.test.ts @@ -0,0 +1,410 @@ +import { describe, it, expect } from 'vitest'; +import { extractMethodSignature } from '../../src/core/ingestion/utils.js'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import Python from 'tree-sitter-python'; +import Java from 'tree-sitter-java'; +import CSharp from 'tree-sitter-c-sharp'; +import Kotlin from 'tree-sitter-kotlin'; +import CPP from 'tree-sitter-cpp'; +import Go from 'tree-sitter-go'; +import Rust from 'tree-sitter-rust'; + +describe('extractMethodSignature', () => { + const parser = new Parser(); + + it('returns zero params and no return type for null node', () => { + const sig = extractMethodSignature(null); + expect(sig.parameterCount).toBe(0); + expect(sig.returnType).toBeUndefined(); + }); + + describe('TypeScript', () => { + it('extracts params and return type from a typed method', () => { + parser.setLanguage(TypeScript.typescript); + const code = `class Foo { + greet(name: string, age: number): boolean { return true; } +}`; + const tree = parser.parse(code); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.childForFieldName('body')!; + const methodNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(methodNode); + expect(sig.parameterCount).toBe(2); + expect(sig.returnType).toBe('boolean'); + }); + + it('extracts zero params from a method with no parameters', () => { + parser.setLanguage(TypeScript.typescript); + const code = `class Foo { + run(): void {} +}`; + const tree = parser.parse(code); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.childForFieldName('body')!; + const methodNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(methodNode); + expect(sig.parameterCount).toBe(0); + expect(sig.returnType).toBe('void'); + }); + + it('extracts params without return type annotation', () => { + parser.setLanguage(TypeScript.typescript); + const code = `class Foo { + process(x: number) { return x + 1; } +}`; + const tree = parser.parse(code); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.childForFieldName('body')!; + const methodNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(methodNode); + expect(sig.parameterCount).toBe(1); + expect(sig.returnType).toBeUndefined(); + }); + }); + + describe('Python', () => { + it('skips self parameter', () => { + parser.setLanguage(Python); + const code = `class Foo: + def bar(self, x, y): + pass`; + const tree = parser.parse(code); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.childForFieldName('body')!; + const methodNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(methodNode); + expect(sig.parameterCount).toBe(2); + expect(sig.returnType).toBeUndefined(); + }); + + it('handles method with only self', () => { + parser.setLanguage(Python); + const code = `class Foo: + def noop(self): + pass`; + const tree = parser.parse(code); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.childForFieldName('body')!; + const methodNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(methodNode); + expect(sig.parameterCount).toBe(0); + }); + + it('handles Python return type annotation', () => { + parser.setLanguage(Python); + const code = `class Foo: + def bar(self, x: int) -> bool: + return True`; + const tree = parser.parse(code); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.childForFieldName('body')!; + const methodNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(methodNode); + expect(sig.parameterCount).toBe(1); + // The important thing is parameterCount is correct; returnType may vary. + }); + }); + + describe('Java', () => { + it('extracts params from a Java method', () => { + parser.setLanguage(Java); + const code = `class Foo { + public int add(int a, int b) { return a + b; } +}`; + const tree = parser.parse(code); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.childForFieldName('body')!; + const methodNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(methodNode); + expect(sig.parameterCount).toBe(2); + }); + + it('extracts zero params from no-arg Java method', () => { + parser.setLanguage(Java); + const code = `class Foo { + public void run() {} +}`; + const tree = parser.parse(code); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.childForFieldName('body')!; + const methodNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(methodNode); + expect(sig.parameterCount).toBe(0); + }); + }); + + describe('Kotlin', () => { + it('extracts params from a Kotlin function declaration', () => { + parser.setLanguage(Kotlin); + const code = `object OneArg { + fun writeAudit(message: String): String { + return message + } +}`; + const tree = parser.parse(code); + const objectNode = tree.rootNode.child(0)!; + const classBody = objectNode.namedChild(1)!; + const functionNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(functionNode); + expect(sig.parameterCount).toBe(1); + }); + + it('extracts zero params from a no-arg Kotlin function', () => { + parser.setLanguage(Kotlin); + const code = `object ZeroArg { + fun writeAudit(): String { + return "zero" + } +}`; + const tree = parser.parse(code); + const objectNode = tree.rootNode.child(0)!; + const classBody = objectNode.namedChild(1)!; + const functionNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(functionNode); + expect(sig.parameterCount).toBe(0); + }); + }); + + describe('C++', () => { + it('extracts params from a nested C++ declarator', () => { + parser.setLanguage(CPP); + const code = `inline const char* write_audit(const char* message) { + return message; +}`; + const tree = parser.parse(code); + const functionNode = tree.rootNode.namedChild(0)!; + + const sig = extractMethodSignature(functionNode); + expect(sig.parameterCount).toBe(1); + }); + + it('extracts zero params from a no-arg C++ function', () => { + parser.setLanguage(CPP); + const code = `inline const char* write_audit() { + return "zero"; +}`; + const tree = parser.parse(code); + const functionNode = tree.rootNode.namedChild(0)!; + + const sig = extractMethodSignature(functionNode); + expect(sig.parameterCount).toBe(0); + }); + }); + + describe('C#', () => { + it('extracts params from a C# method', () => { + parser.setLanguage(CSharp); + const code = `class Foo { + public bool Check(string name, int count) { return true; } +}`; + const tree = parser.parse(code); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.childForFieldName('body')!; + const methodNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(methodNode); + expect(sig.parameterCount).toBe(2); + }); + + it('handles C# method with no params', () => { + parser.setLanguage(CSharp); + const code = `class Foo { + public void Execute() {} +}`; + const tree = parser.parse(code); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.childForFieldName('body')!; + const methodNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(methodNode); + expect(sig.parameterCount).toBe(0); + }); + }); + + describe('Go', () => { + it('extracts params and single return type', () => { + parser.setLanguage(Go); + const code = `package main +func add(a int, b int) int { return a + b }`; + const tree = parser.parse(code); + const funcNode = tree.rootNode.namedChildren.find(c => c.type === 'function_declaration')!; + + const sig = extractMethodSignature(funcNode); + expect(sig.parameterCount).toBe(2); + expect(sig.returnType).toBe('int'); + }); + + it('extracts multi-return type', () => { + parser.setLanguage(Go); + const code = `package main +func parse(s string) (string, error) { return s, nil }`; + const tree = parser.parse(code); + const funcNode = tree.rootNode.namedChildren.find(c => c.type === 'function_declaration')!; + + const sig = extractMethodSignature(funcNode); + expect(sig.parameterCount).toBe(1); + expect(sig.returnType).toBe('(string, error)'); + }); + + it('handles no return type', () => { + parser.setLanguage(Go); + const code = `package main +func doSomething(x int) { }`; + const tree = parser.parse(code); + const funcNode = tree.rootNode.namedChildren.find(c => c.type === 'function_declaration')!; + + const sig = extractMethodSignature(funcNode); + expect(sig.parameterCount).toBe(1); + expect(sig.returnType).toBeUndefined(); + }); + + it('marks variadic function with undefined parameterCount', () => { + parser.setLanguage(Go); + const code = `package main +func log(args ...string) int { return 0 }`; + const tree = parser.parse(code); + const funcNode = tree.rootNode.namedChildren.find(c => c.type === 'function_declaration')!; + + const sig = extractMethodSignature(funcNode); + expect(sig.parameterCount).toBeUndefined(); + expect(sig.returnType).toBe('int'); + }); + }); + + describe('Rust', () => { + it('extracts return type from function', () => { + parser.setLanguage(Rust); + const code = `fn add(a: i32, b: i32) -> i32 { a + b }`; + const tree = parser.parse(code); + const funcNode = tree.rootNode.namedChild(0)!; + + const sig = extractMethodSignature(funcNode); + expect(sig.parameterCount).toBe(2); + expect(sig.returnType).toBe('i32'); + }); + }); + + describe('C++ return types', () => { + it('extracts primitive return type', () => { + parser.setLanguage(CPP); + const code = `int add(int a, int b) { return a + b; }`; + const tree = parser.parse(code); + const funcNode = tree.rootNode.namedChild(0)!; + + const sig = extractMethodSignature(funcNode); + expect(sig.parameterCount).toBe(2); + expect(sig.returnType).toBe('int'); + }); + + it('extracts qualified return type', () => { + parser.setLanguage(CPP); + const code = `std::string getName() { return ""; }`; + const tree = parser.parse(code); + const funcNode = tree.rootNode.namedChild(0)!; + + const sig = extractMethodSignature(funcNode); + expect(sig.parameterCount).toBe(0); + expect(sig.returnType).toBe('std::string'); + }); + + it('returns undefined returnType for void', () => { + parser.setLanguage(CPP); + const code = `void doNothing() { }`; + const tree = parser.parse(code); + const funcNode = tree.rootNode.namedChild(0)!; + + const sig = extractMethodSignature(funcNode); + expect(sig.returnType).toBeUndefined(); + }); + + it('marks variadic function with undefined parameterCount', () => { + parser.setLanguage(CPP); + const code = `int printf(const char* fmt, ...) { return 0; }`; + const tree = parser.parse(code); + const funcNode = tree.rootNode.namedChild(0)!; + + const sig = extractMethodSignature(funcNode); + expect(sig.parameterCount).toBeUndefined(); + expect(sig.returnType).toBe('int'); + }); + }); + + describe('variadic params', () => { + it('Java: marks varargs with undefined parameterCount', () => { + parser.setLanguage(Java); + const code = `class Foo { + public void log(String fmt, Object... args) {} +}`; + const tree = parser.parse(code); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.childForFieldName('body')!; + const methodNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(methodNode); + expect(sig.parameterCount).toBeUndefined(); + }); + + it('Python: marks *args with undefined parameterCount', () => { + parser.setLanguage(Python); + const code = `class Foo: + def log(self, fmt, *args): + pass`; + const tree = parser.parse(code); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.childForFieldName('body')!; + const methodNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(methodNode); + expect(sig.parameterCount).toBeUndefined(); + }); + + it('Python: marks **kwargs with undefined parameterCount', () => { + parser.setLanguage(Python); + const code = `class Foo: + def config(self, **kwargs): + pass`; + const tree = parser.parse(code); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.childForFieldName('body')!; + const methodNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(methodNode); + expect(sig.parameterCount).toBeUndefined(); + }); + + it('TypeScript: marks rest params with undefined parameterCount', () => { + parser.setLanguage(TypeScript.typescript); + const code = `function logEntry(...messages: string[]): void {}`; + const tree = parser.parse(code); + const funcNode = tree.rootNode.namedChild(0)!; + + const sig = extractMethodSignature(funcNode); + expect(sig.parameterCount).toBeUndefined(); + }); + + it('Kotlin: marks vararg with undefined parameterCount', () => { + parser.setLanguage(Kotlin); + const code = `object Foo { + fun log(vararg args: String) {} +}`; + const tree = parser.parse(code); + const objectNode = tree.rootNode.child(0)!; + const classBody = objectNode.namedChild(1)!; + const functionNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(functionNode); + expect(sig.parameterCount).toBeUndefined(); + }); + }); +}); diff --git a/gitnexus/test/unit/mro-processor.test.ts b/gitnexus/test/unit/mro-processor.test.ts new file mode 100644 index 0000000000..78ca0bf146 --- /dev/null +++ b/gitnexus/test/unit/mro-processor.test.ts @@ -0,0 +1,461 @@ +import { describe, it, expect } from 'vitest'; +import { computeMRO } from '../../src/core/ingestion/mro-processor.js'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import type { KnowledgeGraph } from '../../src/core/graph/types.js'; +import { generateId } from '../../src/lib/utils.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function addClass(graph: KnowledgeGraph, name: string, language: string, label: 'Class' | 'Interface' | 'Struct' | 'Trait' = 'Class') { + const id = generateId(label, name); + graph.addNode({ + id, + label, + properties: { name, filePath: `src/${name}.ts`, language }, + }); + return id; +} + +function addMethod(graph: KnowledgeGraph, className: string, methodName: string, classLabel: 'Class' | 'Interface' | 'Struct' | 'Trait' = 'Class') { + const classId = generateId(classLabel, className); + const methodId = generateId('Method', `${className}.${methodName}`); + graph.addNode({ + id: methodId, + label: 'Method', + properties: { name: methodName, filePath: `src/${className}.ts` }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${classId}->${methodId}`), + sourceId: classId, + targetId: methodId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + return methodId; +} + +function addExtends(graph: KnowledgeGraph, childName: string, parentName: string, childLabel: 'Class' | 'Struct' = 'Class', parentLabel: 'Class' | 'Interface' | 'Trait' = 'Class') { + const childId = generateId(childLabel, childName); + const parentId = generateId(parentLabel, parentName); + graph.addRelationship({ + id: generateId('EXTENDS', `${childId}->${parentId}`), + sourceId: childId, + targetId: parentId, + type: 'EXTENDS', + confidence: 1.0, + reason: '', + }); +} + +function addImplements(graph: KnowledgeGraph, childName: string, parentName: string, childLabel: 'Class' | 'Struct' = 'Class', parentLabel: 'Interface' | 'Trait' = 'Interface') { + const childId = generateId(childLabel, childName); + const parentId = generateId(parentLabel, parentName); + graph.addRelationship({ + id: generateId('IMPLEMENTS', `${childId}->${parentId}`), + sourceId: childId, + targetId: parentId, + type: 'IMPLEMENTS', + confidence: 1.0, + reason: '', + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('computeMRO', () => { + + // ---- C++ diamond -------------------------------------------------------- + describe('C++ diamond inheritance', () => { + it('leftmost base wins when both B and C override foo', () => { + // Diamond: A <- B, A <- C, B <- D, C <- D + const graph = createKnowledgeGraph(); + const aId = addClass(graph, 'A', 'cpp'); + const bId = addClass(graph, 'B', 'cpp'); + const cId = addClass(graph, 'C', 'cpp'); + const dId = addClass(graph, 'D', 'cpp'); + + addExtends(graph, 'B', 'A'); + addExtends(graph, 'C', 'A'); + addExtends(graph, 'D', 'B'); // B is leftmost + addExtends(graph, 'D', 'C'); + + // A has foo, B overrides foo, C overrides foo + addMethod(graph, 'A', 'foo'); + const bFoo = addMethod(graph, 'B', 'foo'); + const cFoo = addMethod(graph, 'C', 'foo'); + + const result = computeMRO(graph); + + // D should have an entry with ambiguity on foo + const dEntry = result.entries.find(e => e.className === 'D'); + expect(dEntry).toBeDefined(); + expect(dEntry!.language).toBe('cpp'); + + const fooAmbiguity = dEntry!.ambiguities.find(a => a.methodName === 'foo'); + expect(fooAmbiguity).toBeDefined(); + expect(fooAmbiguity!.definedIn.length).toBeGreaterThanOrEqual(2); + + // Leftmost base (B) wins + expect(fooAmbiguity!.resolvedTo).toBe(bFoo); + expect(fooAmbiguity!.reason).toContain('C++ leftmost'); + expect(fooAmbiguity!.reason).toContain('B'); + + // OVERRIDES edge emitted + expect(result.overrideEdges).toBeGreaterThanOrEqual(1); + const overrides = graph.relationships.filter(r => r.type === 'OVERRIDES'); + expect(overrides.some(r => r.sourceId === dId && r.targetId === bFoo)).toBe(true); + }); + + it('no ambiguity when foo only in A (diamond no override)', () => { + // Diamond: A <- B, A <- C, B <- D, C <- D, but only A has foo + const graph = createKnowledgeGraph(); + addClass(graph, 'A', 'cpp'); + addClass(graph, 'B', 'cpp'); + addClass(graph, 'C', 'cpp'); + addClass(graph, 'D', 'cpp'); + + addExtends(graph, 'B', 'A'); + addExtends(graph, 'C', 'A'); + addExtends(graph, 'D', 'B'); + addExtends(graph, 'D', 'C'); + + // Only A has foo + addMethod(graph, 'A', 'foo'); + + const result = computeMRO(graph); + + const dEntry = result.entries.find(e => e.className === 'D'); + expect(dEntry).toBeDefined(); + // A::foo appears only once across ancestors — no collision + // (B and C don't have their own foo, the duplicate is A::foo seen through both paths) + const fooAmbiguity = dEntry!.ambiguities.find(a => a.methodName === 'foo'); + expect(fooAmbiguity).toBeUndefined(); + }); + }); + + // ---- C# class + interface ----------------------------------------------- + describe('C# class + interface', () => { + it('class method beats interface default', () => { + const graph = createKnowledgeGraph(); + const classId = addClass(graph, 'MyClass', 'csharp'); + const baseId = addClass(graph, 'BaseClass', 'csharp'); + const ifaceId = addClass(graph, 'IDoSomething', 'csharp', 'Interface'); + + addExtends(graph, 'MyClass', 'BaseClass'); + addImplements(graph, 'MyClass', 'IDoSomething'); + + const baseDoIt = addMethod(graph, 'BaseClass', 'doIt'); + const ifaceDoIt = addMethod(graph, 'IDoSomething', 'doIt', 'Interface'); + + const result = computeMRO(graph); + + const entry = result.entries.find(e => e.className === 'MyClass'); + expect(entry).toBeDefined(); + + const doItAmbiguity = entry!.ambiguities.find(a => a.methodName === 'doIt'); + expect(doItAmbiguity).toBeDefined(); + // Class method wins + expect(doItAmbiguity!.resolvedTo).toBe(baseDoIt); + expect(doItAmbiguity!.reason).toContain('class method wins'); + }); + + it('multiple interface methods with same name are ambiguous', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'MyClass', 'csharp'); + addClass(graph, 'IFoo', 'csharp', 'Interface'); + addClass(graph, 'IBar', 'csharp', 'Interface'); + + addImplements(graph, 'MyClass', 'IFoo'); + addImplements(graph, 'MyClass', 'IBar'); + + addMethod(graph, 'IFoo', 'process', 'Interface'); + addMethod(graph, 'IBar', 'process', 'Interface'); + + const result = computeMRO(graph); + + const entry = result.entries.find(e => e.className === 'MyClass'); + expect(entry).toBeDefined(); + + const processAmbiguity = entry!.ambiguities.find(a => a.methodName === 'process'); + expect(processAmbiguity).toBeDefined(); + expect(processAmbiguity!.resolvedTo).toBeNull(); + expect(processAmbiguity!.reason).toContain('ambiguous'); + expect(result.ambiguityCount).toBeGreaterThanOrEqual(1); + }); + }); + + // ---- Python C3 ---------------------------------------------------------- + describe('Python C3 linearization', () => { + it('C3 order determines winner in diamond with overrides', () => { + // Diamond: A <- B, A <- C, B <- D, C <- D + // class D(B, C) → C3 MRO: B, C, A + const graph = createKnowledgeGraph(); + addClass(graph, 'A', 'python'); + addClass(graph, 'B', 'python'); + addClass(graph, 'C', 'python'); + const dId = addClass(graph, 'D', 'python'); + + addExtends(graph, 'B', 'A'); + addExtends(graph, 'C', 'A'); + addExtends(graph, 'D', 'B'); // B first → leftmost in C3 + addExtends(graph, 'D', 'C'); + + addMethod(graph, 'A', 'foo'); + const bFoo = addMethod(graph, 'B', 'foo'); + addMethod(graph, 'C', 'foo'); + + const result = computeMRO(graph); + + const dEntry = result.entries.find(e => e.className === 'D'); + expect(dEntry).toBeDefined(); + + const fooAmbiguity = dEntry!.ambiguities.find(a => a.methodName === 'foo'); + expect(fooAmbiguity).toBeDefined(); + // C3 linearization for D(B, C): B comes first + expect(fooAmbiguity!.resolvedTo).toBe(bFoo); + expect(fooAmbiguity!.reason).toContain('Python C3'); + }); + }); + + // ---- Java class + interface --------------------------------------------- + describe('Java class + interface', () => { + it('class method beats interface default', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'Service', 'java'); + addClass(graph, 'BaseService', 'java'); + addClass(graph, 'Runnable', 'java', 'Interface'); + + addExtends(graph, 'Service', 'BaseService'); + addImplements(graph, 'Service', 'Runnable'); + + const baseRun = addMethod(graph, 'BaseService', 'run'); + addMethod(graph, 'Runnable', 'run', 'Interface'); + + const result = computeMRO(graph); + + const entry = result.entries.find(e => e.className === 'Service'); + expect(entry).toBeDefined(); + + const runAmbiguity = entry!.ambiguities.find(a => a.methodName === 'run'); + expect(runAmbiguity).toBeDefined(); + expect(runAmbiguity!.resolvedTo).toBe(baseRun); + expect(runAmbiguity!.reason).toContain('class method wins'); + }); + }); + + // ---- Rust trait conflicts ----------------------------------------------- + describe('Rust trait conflicts', () => { + it('trait conflicts result in null resolution with qualified syntax reason', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'MyStruct', 'rust', 'Struct'); + addClass(graph, 'TraitA', 'rust', 'Trait'); + addClass(graph, 'TraitB', 'rust', 'Trait'); + + addImplements(graph, 'MyStruct', 'TraitA', 'Struct', 'Trait'); + addImplements(graph, 'MyStruct', 'TraitB', 'Struct', 'Trait'); + + addMethod(graph, 'TraitA', 'execute', 'Trait'); + addMethod(graph, 'TraitB', 'execute', 'Trait'); + + const result = computeMRO(graph); + + const entry = result.entries.find(e => e.className === 'MyStruct'); + expect(entry).toBeDefined(); + + const execAmbiguity = entry!.ambiguities.find(a => a.methodName === 'execute'); + expect(execAmbiguity).toBeDefined(); + expect(execAmbiguity!.resolvedTo).toBeNull(); + expect(execAmbiguity!.reason).toContain('qualified syntax'); + expect(result.ambiguityCount).toBeGreaterThanOrEqual(1); + + // No OVERRIDES edge emitted for Rust ambiguity + const overrides = graph.relationships.filter( + r => r.type === 'OVERRIDES' && r.sourceId === generateId('Struct', 'MyStruct') + ); + expect(overrides).toHaveLength(0); + }); + }); + + // ---- Property collisions don't trigger OVERRIDES ------------------------ + describe('Property nodes excluded from OVERRIDES', () => { + it('property name collision across parents does not emit OVERRIDES edge', () => { + const graph = createKnowledgeGraph(); + const parentA = addClass(graph, 'ParentA', 'typescript'); + const parentB = addClass(graph, 'ParentB', 'typescript'); + const child = addClass(graph, 'Child', 'typescript'); + + addExtends(graph, 'Child', 'ParentA'); + addExtends(graph, 'Child', 'ParentB'); + + // Add Property nodes (same name 'name') to both parents via HAS_METHOD + const propA = generateId('Property', 'ParentA.name'); + graph.addNode({ id: propA, label: 'Property', properties: { name: 'name', filePath: 'src/ParentA.ts' } }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${parentA}->${propA}`), + sourceId: parentA, targetId: propA, type: 'HAS_METHOD', confidence: 1.0, reason: '', + }); + + const propB = generateId('Property', 'ParentB.name'); + graph.addNode({ id: propB, label: 'Property', properties: { name: 'name', filePath: 'src/ParentB.ts' } }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${parentB}->${propB}`), + sourceId: parentB, targetId: propB, type: 'HAS_METHOD', confidence: 1.0, reason: '', + }); + + const result = computeMRO(graph); + + // No OVERRIDES edge should be emitted for properties + const overrides = graph.relationships.filter(r => r.type === 'OVERRIDES'); + expect(overrides).toHaveLength(0); + expect(result.overrideEdges).toBe(0); + }); + + it('method collision still triggers OVERRIDES even when properties also collide', () => { + const graph = createKnowledgeGraph(); + const parentA = addClass(graph, 'PA', 'cpp'); + const parentB = addClass(graph, 'PB', 'cpp'); + addClass(graph, 'Ch', 'cpp'); + + addExtends(graph, 'Ch', 'PA'); + addExtends(graph, 'Ch', 'PB'); + + // Method collision (should trigger OVERRIDES) + const methodA = addMethod(graph, 'PA', 'doWork'); + addMethod(graph, 'PB', 'doWork'); + + // Property collision (should NOT trigger OVERRIDES) + const propA = generateId('Property', 'PA.id'); + graph.addNode({ id: propA, label: 'Property', properties: { name: 'id', filePath: 'src/PA.ts' } }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${parentA}->${propA}`), + sourceId: parentA, targetId: propA, type: 'HAS_METHOD', confidence: 1.0, reason: '', + }); + + const propB = generateId('Property', 'PB.id'); + graph.addNode({ id: propB, label: 'Property', properties: { name: 'id', filePath: 'src/PB.ts' } }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${parentB}->${propB}`), + sourceId: parentB, targetId: propB, type: 'HAS_METHOD', confidence: 1.0, reason: '', + }); + + const result = computeMRO(graph); + + // Only 1 OVERRIDES edge (for the method, not the property) + const overrides = graph.relationships.filter(r => r.type === 'OVERRIDES'); + expect(overrides).toHaveLength(1); + expect(overrides[0].targetId).toBe(methodA); // leftmost base wins for C++ + expect(result.overrideEdges).toBe(1); + }); + }); + + // ---- No ambiguity: single parent ---------------------------------------- + describe('single parent, no ambiguity', () => { + it('single parent with unique methods produces no ambiguities', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'Parent', 'typescript'); + addClass(graph, 'Child', 'typescript'); + + addExtends(graph, 'Child', 'Parent'); + + addMethod(graph, 'Parent', 'foo'); + addMethod(graph, 'Parent', 'bar'); + + const result = computeMRO(graph); + + const entry = result.entries.find(e => e.className === 'Child'); + expect(entry).toBeDefined(); + expect(entry!.ambiguities).toHaveLength(0); + }); + }); + + // ---- No parents: standalone class not in entries ------------------------ + describe('standalone class', () => { + it('class with no parents is not included in entries', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'Standalone', 'typescript'); + addMethod(graph, 'Standalone', 'doStuff'); + + const result = computeMRO(graph); + + const entry = result.entries.find(e => e.className === 'Standalone'); + expect(entry).toBeUndefined(); + expect(result.overrideEdges).toBe(0); + expect(result.ambiguityCount).toBe(0); + }); + }); + + // ---- Own method shadows ancestor ---------------------------------------- + describe('own method shadows ancestor', () => { + it('class defining its own method suppresses ambiguity', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'Base1', 'cpp'); + addClass(graph, 'Base2', 'cpp'); + addClass(graph, 'Child', 'cpp'); + + addExtends(graph, 'Child', 'Base1'); + addExtends(graph, 'Child', 'Base2'); + + addMethod(graph, 'Base1', 'foo'); + addMethod(graph, 'Base2', 'foo'); + addMethod(graph, 'Child', 'foo'); // own method + + const result = computeMRO(graph); + + const entry = result.entries.find(e => e.className === 'Child'); + expect(entry).toBeDefined(); + // No ambiguity because Child defines its own foo + const fooAmbiguity = entry!.ambiguities.find(a => a.methodName === 'foo'); + expect(fooAmbiguity).toBeUndefined(); + }); + }); + + // ---- Empty graph -------------------------------------------------------- + describe('empty graph', () => { + it('returns empty result for graph with no classes', () => { + const graph = createKnowledgeGraph(); + const result = computeMRO(graph); + expect(result.entries).toHaveLength(0); + expect(result.overrideEdges).toBe(0); + expect(result.ambiguityCount).toBe(0); + }); + }); + + // ---- Cyclic inheritance (P1 fix) ---------------------------------------- + describe('cyclic inheritance', () => { + it('does not stack overflow on cyclic Python hierarchy', () => { + // A extends B, B extends A — cyclic + const graph = createKnowledgeGraph(); + addClass(graph, 'A', 'python'); + addClass(graph, 'B', 'python'); + addExtends(graph, 'A', 'B'); + addExtends(graph, 'B', 'A'); + addMethod(graph, 'A', 'foo'); + addMethod(graph, 'B', 'foo'); + + // Should NOT throw — c3Linearize returns null, falls back to BFS + const result = computeMRO(graph); + expect(result).toBeDefined(); + // Both A and B have parents, so both get entries + expect(result.entries.length).toBeGreaterThanOrEqual(1); + }); + + it('handles 3-node cycle gracefully', () => { + // A → B → C → A + const graph = createKnowledgeGraph(); + addClass(graph, 'X', 'python'); + addClass(graph, 'Y', 'python'); + addClass(graph, 'Z', 'python'); + addExtends(graph, 'X', 'Y'); + addExtends(graph, 'Y', 'Z'); + addExtends(graph, 'Z', 'X'); + + const result = computeMRO(graph); + expect(result).toBeDefined(); + }); + }); +}); diff --git a/gitnexus/test/unit/named-binding-extraction.test.ts b/gitnexus/test/unit/named-binding-extraction.test.ts new file mode 100644 index 0000000000..3c66984d0b --- /dev/null +++ b/gitnexus/test/unit/named-binding-extraction.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { extractCsharpNamedBindings } from '../../src/core/ingestion/named-binding-extraction.js'; +import Parser from 'tree-sitter'; +import CSharp from 'tree-sitter-c-sharp'; + +const parser = new Parser(); + +/** Walk a tree depth-first and return the first node matching the given type. */ +function findFirst(node: any, type: string): any | undefined { + if (node.type === type) return node; + for (let i = 0; i < node.childCount; i++) { + const found = findFirst(node.child(i), type); + if (found) return found; + } + return undefined; +} + +const parse = (code: string) => { + parser.setLanguage(CSharp); + return parser.parse(code); +}; + +describe('extractCsharpNamedBindings', () => { + describe('non-aliased namespace imports (known limitation)', () => { + it('returns undefined for non-aliased namespace imports (known limitation)', () => { + // C# using Namespace imports can't be reduced to per-symbol bindings without type + // inference — resolution falls back to PackageMap directory matching. + const tree = parse('using MyApp.Models;'); + const usingNode = findFirst(tree.rootNode, 'using_directive'); + expect(usingNode).toBeDefined(); + + const result = extractCsharpNamedBindings(usingNode); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for a single-segment non-aliased import', () => { + // C# using Namespace imports can't be reduced to per-symbol bindings without type + // inference — resolution falls back to PackageMap directory matching. + const tree = parse('using System;'); + const usingNode = findFirst(tree.rootNode, 'using_directive'); + expect(usingNode).toBeDefined(); + + const result = extractCsharpNamedBindings(usingNode); + + expect(result).toBeUndefined(); + }); + }); + + describe('aliased imports', () => { + it('returns a binding for a simple aliased import', () => { + const tree = parse('using Mod = MyApp.Models;'); + const usingNode = findFirst(tree.rootNode, 'using_directive'); + expect(usingNode).toBeDefined(); + + const result = extractCsharpNamedBindings(usingNode); + + expect(result).toEqual([{ local: 'Mod', exported: 'Models' }]); + }); + + it('uses the last segment of the qualified name as the exported binding', () => { + const tree = parse('using Svc = MyApp.Services.UserService;'); + const usingNode = findFirst(tree.rootNode, 'using_directive'); + expect(usingNode).toBeDefined(); + + const result = extractCsharpNamedBindings(usingNode); + + expect(result).toEqual([{ local: 'Svc', exported: 'UserService' }]); + }); + }); + + describe('edge cases', () => { + it('returns undefined when the node type is not using_directive', () => { + // Passing a synthetic object that is not a using_directive node. + const fakeNode = { type: 'import_declaration', namedChildCount: 0 }; + + const result = extractCsharpNamedBindings(fakeNode); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index d25cdee955..09d12dc49a 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -119,6 +119,35 @@ describe('KuzuDB Schema', () => { expect(RELATION_SCHEMA).toContain('FROM Function TO Process'); expect(RELATION_SCHEMA).toContain('FROM Method TO Process'); }); + + it('has all FROM/TO pairs needed for HAS_METHOD edges', () => { + // HAS_METHOD sources: Class, Interface, Struct, Trait, Impl, Record + // HAS_METHOD targets: Method, Constructor, Property + const sources = ['Class', 'Interface']; + const backtickSources = ['Struct', 'Trait', 'Impl', 'Record']; + const targets = ['Method']; + const backtickTargets = ['Constructor', 'Property']; + + // Non-backtick source → non-backtick target + for (const src of sources) { + for (const tgt of targets) { + expect(RELATION_SCHEMA).toContain(`FROM ${src} TO ${tgt}`); + } + for (const tgt of backtickTargets) { + expect(RELATION_SCHEMA).toContain(`FROM ${src} TO \`${tgt}\``); + } + } + + // Backtick source → all targets + for (const src of backtickSources) { + for (const tgt of targets) { + expect(RELATION_SCHEMA).toContain(`FROM \`${src}\` TO ${tgt}`); + } + for (const tgt of backtickTargets) { + expect(RELATION_SCHEMA).toContain(`FROM \`${src}\` TO \`${tgt}\``); + } + } + }); }); describe('embedding schema', () => { diff --git a/gitnexus/test/unit/symbol-resolver.test.ts b/gitnexus/test/unit/symbol-resolver.test.ts new file mode 100644 index 0000000000..a9707de096 --- /dev/null +++ b/gitnexus/test/unit/symbol-resolver.test.ts @@ -0,0 +1,449 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resolveSymbol, resolveSymbolInternal } from '../../src/core/ingestion/symbol-resolver.js'; +import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { createImportMap, createPackageMap, isFileInPackageDir } from '../../src/core/ingestion/import-processor.js'; +import type { ImportMap, PackageMap } from '../../src/core/ingestion/import-processor.js'; + +describe('resolveSymbol', () => { + let symbolTable: ReturnType; + let importMap: ImportMap; + + beforeEach(() => { + symbolTable = createSymbolTable(); + importMap = createImportMap(); + }); + + describe('Tier 1: Same-file resolution', () => { + it('resolves symbol defined in the same file', () => { + symbolTable.add('src/models/user.ts', 'User', 'Class:src/models/user.ts:User', 'Class'); + + const result = resolveSymbol('User', 'src/models/user.ts', symbolTable, importMap); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('Class:src/models/user.ts:User'); + expect(result!.filePath).toBe('src/models/user.ts'); + expect(result!.type).toBe('Class'); + }); + + it('prefers same-file over imported definition', () => { + symbolTable.add('src/local.ts', 'Config', 'Class:src/local.ts:Config', 'Class'); + symbolTable.add('src/shared.ts', 'Config', 'Class:src/shared.ts:Config', 'Class'); + importMap.set('src/local.ts', new Set(['src/shared.ts'])); + + const result = resolveSymbol('Config', 'src/local.ts', symbolTable, importMap); + + expect(result!.nodeId).toBe('Class:src/local.ts:Config'); + expect(result!.filePath).toBe('src/local.ts'); + }); + }); + + describe('Tier 2: Import-scoped resolution', () => { + it('resolves symbol from an imported file', () => { + symbolTable.add('src/services/auth.ts', 'AuthService', 'Class:src/services/auth.ts:AuthService', 'Class'); + importMap.set('src/controllers/login.ts', new Set(['src/services/auth.ts'])); + + const result = resolveSymbol('AuthService', 'src/controllers/login.ts', symbolTable, importMap); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('Class:src/services/auth.ts:AuthService'); + expect(result!.filePath).toBe('src/services/auth.ts'); + }); + + it('prefers imported definition over non-imported with same name', () => { + symbolTable.add('src/services/logger.ts', 'Logger', 'Class:src/services/logger.ts:Logger', 'Class'); + symbolTable.add('src/testing/mock-logger.ts', 'Logger', 'Class:src/testing/mock-logger.ts:Logger', 'Class'); + importMap.set('src/app.ts', new Set(['src/services/logger.ts'])); + + const result = resolveSymbol('Logger', 'src/app.ts', symbolTable, importMap); + + expect(result!.nodeId).toBe('Class:src/services/logger.ts:Logger'); + expect(result!.filePath).toBe('src/services/logger.ts'); + }); + + it('handles file with no imports — unique global falls through', () => { + symbolTable.add('src/utils.ts', 'Helper', 'Class:src/utils.ts:Helper', 'Class'); + + const result = resolveSymbol('Helper', 'src/app.ts', symbolTable, importMap); + + // Falls through to Tier 3 (unique global) + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('Class:src/utils.ts:Helper'); + }); + }); + + describe('Tier 3: Unique global resolution', () => { + it('resolves unique global when not in imports', () => { + symbolTable.add('src/external/base.ts', 'BaseModel', 'Class:src/external/base.ts:BaseModel', 'Class'); + importMap.set('src/app.ts', new Set(['src/other.ts'])); + + const result = resolveSymbol('BaseModel', 'src/app.ts', symbolTable, importMap); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBe('Class:src/external/base.ts:BaseModel'); + }); + + it('refuses ambiguous global — returns null when multiple candidates exist', () => { + symbolTable.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + symbolTable.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + + const result = resolveSymbol('Config', 'src/other.ts', symbolTable, importMap); + + // A wrong edge is worse than no edge + expect(result).toBeNull(); + }); + }); + + describe('null cases', () => { + it('returns null for unknown symbol', () => { + const result = resolveSymbol('NonExistent', 'src/app.ts', symbolTable, importMap); + expect(result).toBeNull(); + }); + + it('returns null when symbol table is empty', () => { + const result = resolveSymbol('Anything', 'src/app.ts', symbolTable, importMap); + expect(result).toBeNull(); + }); + }); + + describe('type preservation', () => { + it('preserves Interface type for heritage resolution', () => { + symbolTable.add('src/interfaces.ts', 'ILogger', 'Interface:src/interfaces.ts:ILogger', 'Interface'); + importMap.set('src/app.ts', new Set(['src/interfaces.ts'])); + + const result = resolveSymbol('ILogger', 'src/app.ts', symbolTable, importMap); + + expect(result!.type).toBe('Interface'); + }); + + it('preserves Class type for heritage resolution', () => { + symbolTable.add('src/base.ts', 'BaseService', 'Class:src/base.ts:BaseService', 'Class'); + importMap.set('src/app.ts', new Set(['src/base.ts'])); + + const result = resolveSymbol('BaseService', 'src/app.ts', symbolTable, importMap); + + expect(result!.type).toBe('Class'); + }); + }); + + describe('heritage-specific scenarios', () => { + it('resolves C# interface vs class ambiguity via imports', () => { + // ILogger exists as Interface in one file and Class in another + symbolTable.add('src/logging/ilogger.cs', 'ILogger', 'Interface:src/logging/ilogger.cs:ILogger', 'Interface'); + symbolTable.add('src/testing/ilogger.cs', 'ILogger', 'Class:src/testing/ilogger.cs:ILogger', 'Class'); + importMap.set('src/services/auth.cs', new Set(['src/logging/ilogger.cs'])); + + const result = resolveSymbol('ILogger', 'src/services/auth.cs', symbolTable, importMap); + + expect(result!.type).toBe('Interface'); + expect(result!.filePath).toBe('src/logging/ilogger.cs'); + }); + + it('resolves parent class from imported file for extends', () => { + symbolTable.add('src/api/controller.ts', 'UserController', 'Class:src/api/controller.ts:UserController', 'Class'); + symbolTable.add('src/base/controller.ts', 'BaseController', 'Class:src/base/controller.ts:BaseController', 'Class'); + importMap.set('src/api/controller.ts', new Set(['src/base/controller.ts'])); + + const result = resolveSymbol('BaseController', 'src/api/controller.ts', symbolTable, importMap); + + expect(result!.nodeId).toBe('Class:src/base/controller.ts:BaseController'); + }); + }); +}); + +describe('resolveSymbolInternal — tier metadata', () => { + let symbolTable: ReturnType; + let importMap: ImportMap; + + beforeEach(() => { + symbolTable = createSymbolTable(); + importMap = createImportMap(); + }); + + it('returns same-file tier for Tier 1 match', () => { + symbolTable.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + + const result = resolveSymbolInternal('Foo', 'src/a.ts', symbolTable, importMap); + + expect(result).not.toBeNull(); + expect(result!.tier).toBe('same-file'); + expect(result!.candidateCount).toBe(1); + expect(result!.definition.nodeId).toBe('Class:src/a.ts:Foo'); + }); + + it('returns import-scoped tier for Tier 2 match', () => { + symbolTable.add('src/logger.ts', 'Logger', 'Class:src/logger.ts:Logger', 'Class'); + symbolTable.add('src/mock.ts', 'Logger', 'Class:src/mock.ts:Logger', 'Class'); + importMap.set('src/app.ts', new Set(['src/logger.ts'])); + + const result = resolveSymbolInternal('Logger', 'src/app.ts', symbolTable, importMap); + + expect(result).not.toBeNull(); + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidateCount).toBe(2); + expect(result!.definition.filePath).toBe('src/logger.ts'); + }); + + it('returns unique-global tier for Tier 3 match', () => { + symbolTable.add('src/only.ts', 'Singleton', 'Class:src/only.ts:Singleton', 'Class'); + + const result = resolveSymbolInternal('Singleton', 'src/other.ts', symbolTable, importMap); + + expect(result).not.toBeNull(); + expect(result!.tier).toBe('unique-global'); + expect(result!.candidateCount).toBe(1); + }); + + it('returns null for ambiguous global — refuses to guess', () => { + symbolTable.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + symbolTable.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + + const result = resolveSymbolInternal('Config', 'src/other.ts', symbolTable, importMap); + + expect(result).toBeNull(); + }); + + it('returns null for unknown symbol', () => { + const result = resolveSymbolInternal('Ghost', 'src/any.ts', symbolTable, importMap); + expect(result).toBeNull(); + }); + + it('Tier 1 wins over Tier 2 — same-file takes priority', () => { + symbolTable.add('src/app.ts', 'Util', 'Function:src/app.ts:Util', 'Function'); + symbolTable.add('src/lib.ts', 'Util', 'Function:src/lib.ts:Util', 'Function'); + importMap.set('src/app.ts', new Set(['src/lib.ts'])); + + const result = resolveSymbolInternal('Util', 'src/app.ts', symbolTable, importMap); + + expect(result!.tier).toBe('same-file'); + expect(result!.definition.filePath).toBe('src/app.ts'); + }); +}); + +describe('negative tests — ambiguous refusal per language family', () => { + let symbolTable: ReturnType; + let importMap: ImportMap; + + beforeEach(() => { + symbolTable = createSymbolTable(); + importMap = createImportMap(); + }); + + it('TS/JS: two Logger definitions with no import → returns null', () => { + symbolTable.add('src/services/logger.ts', 'Logger', 'Class:src/services/logger.ts:Logger', 'Class'); + symbolTable.add('src/testing/logger.ts', 'Logger', 'Class:src/testing/logger.ts:Logger', 'Class'); + + const result = resolveSymbol('Logger', 'src/app.ts', symbolTable, importMap); + expect(result).toBeNull(); + }); + + it('Java: same-named class in different packages, no import → returns null', () => { + symbolTable.add('com/example/models/User.java', 'User', 'Class:com/example/models/User.java:User', 'Class'); + symbolTable.add('com/example/dto/User.java', 'User', 'Class:com/example/dto/User.java:User', 'Class'); + + const result = resolveSymbol('User', 'com/example/services/UserService.java', symbolTable, importMap); + expect(result).toBeNull(); + }); + + it('C/C++: type defined in transitively-included header → returns null (not reachable via direct import)', () => { + // a.c includes b.h (direct), b.h includes c.h (transitive — not in ImportMap) + symbolTable.add('src/c.h', 'Widget', 'Struct:src/c.h:Widget', 'Struct'); + symbolTable.add('src/d.h', 'Widget', 'Struct:src/d.h:Widget', 'Struct'); + importMap.set('src/a.c', new Set(['src/b.h'])); + + const result = resolveSymbol('Widget', 'src/a.c', symbolTable, importMap); + // Neither c.h nor d.h is directly imported → ambiguous global → null + expect(result).toBeNull(); + }); + + it('C#: two IService interfaces in different namespaces, no import → returns null', () => { + symbolTable.add('src/Services/IService.cs', 'IService', 'Interface:src/Services/IService.cs:IService', 'Interface'); + symbolTable.add('src/Testing/IService.cs', 'IService', 'Interface:src/Testing/IService.cs:IService', 'Interface'); + + const result = resolveSymbol('IService', 'src/App.cs', symbolTable, importMap); + expect(result).toBeNull(); + }); +}); + +describe('heritage false-positive guard', () => { + let symbolTable: ReturnType; + let importMap: ImportMap; + + beforeEach(() => { + symbolTable = createSymbolTable(); + importMap = createImportMap(); + }); + + it('null from resolveSymbol prevents false edge — generateId fallback produces synthetic ID, not wrong match', () => { + // Two BaseController in different files — ambiguous + symbolTable.add('src/api/base.ts', 'BaseController', 'Class:src/api/base.ts:BaseController', 'Class'); + symbolTable.add('src/testing/base.ts', 'BaseController', 'Class:src/testing/base.ts:BaseController', 'Class'); + + // resolveSymbol returns null — heritage-processor would use generateId fallback + const result = resolveSymbol('BaseController', 'src/routes/admin.ts', symbolTable, importMap); + expect(result).toBeNull(); + + // Verify: with import, it resolves correctly to the right one + importMap.set('src/routes/admin.ts', new Set(['src/api/base.ts'])); + const resolved = resolveSymbol('BaseController', 'src/routes/admin.ts', symbolTable, importMap); + expect(resolved).not.toBeNull(); + expect(resolved!.filePath).toBe('src/api/base.ts'); + }); +}); + +describe('lookupExactFull', () => { + it('returns full SymbolDefinition for same-file lookup via O(1) direct storage', () => { + const symbolTable = createSymbolTable(); + symbolTable.add('src/models/user.ts', 'User', 'Class:src/models/user.ts:User', 'Class'); + + const result = symbolTable.lookupExactFull('src/models/user.ts', 'User'); + + expect(result).not.toBeUndefined(); + expect(result!.nodeId).toBe('Class:src/models/user.ts:User'); + expect(result!.filePath).toBe('src/models/user.ts'); + expect(result!.type).toBe('Class'); + }); + + it('returns undefined for non-existent symbol', () => { + const symbolTable = createSymbolTable(); + const result = symbolTable.lookupExactFull('src/app.ts', 'NonExistent'); + expect(result).toBeUndefined(); + }); + + it('returns undefined for wrong file', () => { + const symbolTable = createSymbolTable(); + symbolTable.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + + const result = symbolTable.lookupExactFull('src/b.ts', 'Foo'); + expect(result).toBeUndefined(); + }); + + it('shares same object reference between fileIndex and globalIndex', () => { + const symbolTable = createSymbolTable(); + symbolTable.add('src/x.ts', 'Bar', 'Class:src/x.ts:Bar', 'Class'); + + const fromExact = symbolTable.lookupExactFull('src/x.ts', 'Bar'); + const fromFuzzy = symbolTable.lookupFuzzy('Bar')[0]; + + // Same object reference — zero additional memory + expect(fromExact).toBe(fromFuzzy); + }); + + it('preserves optional callable metadata on stored definitions', () => { + const symbolTable = createSymbolTable(); + symbolTable.add('src/math.ts', 'sum', 'Function:src/math.ts:sum', 'Function', { parameterCount: 2 }); + + const fromExact = symbolTable.lookupExactFull('src/math.ts', 'sum'); + const fromFuzzy = symbolTable.lookupFuzzy('sum')[0]; + + expect(fromExact?.parameterCount).toBe(2); + expect(fromFuzzy.parameterCount).toBe(2); + expect(fromExact).toBe(fromFuzzy); + }); +}); + +describe('isFileInPackageDir', () => { + it('matches file directly in the package directory', () => { + expect(isFileInPackageDir('internal/auth/handler.go', '/internal/auth/')).toBe(true); + }); + + it('matches with leading path segments', () => { + expect(isFileInPackageDir('myrepo/internal/auth/handler.go', '/internal/auth/')).toBe(true); + expect(isFileInPackageDir('src/github.com/user/repo/internal/auth/handler.go', '/internal/auth/')).toBe(true); + }); + + it('rejects files in subdirectories', () => { + expect(isFileInPackageDir('internal/auth/middleware/jwt.go', '/internal/auth/')).toBe(false); + }); + + it('matches any file extension in the directory', () => { + expect(isFileInPackageDir('internal/auth/README.md', '/internal/auth/')).toBe(true); + expect(isFileInPackageDir('Models/User.cs', '/Models/')).toBe(true); + expect(isFileInPackageDir('internal/auth/handler_test.go', '/internal/auth/')).toBe(true); + }); + + it('rejects files not in the package', () => { + expect(isFileInPackageDir('internal/db/connection.go', '/internal/auth/')).toBe(false); + }); + + it('handles backslash paths (Windows)', () => { + expect(isFileInPackageDir('internal\\auth\\handler.go', '/internal/auth/')).toBe(true); + }); + + it('matches C# namespace directories', () => { + expect(isFileInPackageDir('MyProject/Models/User.cs', '/MyProject/Models/')).toBe(true); + expect(isFileInPackageDir('MyProject/Models/Order.cs', '/MyProject/Models/')).toBe(true); + expect(isFileInPackageDir('MyProject/Models/Sub/Nested.cs', '/MyProject/Models/')).toBe(false); + }); +}); + +describe('Tier 2b: PackageMap resolution (Go)', () => { + let symbolTable: ReturnType; + let importMap: ImportMap; + let packageMap: PackageMap; + + beforeEach(() => { + symbolTable = createSymbolTable(); + importMap = createImportMap(); + packageMap = createPackageMap(); + }); + + it('resolves symbol via PackageMap when not in ImportMap', () => { + symbolTable.add('internal/auth/handler.go', 'HandleLogin', 'Function:internal/auth/handler.go:HandleLogin', 'Function'); + // No ImportMap entry — but PackageMap has the package directory + packageMap.set('cmd/server/main.go', new Set(['/internal/auth/'])); + + const result = resolveSymbolInternal('HandleLogin', 'cmd/server/main.go', symbolTable, importMap, packageMap); + + expect(result).not.toBeNull(); + expect(result!.tier).toBe('import-scoped'); + expect(result!.definition.filePath).toBe('internal/auth/handler.go'); + }); + + it('does not resolve symbol from wrong package', () => { + symbolTable.add('internal/db/connection.go', 'Connect', 'Function:internal/db/connection.go:Connect', 'Function'); + packageMap.set('cmd/server/main.go', new Set(['/internal/auth/'])); + + const result = resolveSymbolInternal('Connect', 'cmd/server/main.go', symbolTable, importMap, packageMap); + + // Not in imported package, and not unique global (only 1 def) → unique-global + expect(result).not.toBeNull(); + expect(result!.tier).toBe('unique-global'); + }); + + it('Tier 2a (ImportMap) takes precedence over Tier 2b (PackageMap)', () => { + symbolTable.add('internal/auth/handler.go', 'Validate', 'Function:internal/auth/handler.go:Validate', 'Function'); + symbolTable.add('internal/db/validator.go', 'Validate', 'Function:internal/db/validator.go:Validate', 'Function'); + + // ImportMap points to db, PackageMap points to auth + importMap.set('cmd/server/main.go', new Set(['internal/db/validator.go'])); + packageMap.set('cmd/server/main.go', new Set(['/internal/auth/'])); + + const result = resolveSymbolInternal('Validate', 'cmd/server/main.go', symbolTable, importMap, packageMap); + + expect(result).not.toBeNull(); + expect(result!.tier).toBe('import-scoped'); + expect(result!.definition.filePath).toBe('internal/db/validator.go'); + }); + + it('resolves both symbols in same imported package (first match wins)', () => { + symbolTable.add('internal/auth/handler.go', 'Run', 'Function:internal/auth/handler.go:Run', 'Function'); + symbolTable.add('internal/auth/worker.go', 'Run', 'Function:internal/auth/worker.go:Run', 'Function'); + packageMap.set('cmd/main.go', new Set(['/internal/auth/'])); + + const result = resolveSymbolInternal('Run', 'cmd/main.go', symbolTable, importMap, packageMap); + + // Both match the package — returns first match as import-scoped + expect(result).not.toBeNull(); + expect(result!.tier).toBe('import-scoped'); + }); + + it('returns null without packageMap (backward compat)', () => { + symbolTable.add('internal/auth/handler.go', 'X', 'Function:internal/auth/handler.go:X', 'Function'); + symbolTable.add('internal/db/handler.go', 'X', 'Function:internal/db/handler.go:X', 'Function'); + + // No importMap entry, no packageMap → ambiguous + const result = resolveSymbolInternal('X', 'cmd/main.go', symbolTable, importMap); + + expect(result).toBeNull(); + }); +}); diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts new file mode 100644 index 0000000000..78f6523bea --- /dev/null +++ b/gitnexus/test/unit/type-env.test.ts @@ -0,0 +1,434 @@ +import { describe, it, expect } from 'vitest'; +import { buildTypeEnv, lookupTypeEnv, type TypeEnv } from '../../src/core/ingestion/type-env.js'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import Java from 'tree-sitter-java'; +import CSharp from 'tree-sitter-c-sharp'; +import Go from 'tree-sitter-go'; +import Rust from 'tree-sitter-rust'; +import Python from 'tree-sitter-python'; +import CPP from 'tree-sitter-cpp'; +import Kotlin from 'tree-sitter-kotlin'; +import PHP from 'tree-sitter-php'; + +const parser = new Parser(); + +const parse = (code: string, lang: any) => { + parser.setLanguage(lang); + return parser.parse(code); +}; + +/** Flatten a scoped TypeEnv into a simple name→type map (for simple test assertions). */ +function flatGet(env: TypeEnv, varName: string): string | undefined { + for (const [, scopeMap] of env) { + const val = scopeMap.get(varName); + if (val) return val; + } + return undefined; +} + +/** Count all bindings across all scopes. */ +function flatSize(env: TypeEnv): number { + let count = 0; + for (const [, scopeMap] of env) count += scopeMap.size; + return count; +} + +describe('buildTypeEnv', () => { + describe('TypeScript', () => { + it('extracts type from const declaration', () => { + const tree = parse('const user: User = getUser();', TypeScript.typescript); + const env = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from let declaration', () => { + const tree = parse('let repo: Repository;', TypeScript.typescript); + const env = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'repo')).toBe('Repository'); + }); + + it('extracts type from function parameters', () => { + const tree = parse('function save(user: User, repo: Repository) {}', TypeScript.typescript); + const env = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'user')).toBe('User'); + expect(flatGet(env, 'repo')).toBe('Repository'); + }); + + it('extracts type from arrow function parameters', () => { + const tree = parse('const fn = (user: User) => user.save();', TypeScript.typescript); + const env = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('ignores variables without type annotations', () => { + const tree = parse('const x = 5; let y = "hello";', TypeScript.typescript); + const env = buildTypeEnv(tree, 'typescript'); + expect(flatSize(env)).toBe(0); + }); + }); + + describe('Java', () => { + it('extracts type from local variable declaration', () => { + const tree = parse(` + class App { + void run() { + User user = new User(); + Repository repo = getRepo(); + } + } + `, Java); + const env = buildTypeEnv(tree, 'java'); + expect(flatGet(env, 'user')).toBe('User'); + expect(flatGet(env, 'repo')).toBe('Repository'); + }); + + it('extracts type from method parameters', () => { + const tree = parse(` + class App { + void process(User user, Repository repo) {} + } + `, Java); + const env = buildTypeEnv(tree, 'java'); + expect(flatGet(env, 'user')).toBe('User'); + expect(flatGet(env, 'repo')).toBe('Repository'); + }); + + it('extracts type from field declaration', () => { + const tree = parse(` + class App { + private User user; + } + `, Java); + const env = buildTypeEnv(tree, 'java'); + expect(flatGet(env, 'user')).toBe('User'); + }); + }); + + describe('C#', () => { + it('extracts type from local variable declaration', () => { + const tree = parse(` + class App { + void Run() { + User user = new User(); + } + } + `, CSharp); + const env = buildTypeEnv(tree, 'csharp'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from var with new expression', () => { + const tree = parse(` + class App { + void Run() { + var user = new User(); + } + } + `, CSharp); + const env = buildTypeEnv(tree, 'csharp'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from method parameters', () => { + const tree = parse(` + class App { + void Process(User user, Repository repo) {} + } + `, CSharp); + const env = buildTypeEnv(tree, 'csharp'); + expect(flatGet(env, 'user')).toBe('User'); + expect(flatGet(env, 'repo')).toBe('Repository'); + }); + }); + + describe('Go', () => { + it('extracts type from var declaration', () => { + const tree = parse(` + package main + func main() { + var user User + } + `, Go); + const env = buildTypeEnv(tree, 'go'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from short var with composite literal', () => { + const tree = parse(` + package main + func main() { + user := User{Name: "Alice"} + } + `, Go); + const env = buildTypeEnv(tree, 'go'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from function parameters', () => { + const tree = parse(` + package main + func process(user User, repo Repository) {} + `, Go); + const env = buildTypeEnv(tree, 'go'); + // Go parameter extraction depends on tree-sitter grammar structure + // Parameters may or may not have 'name'/'type' fields + }); + }); + + describe('Rust', () => { + it('extracts type from let declaration', () => { + const tree = parse(` + fn main() { + let user: User = User::new(); + } + `, Rust); + const env = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from function parameters', () => { + const tree = parse(` + fn process(user: User, repo: Repository) {} + `, Rust); + const env = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'user')).toBe('User'); + expect(flatGet(env, 'repo')).toBe('Repository'); + }); + + it('extracts type from let with reference', () => { + const tree = parse(` + fn main() { + let user: &User = &get_user(); + } + `, Rust); + const env = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'user')).toBe('User'); + }); + }); + + describe('Python', () => { + it('extracts type from annotated assignment (PEP 484)', () => { + const tree = parse('user: User = get_user()', Python); + const env = buildTypeEnv(tree, 'python'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from function parameters', () => { + const tree = parse('def process(user: User, repo: Repository): pass', Python); + const env = buildTypeEnv(tree, 'python'); + // Python uses typed_parameter nodes, check if they match + }); + }); + + describe('C++', () => { + it('extracts type from local variable declaration', () => { + const tree = parse(` + void run() { + User user; + } + `, CPP); + const env = buildTypeEnv(tree, 'cpp'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from initialized declaration', () => { + const tree = parse(` + void run() { + User user = getUser(); + } + `, CPP); + const env = buildTypeEnv(tree, 'cpp'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from pointer declaration', () => { + const tree = parse(` + void run() { + User* user = new User(); + } + `, CPP); + const env = buildTypeEnv(tree, 'cpp'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from function parameters', () => { + const tree = parse(` + void process(User user, Repository& repo) {} + `, CPP); + const env = buildTypeEnv(tree, 'cpp'); + expect(flatGet(env, 'user')).toBe('User'); + expect(flatGet(env, 'repo')).toBe('Repository'); + }); + }); + + describe('PHP', () => { + it('extracts type from function parameters', () => { + const tree = parse(` { + it('separates same-named variables in different functions', () => { + const tree = parse(` + function handleUser(user: User) { + user.save(); + } + function handleRepo(user: Repo) { + user.save(); + } + `, TypeScript.typescript); + const env = buildTypeEnv(tree, 'typescript'); + + // Each function has its own scope for 'user' (keyed by funcName@startIndex) + // Find the scope keys that start with handleUser/handleRepo + const scopes = [...env.keys()]; + const handleUserKey = scopes.find(k => k.startsWith('handleUser@')); + const handleRepoKey = scopes.find(k => k.startsWith('handleRepo@')); + expect(handleUserKey).toBeDefined(); + expect(handleRepoKey).toBeDefined(); + expect(env.get(handleUserKey!)?.get('user')).toBe('User'); + expect(env.get(handleRepoKey!)?.get('user')).toBe('Repo'); + }); + + it('lookupTypeEnv resolves from enclosing function scope', () => { + const code = ` +function handleUser(user: User) { + user.save(); +} +function handleRepo(user: Repo) { + user.save(); +}`; + const tree = parse(code, TypeScript.typescript); + const env = buildTypeEnv(tree, 'typescript'); + + // Find the call nodes inside each function + 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); + + expect(calls.length).toBe(2); + // First call is inside handleUser → user should be User + expect(lookupTypeEnv(env, 'user', calls[0])).toBe('User'); + // Second call is inside handleRepo → user should be Repo + expect(lookupTypeEnv(env, 'user', calls[1])).toBe('Repo'); + }); + + it('separates same-named methods in different classes via startIndex', () => { + const code = ` +class UserService { + process(user: User) { + user.save(); + } +} +class RepoService { + process(repo: Repo) { + repo.save(); + } +}`; + const tree = parse(code, TypeScript.typescript); + const env = buildTypeEnv(tree, 'typescript'); + + // Find the call nodes inside each process method + 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); + + expect(calls.length).toBe(2); + // First call inside UserService.process → user should be User + expect(lookupTypeEnv(env, 'user', calls[0])).toBe('User'); + // Second call inside RepoService.process → repo should be Repo + expect(lookupTypeEnv(env, 'repo', calls[1])).toBe('Repo'); + }); + + it('file-level variables are accessible from all scopes', () => { + const tree = parse(` + const config: Config = getConfig(); + function process(user: User) { + config.validate(); + user.save(); + } + `, TypeScript.typescript); + const env = buildTypeEnv(tree, 'typescript'); + + // config is at file-level scope + const fileScope = env.get(''); + expect(fileScope?.get('config')).toBe('Config'); + + // user is in process scope (key includes startIndex) + // Find call nodes inside the process function + 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() at file level, calls[1] = config.validate(), calls[2] = user.save() + // Use a call inside the function to test scope resolution + expect(lookupTypeEnv(env, 'user', calls[2])).toBe('User'); + // config is file-level, accessible from any scope + expect(lookupTypeEnv(env, 'config', calls[1])).toBe('Config'); + }); + }); + + describe('destructuring patterns (known limitations)', () => { + it('captures the typed source variable but not destructured bindings', () => { + const tree = parse(` + const user: User = getUser(); + const { name, email } = user; + `, TypeScript.typescript); + const env = buildTypeEnv(tree, 'typescript'); + // The typed variable is captured + expect(flatGet(env, 'user')).toBe('User'); + // Destructured bindings (name, email) would need type inference to resolve + // — not extractable from annotations alone + expect(flatGet(env, 'name')).toBeUndefined(); + expect(flatGet(env, 'email')).toBeUndefined(); + }); + + it('does not extract from object-type-annotated destructuring', () => { + // TypeScript allows: const { name }: { name: string } = user; + // The annotation is on the whole pattern, not individual bindings + const tree = parse(` + const { name }: { name: string } = getUser(); + `, TypeScript.typescript); + const env = buildTypeEnv(tree, 'typescript'); + // Complex type annotation (object type) — extractSimpleTypeName returns undefined + expect(flatSize(env)).toBe(0); + }); + }); + + describe('edge cases', () => { + it('returns empty map for code without type annotations', () => { + const tree = parse('const x = 5;', TypeScript.typescript); + const env = buildTypeEnv(tree, 'typescript'); + expect(flatSize(env)).toBe(0); + }); + + it('last-write-wins for same variable name in same scope', () => { + const tree = parse(` + let x: User = getUser(); + let x: Admin = getAdmin(); + `, TypeScript.typescript); + const env = buildTypeEnv(tree, 'typescript'); + // Both declarations are at file level; last one wins + expect(flatGet(env, 'x')).toBeDefined(); + }); + }); +});