From 2c17a4642c55b08b97ac6e9f8738df6bc1876b5b Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sat, 21 Mar 2026 11:19:43 +0000 Subject: [PATCH 1/2] refactor: unify language dispatch with compile-time exhaustive tables Replace the 120-line if-chain in resolveLanguageImport() and the 7-branch dispatch in extractNamedBindings() with per-language dispatch tables using `satisfies Record` for compile-time exhaustiveness. Key changes: - New import-resolution.ts: buildImportResolvers factory + namedBindingExtractors table + preprocessImportPath with control character rejection - Extracted loadImportConfigs(), createImportEdgeHelpers(), getLabelFromCaptures() to eliminate duplication across import-processor.ts and parse-worker.ts - Added isCppDuplicateClassFunction and getLabelFromCaptures shared helpers - Migrated ENTRY_POINT_PATTERNS and AST_FRAMEWORK_PATTERNS_BY_LANGUAGE to satisfies Record with compile-time exhaustiveness - Added Kotlin entry-point patterns (Android lifecycle, Ktor, MVVM) - Expanded framework detection: Go (Gin/Echo/Fiber/gRPC), Rust (Actix/Axum/ Rocket/Tokio), C++ (Qt), Swift (UIKit/SwiftUI/Vapor), Ruby (Rails/Sinatra) - Fixed Go cmd/ entry-point detection operator precedence bug - Added EMPTY_INDEX frozen sentinel for type-safe memory cleanup - Unified ImportResolutionContext (suffixIndex->index, removed unused dispose()) - Extracted NamedBinding interface (replaced 16 inline occurrences) - Replaced any with SyntaxNode on all tree-sitter node parameters - Added contributor checklist to SupportedLanguages enum 14 files changed, +318/-550 (net -232 lines). All 3550+ tests pass. --- gitnexus/src/config/supported-languages.ts | 30 ++ .../src/core/ingestion/entry-point-scoring.ts | 74 ++-- .../src/core/ingestion/framework-detection.ts | 67 +++- .../src/core/ingestion/import-processor.ts | 335 ++-------------- .../src/core/ingestion/import-resolution.ts | 375 ++++++++++++++++++ .../src/core/ingestion/language-config.ts | 16 + .../ingestion/named-binding-extraction.ts | 94 +---- .../src/core/ingestion/parsing-processor.ts | 64 +-- gitnexus/src/core/ingestion/pipeline.ts | 4 +- .../src/core/ingestion/resolvers/index.ts | 2 +- .../src/core/ingestion/resolvers/utils.ts | 18 + gitnexus/src/core/ingestion/utils.ts | 62 +++ .../core/ingestion/workers/parse-worker.ts | 75 +--- gitnexus/test/integration/parsing.test.ts | 2 +- .../test/unit/framework-detection.test.ts | 15 +- gitnexus/test/unit/import-processor.test.ts | 12 +- 16 files changed, 694 insertions(+), 551 deletions(-) create mode 100644 gitnexus/src/core/ingestion/import-resolution.ts diff --git a/gitnexus/src/config/supported-languages.ts b/gitnexus/src/config/supported-languages.ts index 48562033a..e9f09213a 100644 --- a/gitnexus/src/config/supported-languages.ts +++ b/gitnexus/src/config/supported-languages.ts @@ -1,3 +1,33 @@ +/** + * HOW TO ADD A NEW LANGUAGE: + * + * 1. Add the enum member below (e.g., Scala = 'scala') + * 2. Run `tsc --noEmit` — compiler errors guide you to every dispatch table + * 3. Use this checklist for each file: + * + * FILE | WHAT TO ADD | DEFAULT (simple languages) + * ----------------------------------|------------------------------------------|--------------------------- + * tree-sitter-queries.ts | Query string + LANGUAGE_QUERIES entry | (required) + * export-detection.ts | ExportChecker function + table entry | (required) + * import-resolution.ts | Resolver in buildImportResolvers | resolveStandard(...) + * import-resolution.ts | namedBindingExtractors entry | undefined + * call-routing.ts | callRouters entry | noRouting + * entry-point-scoring.ts | ENTRY_POINT_PATTERNS entry | [] + * framework-detection.ts | AST_FRAMEWORK_PATTERNS entry | [] + * type-extractors/.ts | New file + index.ts import | (required) + * resolvers/.ts | Resolver file (if non-standard) | (only if resolveStandard insufficient) + * named-binding-extraction.ts | Extractor (if named imports) | (only if language has named imports) + * + * 4. Also check these files for language-specific if-checks (no compile-time guard): + * - mro-processor.ts (MRO strategy selection) + * - heritage-processor.ts (extends/implements handling) + * - parse-worker.ts (AST edge cases) + * - parsing-processor.ts (node label normalization) + * + * 5. Add tree-sitter- to package.json dependencies + * 6. Add file extension mapping in utils.ts getLanguageFromFilename() + * 7. Run full test suite + */ export enum SupportedLanguages { JavaScript = 'javascript', TypeScript = 'typescript', diff --git a/gitnexus/src/core/ingestion/entry-point-scoring.ts b/gitnexus/src/core/ingestion/entry-point-scoring.ts index b58bbe33d..1da37a1df 100644 --- a/gitnexus/src/core/ingestion/entry-point-scoring.ts +++ b/gitnexus/src/core/ingestion/entry-point-scoring.ts @@ -18,26 +18,29 @@ import { SupportedLanguages } from '../../config/supported-languages.js'; // ============================================================================ /** - * Common entry point naming patterns by language - * These patterns indicate functions that are likely feature entry points + * Common entry point naming patterns by language. + * These patterns indicate functions that are likely feature entry points. + * + * Universal patterns are separated from per-language patterns so the per-language + * table can use `satisfies Record` for compile-time + * exhaustiveness — the compiler catches any missing language entry. */ -const ENTRY_POINT_PATTERNS: Record = { - // Universal patterns (apply to all languages) - '*': [ - /^(main|init|bootstrap|start|run|setup|configure)$/i, - /^handle[A-Z]/, // handleLogin, handleSubmit - /^on[A-Z]/, // onClick, onSubmit - /Handler$/, // RequestHandler - /Controller$/, // UserController - /^process[A-Z]/, // processPayment - /^execute[A-Z]/, // executeQuery - /^perform[A-Z]/, // performAction - /^dispatch[A-Z]/, // dispatchEvent - /^trigger[A-Z]/, // triggerAction - /^fire[A-Z]/, // fireEvent - /^emit[A-Z]/, // emitEvent - ], - +const UNIVERSAL_ENTRY_POINT_PATTERNS: RegExp[] = [ + /^(main|init|bootstrap|start|run|setup|configure)$/i, + /^handle[A-Z]/, // handleLogin, handleSubmit + /^on[A-Z]/, // onClick, onSubmit + /Handler$/, // RequestHandler + /Controller$/, // UserController + /^process[A-Z]/, // processPayment + /^execute[A-Z]/, // executeQuery + /^perform[A-Z]/, // performAction + /^dispatch[A-Z]/, // dispatchEvent + /^trigger[A-Z]/, // triggerAction + /^fire[A-Z]/, // fireEvent + /^emit[A-Z]/, // emitEvent +]; + +const ENTRY_POINT_PATTERNS = { // JavaScript/TypeScript [SupportedLanguages.JavaScript]: [ /^use[A-Z]/, // React hooks (useEffect, etc.) @@ -62,6 +65,17 @@ const ENTRY_POINT_PATTERNS: Record = { /Service$/, // UserService ], + // Kotlin + [SupportedLanguages.Kotlin]: [ + /^on(Create|Start|Resume|Pause|Stop|Destroy)$/, // Android lifecycle + /^do[A-Z]/, // doGet, doPost (shared JVM Servlet pattern) + /^create[A-Z]/, // Factory patterns + /^build[A-Z]/, // Builder patterns + /ViewModel$/, // MVVM pattern (Android) + /^module$/, // Ktor module entry point + /Service$/, // Service classes + ], + // C# [SupportedLanguages.CSharp]: [ /^(Get|Post|Put|Delete|Patch)/, // ASP.NET action methods @@ -77,7 +91,7 @@ const ENTRY_POINT_PATTERNS: Record = { /Service$/, // Service classes /^Seed/, // Database seeding ], - + // Go [SupportedLanguages.Go]: [ /Handler$/, // http.Handler pattern @@ -85,7 +99,7 @@ const ENTRY_POINT_PATTERNS: Record = { /^New[A-Z]/, // Constructor pattern (returns new instance) /^Make[A-Z]/, // Make functions ], - + // Rust [SupportedLanguages.Rust]: [ /^(get|post|put|delete)_handler$/i, @@ -94,7 +108,7 @@ const ENTRY_POINT_PATTERNS: Record = { /^run$/, // run entry point /^spawn/, // Async spawn ], - + // C - explicit main() boost plus common C entry point conventions [SupportedLanguages.C]: [ /^main$/, // THE entry point @@ -198,15 +212,15 @@ const ENTRY_POINT_PATTERNS: Record = { /^perform$/, // Background jobs (Sidekiq, ActiveJob) /^execute$/, // Command pattern ], -}; +} satisfies Record; /** Pre-computed merged patterns (universal + language-specific) to avoid per-call array allocation. */ -const MERGED_ENTRY_POINT_PATTERNS: Record = {}; -const UNIVERSAL_PATTERNS = ENTRY_POINT_PATTERNS['*'] || []; -for (const [lang, patterns] of Object.entries(ENTRY_POINT_PATTERNS)) { - if (lang === '*') continue; - MERGED_ENTRY_POINT_PATTERNS[lang] = [...UNIVERSAL_PATTERNS, ...patterns]; -} +const MERGED_ENTRY_POINT_PATTERNS = Object.fromEntries( + (Object.keys(ENTRY_POINT_PATTERNS) as SupportedLanguages[]).map(lang => [ + lang, + [...UNIVERSAL_ENTRY_POINT_PATTERNS, ...ENTRY_POINT_PATTERNS[lang]], + ]) +) as Record; // ============================================================================ // UTILITY PATTERNS - Functions that should be penalized @@ -295,7 +309,7 @@ export function calculateEntryPointScore( reasons.push('utility-pattern'); } else { // Check positive patterns - const allPatterns = MERGED_ENTRY_POINT_PATTERNS[language] || UNIVERSAL_PATTERNS; + const allPatterns = MERGED_ENTRY_POINT_PATTERNS[language]; if (allPatterns.some(p => p.test(name))) { nameMultiplier = 1.5; // Bonus for matching entry point pattern diff --git a/gitnexus/src/core/ingestion/framework-detection.ts b/gitnexus/src/core/ingestion/framework-detection.ts index f17d93b36..3cf30421c 100644 --- a/gitnexus/src/core/ingestion/framework-detection.ts +++ b/gitnexus/src/core/ingestion/framework-detection.ts @@ -10,6 +10,8 @@ * (no bonus, no penalty) - same behavior as before this feature. */ +import { SupportedLanguages } from '../../config/supported-languages.js'; + // ============================================================================ // TYPES // ============================================================================ @@ -235,7 +237,7 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null } // Go main.go files (THE entry point) - if (p.endsWith('/main.go') || p.endsWith('/cmd/') && p.endsWith('.go')) { + if (p.endsWith('/main.go') || (p.includes('/cmd/') && p.endsWith('.go'))) { return { framework: 'go', entryPointMultiplier: 3.0, reason: 'go-main' }; } @@ -431,25 +433,36 @@ export const FRAMEWORK_AST_PATTERNS = { 'blazor': ['@page', '[Parameter]', '@inject'], 'efcore': ['DbContext', 'DbSet<', 'OnModelCreating'], - // Go patterns (function signatures) - 'go-http': ['http.Handler', 'http.HandlerFunc', 'ServeHTTP'], + // Go patterns (function signatures include framework types) + 'go-http': ['http.Handler', 'http.HandlerFunc', 'ServeHTTP', 'http.ResponseWriter', 'http.Request'], + 'gin': ['gin.Context', 'gin.Default', 'gin.New'], + 'echo': ['echo.Context', 'echo.New'], + 'fiber': ['fiber.Ctx', 'fiber.New', 'fiber.App'], + 'go-grpc': ['grpc.Server', 'RegisterServer', 'pb.Unimplemented'], // PHP/Laravel 'laravel': ['Route::get', 'Route::post', 'Route::put', 'Route::delete', 'Route::resource', 'Route::apiResource', '#[Route('], - // Rust macros - 'actix': ['#[get', '#[post', '#[put', '#[delete'], - 'axum': ['Router::new'], - 'rocket': ['#[get', '#[post'], + // Rust macros (proc-macro attributes in definition text) + 'actix': ['#[get', '#[post', '#[put', '#[delete', '#[actix_web', 'HttpRequest', 'HttpResponse'], + 'axum': ['Router::new', 'axum::extract', 'axum::routing'], + 'rocket': ['#[get', '#[post', '#[launch', 'rocket::'], + 'tokio': ['#[tokio::main]', '#[tokio::test]'], + + // C++ patterns (Qt, Boost) + 'qt': ['Q_OBJECT', 'Q_INVOKABLE', 'Q_PROPERTY', 'Q_SIGNAL', 'Q_SLOT', 'QWidget', 'QApplication'], // Swift/iOS - 'uikit': ['viewDidLoad', 'viewWillAppear', 'viewDidAppear', 'UIViewController'], - 'swiftui': ['@main', 'WindowGroup', 'ContentView', '@StateObject', '@ObservedObject'], - 'combine': ['sink', 'assign', 'Publisher', 'Subscriber'], -}; + 'uikit': ['viewDidLoad', 'viewWillAppear', 'viewDidAppear', 'UIViewController', '@IBOutlet', '@IBAction', '@objc'], + 'swiftui': ['@main', 'WindowGroup', 'ContentView', '@StateObject', '@ObservedObject', '@EnvironmentObject', '@Published'], + 'vapor': ['app.get', 'app.post', 'req.content.decode', 'Vapor'], -import { SupportedLanguages } from '../../config/supported-languages.js'; + // Ruby patterns (class-level macros in definition text) + 'rails': ['ApplicationController', 'ApplicationRecord', 'ActiveRecord::Base', + 'before_action', 'after_action', 'has_many', 'belongs_to', 'has_one', 'validates'], + 'sinatra': ['Sinatra::Base', 'Sinatra::Application'], +}; interface AstFrameworkPatternConfig { framework: string; @@ -458,7 +471,7 @@ interface AstFrameworkPatternConfig { patterns: string[]; } -const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record = { +const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = { [SupportedLanguages.JavaScript]: [ { framework: 'nestjs', entryPointMultiplier: 3.2, reason: 'nestjs-decorator', patterns: FRAMEWORK_AST_PATTERNS.nestjs }, ], @@ -488,7 +501,33 @@ const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record; /** Pre-lowercased patterns for O(1) pattern matching at runtime */ const AST_PATTERNS_LOWERED: Record> = diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 95dfe176c..308d77f5f 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -5,34 +5,10 @@ import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/pa 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, - resolveRubyImport, - resolvePythonImport, -} from './resolvers/index.js'; +import { loadImportConfigs } from './language-config.js'; +import { buildSuffixIndex } from './resolvers/index.js'; import { callRouters } from './call-routing.js'; import type { ResolutionContext } from './resolution-context.js'; import type { @@ -42,6 +18,8 @@ import type { CSharpProjectConfig, ComposerConfig } from './resolvers/index.js'; +import { buildImportResolvers, namedBindingExtractors, preprocessImportPath } from './import-resolution.js'; +import type { ImportResult, ResolveCtx, NamedBinding } from './import-resolution.js'; // Re-export resolver types for consumers export type { @@ -89,204 +67,40 @@ export interface ImportResolutionContext { allFilePaths: Set; allFileList: string[]; normalizedFileList: string[]; - suffixIndex: SuffixIndex | null; + index: SuffixIndex; resolveCache: Map; - /** Release heavyweight fields (suffix index, file lists) to free memory after import resolution. */ - dispose(): void; } export function buildImportResolutionContext(allPaths: string[]): ImportResolutionContext { const allFileList = allPaths; const normalizedFileList = allFileList.map(p => p.replace(/\\/g, '/')); const allFilePaths = new Set(allFileList); - const suffixIndex = buildSuffixIndex(normalizedFileList, allFileList); - const ctx: ImportResolutionContext = { - allFilePaths, allFileList, normalizedFileList, suffixIndex, resolveCache: new Map(), - dispose() { - ctx.suffixIndex = null; - ctx.normalizedFileList = []; - ctx.resolveCache.clear(); - }, - }; - return ctx; + const index = buildSuffixIndex(normalizedFileList, allFileList); + return { allFilePaths, allFileList, normalizedFileList, index, resolveCache: new Map() }; } // Config loaders extracted to ./language-config.ts (Phase 2 refactor) -// Resolver functions are in ./resolvers/ — imported above +// Resolver dispatch tables are in ./import-resolution.ts — imported above -// ============================================================================ -// SHARED LANGUAGE DISPATCH -// ============================================================================ +/** Create IMPORTS edge helpers that share a resolved-count tracker. */ +function createImportEdgeHelpers(graph: KnowledgeGraph, importMap: ImportMap) { + let totalImportsResolved = 0; -/** 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[]; -} + const addImportGraphEdge = (filePath: string, resolvedPath: string) => { + const sourceId = generateId('File', filePath); + const targetId = generateId('File', resolvedPath); + const relId = generateId('IMPORTS', `${filePath}->${resolvedPath}`); + totalImportsResolved++; + graph.addRelationship({ id: relId, sourceId, targetId, type: 'IMPORTS', confidence: 1.0, reason: '' }); + }; -/** Context for import path resolution (file lists, indexes, cache). */ -interface ResolveCtx { - allFilePaths: Set; - allFileList: string[]; - normalizedFileList: string[]; - index: SuffixIndex; - resolveCache: Map; -} + const addImportEdge = (filePath: string, resolvedPath: string) => { + addImportGraphEdge(filePath, resolvedPath); + if (!importMap.has(filePath)) importMap.set(filePath, new Set()); + importMap.get(filePath)!.add(resolvedPath); + }; -/** - * 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.) - */ -type ImportResult = - | { kind: 'files'; files: string[] } - | { kind: 'package'; files: string[]; dirSuffix: string } - | null; - -/** - * Shared language dispatch for import resolution. - * Used by both processImports and processImportsFromExtracted. - */ -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 }; - } - if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles }; - // Fall through to standard resolution - } else { - let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index); - if (!memberResolved && language === SupportedLanguages.Kotlin) { - memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index); - } - if (memberResolved) return { kind: 'files', files: [memberResolved] }; - - // Kotlin: top-level function imports (e.g. import models.getUser) have only 2 segments, - // which resolveJvmMemberImport skips (requires ≥3). Fall back to package-directory scan - // for lowercase last segments (function/property imports). Uppercase last segments - // (class imports like models.User) fall through to standard suffix resolution. - if (language === SupportedLanguages.Kotlin) { - const segments = rawImportPath.split('.'); - const lastSeg = segments[segments.length - 1]; - if (segments.length >= 2 && lastSeg[0] && lastSeg[0] === lastSeg[0].toLowerCase()) { - const pkgWildcard = segments.slice(0, -1).join('.') + '.*'; - let dirFiles = resolveJvmWildcard(pkgWildcard, normalizedFileList, allFileList, exts, index); - if (dirFiles.length === 0) { - dirFiles = resolveJvmWildcard(pkgWildcard, normalizedFileList, allFileList, ['.java'], index); - } - if (dirFiles.length > 0) return { kind: 'files', files: dirFiles }; - } - } - // Fall through to standard resolution - } - } - - // 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 }; - } - } - // 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); - if (resolvedFiles.length > 1) { - const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs); - if (dirSuffix) { - return { kind: 'package', files: resolvedFiles, dirSuffix }; - } - } - if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles }; - return null; - } - - // 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; - } - - // 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.) - } - - // Python: relative imports (PEP 328) + proximity-based bare imports - // Falls through to standard suffix resolution when proximity finds no match. - if (language === SupportedLanguages.Python) { - const resolved = resolvePythonImport(filePath, rawImportPath, allFilePaths); - if (resolved) return { kind: 'files', files: [resolved] }; - if (rawImportPath.startsWith('.')) return null; // relative but unresolved — don't suffix-match - } - - // Ruby: require / require_relative - if (language === SupportedLanguages.Ruby) { - const resolved = resolveRubyImport(rawImportPath, normalizedFileList, allFileList, index); - return resolved ? { kind: 'files', files: [resolved] } : null; - } - - // 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; - } - - // Standard single-file resolution - const resolvedPath = resolveImportPath( - filePath, - rawImportPath, - allFilePaths, - allFileList, - normalizedFileList, - resolveCache, - language, - tsconfigPaths, - index, - ); - - return resolvedPath ? { kind: 'files', files: [resolvedPath] } : null; + return { addImportEdge, addImportGraphEdge, getResolvedCount: () => totalImportsResolved }; } /** @@ -301,7 +115,7 @@ function applyImportResult( packageMap: PackageMap | undefined, addImportEdge: (from: string, to: string) => void, addImportGraphEdge: (from: string, to: string) => void, - namedBindings?: { local: string; exported: string }[], + namedBindings?: NamedBinding[], namedImportMap?: NamedImportMap, ): void { if (!result) return; @@ -371,46 +185,12 @@ export const processImports = async ( // Track import statistics let totalImportsFound = 0; - let totalImportsResolved = 0; // Load language-specific configs once before the file loop - const effectiveRoot = repoRoot || ''; - const configs: LanguageConfigs = { - tsconfigPaths: await loadTsconfigPaths(effectiveRoot), - goModule: await loadGoModulePath(effectiveRoot), - composerConfig: await loadComposerConfig(effectiveRoot), - swiftPackageConfig: await loadSwiftPackageConfig(effectiveRoot), - csharpConfigs: await loadCSharpProjectConfig(effectiveRoot), - }; + const configs = await loadImportConfigs(repoRoot || ''); + const importResolvers = buildImportResolvers(configs); const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache }; - - // 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}`); - - totalImportsResolved++; - - graph.addRelationship({ - id: relId, - sourceId, - targetId, - type: 'IMPORTS', - 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()); - } - importMap.get(filePath)!.add(resolvedPath); - }; + const { addImportEdge, addImportGraphEdge, getResolvedCount } = createImportEdgeHelpers(graph, importMap); for (let i = 0; i < files.length; i++) { const file = files[i]; @@ -483,14 +263,12 @@ export const processImports = async ( return; } - // Clean path (remove quotes and angle brackets for C/C++ includes) - const rawImportPath = language === SupportedLanguages.Kotlin - ? appendKotlinWildcard(sourceNode.text.replace(/['"<>]/g, ''), captureMap['import']) - : sourceNode.text.replace(/['"<>]/g, ''); + const rawImportPath = preprocessImportPath(sourceNode.text, captureMap['import'], language); totalImportsFound++; - const result = resolveLanguageImport(file.path, rawImportPath, language, configs, resolveCtx); - const bindings = namedImportMap ? extractNamedBindings(captureMap['import'], language) : undefined; + const result = importResolvers[language](rawImportPath, file.path, resolveCtx); + const extractor = namedBindingExtractors[language]; + const bindings = namedImportMap && extractor ? extractor(captureMap['import']) : undefined; applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge, bindings, namedImportMap); } @@ -502,7 +280,7 @@ export const processImports = async ( const routed = callRouter(callNameNode.text, captureMap['call']); if (routed && routed.kind === 'import') { totalImportsFound++; - const result = resolveLanguageImport(file.path, routed.importPath, language, configs, resolveCtx); + const result = importResolvers[language](routed.importPath, file.path, resolveCtx); applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge); } } @@ -521,7 +299,7 @@ export const processImports = async ( } if (isDev) { - console.log(`📊 Import processing complete: ${totalImportsResolved}/${totalImportsFound} imports resolved to graph edges`); + console.log(`📊 Import processing complete: ${getResolvedCount()}/${totalImportsFound} imports resolved to graph edges`); } }; @@ -542,47 +320,14 @@ export const processImportsFromExtracted = async ( const packageMap = ctx.packageMap; const namedImportMap = ctx.namedImportMap; const importCtx = prebuiltCtx ?? buildImportResolutionContext(files.map(f => f.path)); - const { allFilePaths, allFileList, normalizedFileList, suffixIndex: index, resolveCache } = importCtx; + const { allFilePaths, allFileList, normalizedFileList, index, resolveCache } = importCtx; let totalImportsFound = 0; - let totalImportsResolved = 0; - const effectiveRoot = repoRoot || ''; - const configs: LanguageConfigs = { - tsconfigPaths: await loadTsconfigPaths(effectiveRoot), - goModule: await loadGoModulePath(effectiveRoot), - composerConfig: await loadComposerConfig(effectiveRoot), - swiftPackageConfig: await loadSwiftPackageConfig(effectiveRoot), - csharpConfigs: await loadCSharpProjectConfig(effectiveRoot), - }; + const configs = await loadImportConfigs(repoRoot || ''); + const importResolvers = buildImportResolvers(configs); const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache }; - - // 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}`); - - totalImportsResolved++; - - graph.addRelationship({ - id: relId, - sourceId, - targetId, - type: 'IMPORTS', - confidence: 1.0, - reason: '', - }); - }; - - const addImportEdge = (filePath: string, resolvedPath: string) => { - addImportGraphEdge(filePath, resolvedPath); - - if (!importMap.has(filePath)) { - importMap.set(filePath, new Set()); - } - importMap.get(filePath)!.add(resolvedPath); - }; + const { addImportEdge, addImportGraphEdge, getResolvedCount } = createImportEdgeHelpers(graph, importMap); // Group by file for progress reporting (users see file count, not import count) const importsByFile = new Map(); @@ -608,7 +353,7 @@ export const processImportsFromExtracted = async ( for (const imp of fileImports) { totalImportsFound++; - const result = resolveLanguageImport(filePath, imp.rawImportPath, imp.language, configs, resolveCtx); + const result = importResolvers[imp.language](imp.rawImportPath, filePath, resolveCtx); applyImportResult(result, filePath, importMap, packageMap, addImportEdge, addImportGraphEdge, imp.namedBindings, namedImportMap); } } @@ -616,6 +361,6 @@ export const processImportsFromExtracted = async ( onProgress?.(totalFiles, totalFiles); if (isDev) { - console.log(`📊 Import processing (fast path): ${totalImportsResolved}/${totalImportsFound} imports resolved to graph edges`); + console.log(`📊 Import processing (fast path): ${getResolvedCount()}/${totalImportsFound} imports resolved to graph edges`); } }; diff --git a/gitnexus/src/core/ingestion/import-resolution.ts b/gitnexus/src/core/ingestion/import-resolution.ts new file mode 100644 index 000000000..c29571341 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolution.ts @@ -0,0 +1,375 @@ +/** + * Import Resolution Dispatch + * + * Per-language dispatch table for import resolution and named binding extraction. + * Replaces the 120-line if-chain in resolveLanguageImport() and the 7-branch + * dispatch in extractNamedBindings() with a single table lookup each. + * + * Follows the existing ExportChecker / CallRouter pattern: + * - Function aliases (not interfaces) to avoid megamorphic inline-cache issues + * - `satisfies Record` for compile-time exhaustiveness + * - Factory function that closes over per-language configs at build time + */ + +import { SupportedLanguages } from '../../config/supported-languages.js'; +import type { SyntaxNode } from './utils.js'; +import { + KOTLIN_EXTENSIONS, + appendKotlinWildcard, + resolveJvmWildcard, + resolveJvmMemberImport, + resolveGoPackageDir, + resolveGoPackage, + resolveCSharpImport as resolveCSharpImportHelper, + resolveCSharpNamespaceDir, + resolvePhpImport as resolvePhpImportHelper, + resolveRustImport as resolveRustImportHelper, + resolveRubyImport as resolveRubyImportHelper, + resolvePythonImport as resolvePythonImportHelper, + resolveImportPath, +} from './resolvers/index.js'; +import type { + SuffixIndex, + TsconfigPaths, + GoModuleConfig, + CSharpProjectConfig, + ComposerConfig, +} from './resolvers/index.js'; +import type { SwiftPackageConfig } from './language-config.js'; +import { + extractTsNamedBindings, + extractPythonNamedBindings, + extractKotlinNamedBindings, + extractRustNamedBindings, + extractPhpNamedBindings, + extractCsharpNamedBindings, + extractJavaNamedBindings, +} from './named-binding-extraction.js'; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * 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.) + */ +export type ImportResult = + | { kind: 'files'; files: string[] } + | { kind: 'package'; files: string[]; dirSuffix: string } + | null; + +/** Per-language import resolver -- function alias matching ExportChecker/CallRouter pattern. */ +export type ImportResolverFn = ( + rawImportPath: string, + filePath: string, + resolveCtx: ResolveCtx, +) => ImportResult; + +/** A single named import binding: local name in the importing file and exported name from the source. */ +export interface NamedBinding { local: string; exported: string } + +/** Per-language named binding extractor -- optional (returns undefined if language has no named imports). */ +type NamedBindingExtractorFn = (importNode: SyntaxNode) => NamedBinding[] | undefined; + +/** Bundled language-specific configs loaded once per ingestion run. */ +export interface ImportConfigs { + tsconfigPaths: TsconfigPaths | null; + goModule: GoModuleConfig | null; + composerConfig: ComposerConfig | null; + swiftPackageConfig: SwiftPackageConfig | null; + csharpConfigs: CSharpProjectConfig[]; +} + +/** Context for import path resolution — narrowed from ImportResolutionContext with non-null index. */ +export interface ResolveCtx { + allFilePaths: Set; + allFileList: string[]; + normalizedFileList: string[]; + index: SuffixIndex; + resolveCache: Map; +} + +// ============================================================================ +// Import path preprocessing +// ============================================================================ + +/** + * Clean and preprocess a raw import source text into a resolved import path. + * Strips quotes/angle brackets (universal) and applies language-specific + * transformations (currently only Kotlin wildcard import detection). + */ +export function preprocessImportPath( + sourceText: string, + importNode: SyntaxNode, + language: SupportedLanguages, +): string { + const cleaned = sourceText.replace(/['"<>]/g, ''); + // Defense-in-depth: reject null bytes and control characters (matches Ruby call-routing pattern) + if (!cleaned || cleaned.length > 2048 || /[\x00-\x1f]/.test(cleaned)) return ''; + if (language === SupportedLanguages.Kotlin) { + return appendKotlinWildcard(cleaned, importNode); + } + return cleaned; +} + +// ============================================================================ +// Per-language resolver functions +// ============================================================================ + +/** + * Standard single-file resolution (TS/JS/C/C++ and fallback for other languages). + * Handles relative imports, tsconfig path aliases, and suffix matching. + */ +function resolveStandard( + rawImportPath: string, + filePath: string, + ctx: ResolveCtx, + language: SupportedLanguages, + tsconfigPaths: TsconfigPaths | null, +): ImportResult { + const resolvedPath = resolveImportPath( + filePath, + rawImportPath, + ctx.allFilePaths, + ctx.allFileList, + ctx.normalizedFileList, + ctx.resolveCache, + language, + tsconfigPaths, + ctx.index, + ); + return resolvedPath ? { kind: 'files', files: [resolvedPath] } : null; +} + +/** Java: JVM wildcard -> member import -> standard fallthrough */ +function resolveJavaImport( + rawImportPath: string, + filePath: string, + ctx: ResolveCtx, + tsconfigPaths: TsconfigPaths | null, +): ImportResult { + if (rawImportPath.endsWith('.*')) { + const matchedFiles = resolveJvmWildcard(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index); + if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles }; + } else { + const memberResolved = resolveJvmMemberImport(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index); + if (memberResolved) return { kind: 'files', files: [memberResolved] }; + } + return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Java, tsconfigPaths); +} + +/** + * Kotlin: JVM wildcard/member with Java-interop fallback -> top-level function imports -> standard. + * Kotlin can import from .kt/.kts files OR from .java files (Java interop). + */ +function resolveKotlinImport( + rawImportPath: string, + filePath: string, + ctx: ResolveCtx, + tsconfigPaths: TsconfigPaths | null, +): ImportResult { + if (rawImportPath.endsWith('.*')) { + const matchedFiles = resolveJvmWildcard(rawImportPath, ctx.normalizedFileList, ctx.allFileList, KOTLIN_EXTENSIONS, ctx.index); + if (matchedFiles.length === 0) { + const javaMatches = resolveJvmWildcard(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index); + if (javaMatches.length > 0) return { kind: 'files', files: javaMatches }; + } + if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles }; + } else { + let memberResolved = resolveJvmMemberImport(rawImportPath, ctx.normalizedFileList, ctx.allFileList, KOTLIN_EXTENSIONS, ctx.index); + if (!memberResolved) { + memberResolved = resolveJvmMemberImport(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index); + } + if (memberResolved) return { kind: 'files', files: [memberResolved] }; + + // Kotlin: top-level function imports (e.g. import models.getUser) have only 2 segments, + // which resolveJvmMemberImport skips (requires >=3). Fall back to package-directory scan + // for lowercase last segments (function/property imports). Uppercase last segments + // (class imports like models.User) fall through to standard suffix resolution. + const segments = rawImportPath.split('.'); + const lastSeg = segments[segments.length - 1]; + if (segments.length >= 2 && lastSeg[0] && lastSeg[0] === lastSeg[0].toLowerCase()) { + const pkgWildcard = segments.slice(0, -1).join('.') + '.*'; + let dirFiles = resolveJvmWildcard(pkgWildcard, ctx.normalizedFileList, ctx.allFileList, KOTLIN_EXTENSIONS, ctx.index); + if (dirFiles.length === 0) { + dirFiles = resolveJvmWildcard(pkgWildcard, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index); + } + if (dirFiles.length > 0) return { kind: 'files', files: dirFiles }; + } + } + return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Kotlin, tsconfigPaths); +} + +/** Go: package-level imports via go.mod module path. */ +function resolveGoImport( + rawImportPath: string, + filePath: string, + ctx: ResolveCtx, + goModule: GoModuleConfig | null, + tsconfigPaths: TsconfigPaths | null, +): ImportResult { + if (goModule && rawImportPath.startsWith(goModule.modulePath)) { + const pkgSuffix = resolveGoPackageDir(rawImportPath, goModule); + if (pkgSuffix) { + const pkgFiles = resolveGoPackage(rawImportPath, goModule, ctx.normalizedFileList, ctx.allFileList); + if (pkgFiles.length > 0) { + return { kind: 'package', files: pkgFiles, dirSuffix: pkgSuffix }; + } + } + // Fall through if no files found (package might be external) + } + return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Go, tsconfigPaths); +} + +/** C#: namespace-based resolution via .csproj configs. */ +function resolveCSharpImportDispatch( + rawImportPath: string, + _filePath: string, + ctx: ResolveCtx, + csharpConfigs: CSharpProjectConfig[], +): ImportResult { + if (csharpConfigs.length > 0) { + const resolvedFiles = resolveCSharpImportHelper(rawImportPath, csharpConfigs, ctx.normalizedFileList, ctx.allFileList, ctx.index); + if (resolvedFiles.length > 1) { + const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs); + if (dirSuffix) { + return { kind: 'package', files: resolvedFiles, dirSuffix }; + } + } + if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles }; + } + return null; +} + +/** PHP: namespace-based resolution via composer.json PSR-4. */ +function resolvePhpImportDispatch( + rawImportPath: string, + _filePath: string, + ctx: ResolveCtx, + composerConfig: ComposerConfig | null, +): ImportResult { + const resolved = resolvePhpImportHelper(rawImportPath, composerConfig, ctx.allFilePaths, ctx.normalizedFileList, ctx.allFileList, ctx.index); + return resolved ? { kind: 'files', files: [resolved] } : null; +} + +/** Swift: module imports via Package.swift target map. */ +function resolveSwiftImportDispatch( + rawImportPath: string, + _filePath: string, + ctx: ResolveCtx, + swiftPackageConfig: SwiftPackageConfig | null, +): ImportResult { + if (swiftPackageConfig) { + const targetDir = swiftPackageConfig.targets.get(rawImportPath); + if (targetDir) { + const dirPrefix = targetDir + '/'; + const files: string[] = []; + for (let i = 0; i < ctx.normalizedFileList.length; i++) { + if (ctx.normalizedFileList[i].startsWith(dirPrefix) && ctx.normalizedFileList[i].endsWith('.swift')) { + files.push(ctx.allFileList[i]); + } + } + if (files.length > 0) return { kind: 'files', files }; + } + } + return null; // External framework (Foundation, UIKit, etc.) +} + +/** + * Python: relative imports (PEP 328) + proximity-based bare imports. + * Falls through to standard suffix resolution when proximity finds no match. + */ +function resolvePythonImportDispatch( + rawImportPath: string, + filePath: string, + ctx: ResolveCtx, + tsconfigPaths: TsconfigPaths | null, +): ImportResult { + const resolved = resolvePythonImportHelper(filePath, rawImportPath, ctx.allFilePaths); + if (resolved) return { kind: 'files', files: [resolved] }; + if (rawImportPath.startsWith('.')) return null; // relative but unresolved -- don't suffix-match + return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Python, tsconfigPaths); +} + +/** Ruby: require / require_relative. */ +function resolveRubyImportDispatch( + rawImportPath: string, + _filePath: string, + ctx: ResolveCtx, +): ImportResult { + const resolved = resolveRubyImportHelper(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ctx.index); + return resolved ? { kind: 'files', files: [resolved] } : null; +} + +/** Rust: expand top-level grouped imports: use {crate::a, crate::b}. */ +function resolveRustImportDispatch( + rawImportPath: string, + filePath: string, + ctx: ResolveCtx, + tsconfigPaths: TsconfigPaths | null, +): ImportResult { + if (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 = resolveRustImportHelper(filePath, part, ctx.allFilePaths); + if (r) resolved.push(r); + } + return resolved.length > 0 ? { kind: 'files', files: resolved } : null; + } + return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Rust, tsconfigPaths); +} + +// ============================================================================ +// Dispatch tables +// ============================================================================ + +/** + * Build the import resolver dispatch table with per-language configs closed over at construction time. + * Each resolver function encapsulates the full resolution flow for its language, including + * fallthrough to standard resolution where appropriate. + */ +export function buildImportResolvers(configs: ImportConfigs): Record { + const { tsconfigPaths, goModule, composerConfig, swiftPackageConfig, csharpConfigs } = configs; + + return { + [SupportedLanguages.JavaScript]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.JavaScript, tsconfigPaths), + [SupportedLanguages.TypeScript]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.TypeScript, tsconfigPaths), + [SupportedLanguages.Python]: (raw, fp, ctx) => resolvePythonImportDispatch(raw, fp, ctx, tsconfigPaths), + [SupportedLanguages.Java]: (raw, fp, ctx) => resolveJavaImport(raw, fp, ctx, tsconfigPaths), + [SupportedLanguages.C]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.C, tsconfigPaths), + [SupportedLanguages.CPlusPlus]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.CPlusPlus, tsconfigPaths), + [SupportedLanguages.CSharp]: (raw, fp, ctx) => resolveCSharpImportDispatch(raw, fp, ctx, csharpConfigs), + [SupportedLanguages.Go]: (raw, fp, ctx) => resolveGoImport(raw, fp, ctx, goModule, tsconfigPaths), + [SupportedLanguages.Ruby]: (raw, fp, ctx) => resolveRubyImportDispatch(raw, fp, ctx), + [SupportedLanguages.Rust]: (raw, fp, ctx) => resolveRustImportDispatch(raw, fp, ctx, tsconfigPaths), + [SupportedLanguages.PHP]: (raw, fp, ctx) => resolvePhpImportDispatch(raw, fp, ctx, composerConfig), + [SupportedLanguages.Kotlin]: (raw, fp, ctx) => resolveKotlinImport(raw, fp, ctx, tsconfigPaths), + [SupportedLanguages.Swift]: (raw, fp, ctx) => resolveSwiftImportDispatch(raw, fp, ctx, swiftPackageConfig), + } satisfies Record; +} + +/** + * Per-language named binding extractor dispatch table. + * Languages with whole-module import semantics (Go, Ruby, C/C++, Swift) return undefined -- + * their bindings are synthesized post-parse by synthesizeWildcardImportBindings() in pipeline.ts. + */ +export const namedBindingExtractors = { + [SupportedLanguages.JavaScript]: extractTsNamedBindings, + [SupportedLanguages.TypeScript]: extractTsNamedBindings, + [SupportedLanguages.Python]: extractPythonNamedBindings, + [SupportedLanguages.Java]: extractJavaNamedBindings, + [SupportedLanguages.C]: undefined, + [SupportedLanguages.CPlusPlus]: undefined, + [SupportedLanguages.CSharp]: extractCsharpNamedBindings, + [SupportedLanguages.Go]: undefined, + [SupportedLanguages.Ruby]: undefined, + [SupportedLanguages.Rust]: extractRustNamedBindings, + [SupportedLanguages.PHP]: extractPhpNamedBindings, + [SupportedLanguages.Kotlin]: extractKotlinNamedBindings, + [SupportedLanguages.Swift]: undefined, +} satisfies Record; diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index cd64ca10d..2bb56d8f8 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -1,5 +1,6 @@ import fs from 'fs/promises'; import path from 'path'; +import type { ImportConfigs } from './import-resolution.js'; const isDev = process.env.NODE_ENV === 'development'; @@ -213,3 +214,18 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise { + return { + tsconfigPaths: await loadTsconfigPaths(repoRoot), + goModule: await loadGoModulePath(repoRoot), + composerConfig: await loadComposerConfig(repoRoot), + swiftPackageConfig: await loadSwiftPackageConfig(repoRoot), + csharpConfigs: await loadCSharpProjectConfig(repoRoot), + }; +} diff --git a/gitnexus/src/core/ingestion/named-binding-extraction.ts b/gitnexus/src/core/ingestion/named-binding-extraction.ts index 5cb762259..172faff13 100644 --- a/gitnexus/src/core/ingestion/named-binding-extraction.ts +++ b/gitnexus/src/core/ingestion/named-binding-extraction.ts @@ -1,6 +1,8 @@ -import { SupportedLanguages } from '../../config/supported-languages.js'; import type { SymbolTable, SymbolDefinition } from './symbol-table.js'; import type { NamedImportMap } from './import-processor.js'; +import type { NamedBinding } from './import-resolution.js'; +import type { SyntaxNode } from './utils.js'; +import { findChild } from './resolvers/utils.js'; /** * Walk a named-binding re-export chain through NamedImportMap. @@ -54,61 +56,14 @@ export function walkBindingChain( 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); - } - // Languages below use whole-module import semantics — the import AST node does not - // name specific symbols. namedImportMap entries are synthesized post-parse by - // synthesizeWildcardImportBindings() in pipeline.ts, which expands ImportMap edges - // into per-symbol bindings using graph-exported symbols. - // - // Go: `import "pkg"` — all PascalCase symbols available as pkg.Symbol - // Ruby: `require 'file'` — all top-level classes/modules available - // C/C++: `#include "file.h"` — textual inclusion, all non-static declarations available - // Swift: `import Module` — entire module imported (Phase S blocked on tree-sitter-swift Node 22) - return undefined; -} - -export function extractTsNamedBindings(importNode: any): { local: string; exported: string }[] | undefined { +export function extractTsNamedBindings(importNode: SyntaxNode): NamedBinding[] | 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 }[] = []; + const bindings: NamedBinding[] = []; for (let i = 0; i < namedImports.namedChildCount; i++) { const specifier = namedImports.namedChild(i); if (specifier?.type !== 'import_specifier') continue; @@ -132,7 +87,7 @@ export function extractTsNamedBindings(importNode: any): { local: string; export // 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 }[] = []; + const bindings: NamedBinding[] = []; for (let i = 0; i < exportClause.namedChildCount; i++) { const specifier = exportClause.namedChild(i); if (specifier?.type !== 'export_specifier') continue; @@ -159,11 +114,11 @@ export function extractTsNamedBindings(importNode: any): { local: string; export return undefined; } -export function extractPythonNamedBindings(importNode: any): { local: string; exported: string }[] | undefined { +export function extractPythonNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined { // Only from import_from_statement, not plain import_statement if (importNode.type !== 'import_from_statement') return undefined; - const bindings: { local: string; exported: string }[] = []; + const bindings: NamedBinding[] = []; for (let i = 0; i < importNode.namedChildCount; i++) { const child = importNode.namedChild(i); if (!child) continue; @@ -191,7 +146,7 @@ export function extractPythonNamedBindings(importNode: any): { local: string; ex return bindings.length > 0 ? bindings : undefined; } -export function extractKotlinNamedBindings(importNode: any): { local: string; exported: string }[] | undefined { +export function extractKotlinNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined { // import_header > identifier + import_alias > simple_identifier if (importNode.type !== 'import_header') return undefined; @@ -226,16 +181,16 @@ export function extractKotlinNamedBindings(importNode: any): { local: string; ex return [{ local: exportedName, exported: exportedName }]; } -export function extractRustNamedBindings(importNode: any): { local: string; exported: string }[] | undefined { +export function extractRustNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined { // use_declaration may contain use_as_clause at any depth if (importNode.type !== 'use_declaration') return undefined; - const bindings: { local: string; exported: string }[] = []; + const bindings: NamedBinding[] = []; collectRustBindings(importNode, bindings); return bindings.length > 0 ? bindings : undefined; } -function collectRustBindings(node: any, bindings: { local: string; exported: string }[]): void { +function collectRustBindings(node: SyntaxNode, bindings: NamedBinding[]): void { if (node.type === 'use_as_clause') { // First identifier = exported name, second identifier = local alias const idents: string[] = []; @@ -293,15 +248,15 @@ function collectRustBindings(node: any, bindings: { local: string; exported: str } } -export function extractPhpNamedBindings(importNode: any): { local: string; exported: string }[] | undefined { +export function extractPhpNamedBindings(importNode: SyntaxNode): NamedBinding[] | 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 }[] = []; + const bindings: NamedBinding[] = []; // Collect all clauses — from direct children AND from namespace_use_group - const clauses: any[] = []; + const clauses: SyntaxNode[] = []; for (let i = 0; i < importNode.namedChildCount; i++) { const child = importNode.namedChild(i); if (child?.type === 'namespace_use_clause') { @@ -316,8 +271,8 @@ export function extractPhpNamedBindings(importNode: any): { local: string; expor for (const clause of clauses) { // Flat imports: qualified_name + name (alias) - let qualifiedName: any = null; - const names: any[] = []; + let qualifiedName: SyntaxNode | null = null; + const names: SyntaxNode[] = []; for (let j = 0; j < clause.namedChildCount; j++) { const child = clause.namedChild(j); if (child?.type === 'qualified_name') qualifiedName = child; @@ -345,15 +300,15 @@ export function extractPhpNamedBindings(importNode: any): { local: string; expor return bindings.length > 0 ? bindings : undefined; } -export function extractCsharpNamedBindings(importNode: any): { local: string; exported: string }[] | undefined { +export function extractCsharpNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined { // using_directive — three forms: // using Alias = NS.Type; → aliasIdent + qualifiedName // using static NS.Type; → static + qualifiedName (no alias) // using NS; → qualifiedName only (namespace, not capturable) if (importNode.type !== 'using_directive') return undefined; - let aliasIdent: any = null; - let qualifiedName: any = null; + let aliasIdent: SyntaxNode | null = null; + let qualifiedName: SyntaxNode | null = null; let isStatic = false; for (let i = 0; i < importNode.childCount; i++) { const child = importNode.child(i); @@ -383,7 +338,7 @@ export function extractCsharpNamedBindings(importNode: any): { local: string; ex return undefined; } -export function extractJavaNamedBindings(importNode: any): { local: string; exported: string }[] | undefined { +export function extractJavaNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined { // import_declaration > scoped_identifier "com.example.models.User" // Wildcard imports (.*) don't produce named bindings if (importNode.type !== 'import_declaration') return undefined; @@ -411,10 +366,3 @@ export function extractJavaNamedBindings(importNode: any): { local: string; expo return [{ local: name, exported: name }]; } -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 ed7263cea..455b28c79 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -5,12 +5,11 @@ import { LANGUAGE_QUERIES } from './tree-sitter-queries.js'; import { generateId } from '../../lib/utils.js'; import { SymbolTable } from './symbol-table.js'; import { ASTCache } from './ast-cache.js'; -import { getLanguageFromFilename, yieldToEventLoop, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature, isKotlinClassMethod } from './utils.js'; +import { getLanguageFromFilename, yieldToEventLoop, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature, getLabelFromCaptures } from './utils.js'; import { extractPropertyDeclaredType } from './type-extractors/shared.js'; import { isNodeExported } from './export-detection.js'; import { detectFrameworkFromAST } from './framework-detection.js'; import { typeConfigs } from './type-extractors/index.js'; -import { SupportedLanguages } from '../../config/supported-languages.js'; import { WorkerPool } from './workers/worker-pool.js'; import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings, FileTypeEnvBindings } from './workers/parse-worker.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js'; @@ -27,10 +26,6 @@ export interface WorkerExtractedData { typeEnvBindings: FileTypeEnvBindings[]; } -// isNodeExported imported from ./export-detection.js (shared module) -// Re-export for backward compatibility with any external consumers -export { isNodeExported } from './export-detection.js'; - // ============================================================================ // Worker-based parallel parsing // ============================================================================ @@ -196,65 +191,14 @@ const processParsingSequential = async ( captureMap[c.name] = c.node; }); - if (captureMap['import']) { - return; - } - - if (captureMap['call']) { - return; - } + const nodeLabel = getLabelFromCaptures(captureMap, language); + if (!nodeLabel) return; const nameNode = captureMap['name']; // Synthesize name for constructors without explicit @name capture (e.g. Swift init) - if (!nameNode && !captureMap['definition.constructor']) return; + if (!nameNode && nodeLabel !== 'Constructor') return; const nodeName = nameNode ? nameNode.text : 'init'; - let nodeLabel: NodeLabel = 'CodeElement'; - - if (captureMap['definition.function']) { - // C/C++: @definition.function is broad and also matches inline class methods (inside - // a class/struct body). Those are already captured by @definition.method, so skip - // the duplicate Function entry to prevent double-indexing in globalIndex. - if (language === SupportedLanguages.CPlusPlus || language === SupportedLanguages.C) { - let ancestor = captureMap['definition.function']?.parent; - while (ancestor) { - if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') { - break; - } - ancestor = ancestor.parent; - } - if (ancestor) return; // inside a class body — handled by @definition.method - } - - // Kotlin: function_declaration inside a class_body is a method, not a top-level function. - if (language === SupportedLanguages.Kotlin && - isKotlinClassMethod(captureMap['definition.function'])) { - nodeLabel = 'Method'; - } - if (nodeLabel !== 'Method') nodeLabel = 'Function'; - } - else if (captureMap['definition.class']) nodeLabel = 'Class'; - else if (captureMap['definition.interface']) nodeLabel = 'Interface'; - else if (captureMap['definition.method']) nodeLabel = 'Method'; - else if (captureMap['definition.struct']) nodeLabel = 'Struct'; - else if (captureMap['definition.enum']) nodeLabel = 'Enum'; - else if (captureMap['definition.namespace']) nodeLabel = 'Namespace'; - else if (captureMap['definition.module']) nodeLabel = 'Module'; - else if (captureMap['definition.trait']) nodeLabel = 'Trait'; - else if (captureMap['definition.impl']) nodeLabel = 'Impl'; - else if (captureMap['definition.type']) nodeLabel = 'TypeAlias'; - else if (captureMap['definition.const']) nodeLabel = 'Const'; - else if (captureMap['definition.static']) nodeLabel = 'Static'; - else if (captureMap['definition.typedef']) nodeLabel = 'Typedef'; - else if (captureMap['definition.macro']) nodeLabel = 'Macro'; - else if (captureMap['definition.union']) nodeLabel = 'Union'; - else if (captureMap['definition.property']) nodeLabel = 'Property'; - else if (captureMap['definition.record']) nodeLabel = 'Record'; - else if (captureMap['definition.delegate']) nodeLabel = 'Delegate'; - else if (captureMap['definition.annotation']) nodeLabel = 'Annotation'; - else if (captureMap['definition.constructor']) nodeLabel = 'Constructor'; - else if (captureMap['definition.template']) nodeLabel = 'Template'; - const definitionNodeForRange = getDefinitionNodeFromCaptures(captureMap); const startLine = definitionNodeForRange ? definitionNodeForRange.startPosition.row : (nameNode ? nameNode.startPosition.row : 0); const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`); diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 762816e97..68607c783 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -7,6 +7,7 @@ import { processImportsFromExtracted, buildImportResolutionContext } from './import-processor.js'; +import { EMPTY_INDEX } from './resolvers/index.js'; import { processCalls, processCallsFromExtracted, processAssignmentsFromExtracted, processRoutesFromExtracted, seedCrossFileReceiverTypes, buildImportedReturnTypes, buildImportedRawReturnTypes, type ExportedTypeMap, buildExportedTypeMapFromGraph } from './call-processor.js'; import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js'; import { computeMRO } from './mro-processor.js'; @@ -729,7 +730,8 @@ export const runPipelineFromRepo = async ( // (allPathObjects and importCtx hold ~94MB+ for large repos) allPathObjects.length = 0; importCtx.resolveCache.clear(); - importCtx.dispose(); + importCtx.index = EMPTY_INDEX; // Release suffix index memory (~30MB for large repos) + importCtx.normalizedFileList = []; let communityResult: Awaited> | undefined; let processResult: Awaited> | undefined; diff --git a/gitnexus/src/core/ingestion/resolvers/index.ts b/gitnexus/src/core/ingestion/resolvers/index.ts index b31e535f9..adcdc1b8d 100644 --- a/gitnexus/src/core/ingestion/resolvers/index.ts +++ b/gitnexus/src/core/ingestion/resolvers/index.ts @@ -3,7 +3,7 @@ * Extracted from import-processor.ts for maintainability. */ -export { EXTENSIONS, tryResolveWithExtensions, buildSuffixIndex, suffixResolve } from './utils.js'; +export { EXTENSIONS, tryResolveWithExtensions, buildSuffixIndex, suffixResolve, EMPTY_INDEX } from './utils.js'; export type { SuffixIndex } from './utils.js'; export { KOTLIN_EXTENSIONS, appendKotlinWildcard, resolveJvmWildcard, resolveJvmMemberImport } from './jvm.js'; diff --git a/gitnexus/src/core/ingestion/resolvers/utils.ts b/gitnexus/src/core/ingestion/resolvers/utils.ts index 3c7a1eb2e..12a44d5d2 100644 --- a/gitnexus/src/core/ingestion/resolvers/utils.ts +++ b/gitnexus/src/core/ingestion/resolvers/utils.ts @@ -3,6 +3,8 @@ * Extracted from import-processor.ts to reduce file size. */ +import type { SyntaxNode } from '../utils.js'; + /** All file extensions to try during resolution */ export const EXTENSIONS = [ '', @@ -63,6 +65,13 @@ export interface SuffixIndex { getFilesInDir(dirSuffix: string, extension: string): string[]; } +/** Sentinel index that returns no results. Used to release memory after import resolution. */ +export const EMPTY_INDEX: SuffixIndex = Object.freeze({ + get: () => undefined, + getInsensitive: () => undefined, + getFilesInDir: () => [], +}); + export function buildSuffixIndex(normalizedFileList: string[], allFileList: string[]): SuffixIndex { // Map: normalized suffix -> original file path const exactMap = new Map(); @@ -156,3 +165,12 @@ export function suffixResolve( } return null; } + +/** Find the first direct named child of a tree-sitter node matching the given type. */ +export function findChild(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/utils.ts b/gitnexus/src/core/ingestion/utils.ts index 08a282a06..7e6b16c9a 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -1,5 +1,6 @@ import type Parser from 'tree-sitter'; import { SupportedLanguages } from '../../config/supported-languages.js'; +import type { NodeLabel } from '../graph/types.js'; import { generateId } from '../../lib/utils.js'; import { extractSimpleTypeName } from './type-extractors/shared.js'; @@ -272,6 +273,67 @@ export function isKotlinClassMethod(captureNode: { parent?: any } | null | undef return false; } +/** + * C/C++: check if a Function capture is inside a class/struct body. + * If true, the function is already captured by @definition.method and should be skipped + * to prevent double-indexing in globalIndex. + */ +export function isCppDuplicateClassFunction( + functionNode: { parent?: any } | null | undefined, + nodeLabel: string, + language: SupportedLanguages, +): boolean { + if (nodeLabel !== 'Function') return false; + if (language !== SupportedLanguages.CPlusPlus && language !== SupportedLanguages.C) return false; + let ancestor = functionNode?.parent; + while (ancestor) { + if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') return true; + ancestor = ancestor.parent; + } + return false; +} + +/** + * Determine the graph node label from a tree-sitter capture map. + * Handles language-specific reclassification (C/C++ duplicate skipping, Kotlin Method promotion). + * Returns null if the capture should be skipped (import, call, C/C++ duplicate, missing name). + */ +export function getLabelFromCaptures( + captureMap: Record, + language: SupportedLanguages, +): NodeLabel | null { + if (captureMap['import'] || captureMap['call']) return null; + if (!captureMap['name'] && !captureMap['definition.constructor']) return null; + + if (captureMap['definition.function']) { + if (isCppDuplicateClassFunction(captureMap['definition.function'], 'Function', language)) return null; + if (language === SupportedLanguages.Kotlin && isKotlinClassMethod(captureMap['definition.function'])) return 'Method'; + return 'Function'; + } + if (captureMap['definition.class']) return 'Class'; + if (captureMap['definition.interface']) return 'Interface'; + if (captureMap['definition.method']) return 'Method'; + if (captureMap['definition.struct']) return 'Struct'; + if (captureMap['definition.enum']) return 'Enum'; + if (captureMap['definition.namespace']) return 'Namespace'; + if (captureMap['definition.module']) return 'Module'; + if (captureMap['definition.trait']) return 'Trait'; + if (captureMap['definition.impl']) return 'Impl'; + if (captureMap['definition.type']) return 'TypeAlias'; + if (captureMap['definition.const']) return 'Const'; + if (captureMap['definition.static']) return 'Static'; + if (captureMap['definition.typedef']) return 'Typedef'; + if (captureMap['definition.macro']) return 'Macro'; + if (captureMap['definition.union']) return 'Union'; + if (captureMap['definition.property']) return 'Property'; + if (captureMap['definition.record']) return 'Record'; + if (captureMap['definition.delegate']) return 'Delegate'; + if (captureMap['definition.annotation']) return 'Annotation'; + if (captureMap['definition.constructor']) return 'Constructor'; + if (captureMap['definition.template']) return 'Template'; + return 'CodeElement'; +} + /** 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', diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 6f5e02ecc..618c54980 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -31,7 +31,7 @@ import { isBuiltInOrNoise, getDefinitionNodeFromCaptures, findEnclosingClassId, - isKotlinClassMethod, + getLabelFromCaptures, extractMethodSignature, countCallArguments, inferCallForm, @@ -46,8 +46,8 @@ import { isNodeExported } from '../export-detection.js'; import { detectFrameworkFromAST } from '../framework-detection.js'; import { typeConfigs } from '../type-extractors/index.js'; import { generateId } from '../../../lib/utils.js'; -import { extractNamedBindings } from '../named-binding-extraction.js'; -import { appendKotlinWildcard } from '../resolvers/index.js'; +import { namedBindingExtractors, preprocessImportPath } from '../import-resolution.js'; +import type { NamedBinding } from '../import-resolution.js'; import { callRouters } from '../call-routing.js'; import { extractPropertyDeclaredType } from '../type-extractors/shared.js'; import type { NodeLabel } from '../../graph/types.js'; @@ -102,7 +102,7 @@ export interface ExtractedImport { rawImportPath: string; language: SupportedLanguages; /** Named bindings from the import (e.g., import {User as U} → [{local:'U', exported:'User'}]) */ - namedBindings?: { local: string; exported: string }[]; + namedBindings?: NamedBinding[]; } export interface ExtractedCall { @@ -259,40 +259,7 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null => return null; }; -// ============================================================================ -// Label detection from capture map -// ============================================================================ - -const getLabelFromCaptures = (captureMap: Record): NodeLabel | null => { - // Skip imports (handled separately) and calls - if (captureMap['import'] || captureMap['call']) return null; - // Allow constructors without explicit @name capture (e.g. Swift init) — name synthesized downstream - if (!captureMap['name'] && !captureMap['definition.constructor']) return null; - - if (captureMap['definition.function']) return 'Function'; - if (captureMap['definition.class']) return 'Class'; - if (captureMap['definition.interface']) return 'Interface'; - if (captureMap['definition.method']) return 'Method'; - if (captureMap['definition.struct']) return 'Struct'; - if (captureMap['definition.enum']) return 'Enum'; - if (captureMap['definition.namespace']) return 'Namespace'; - if (captureMap['definition.module']) return 'Module'; - if (captureMap['definition.trait']) return 'Trait'; - if (captureMap['definition.impl']) return 'Impl'; - if (captureMap['definition.type']) return 'TypeAlias'; - if (captureMap['definition.const']) return 'Const'; - if (captureMap['definition.static']) return 'Static'; - if (captureMap['definition.typedef']) return 'Typedef'; - if (captureMap['definition.macro']) return 'Macro'; - if (captureMap['definition.union']) return 'Union'; - if (captureMap['definition.property']) return 'Property'; - if (captureMap['definition.record']) return 'Record'; - if (captureMap['definition.delegate']) return 'Delegate'; - if (captureMap['definition.annotation']) return 'Annotation'; - if (captureMap['definition.constructor']) return 'Constructor'; - if (captureMap['definition.template']) return 'Template'; - return 'CodeElement'; -}; +// Label detection moved to shared getLabelFromCaptures in utils.ts // DEFINITION_CAPTURE_KEYS and getDefinitionNodeFromCaptures imported from ../utils.js @@ -964,10 +931,9 @@ const processFileGroup = ( // Extract import paths before skipping if (captureMap['import'] && captureMap['import.source']) { - 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); + const rawImportPath = preprocessImportPath(captureMap['import.source'].text, captureMap['import'], language); + const extractor = namedBindingExtractors[language]; + const namedBindings = extractor ? extractor(captureMap['import']) : undefined; result.imports.push({ filePath: file.path, rawImportPath, @@ -1166,32 +1132,9 @@ const processFileGroup = ( } } - let nodeLabel = getLabelFromCaptures(captureMap); + const nodeLabel = getLabelFromCaptures(captureMap, language); if (!nodeLabel) continue; - // C/C++: @definition.function is broad and also matches inline class methods (inside - // a class/struct body). Those are already captured by @definition.method, so skip - // the duplicate Function entry to prevent double-indexing in globalIndex. - if ( - (language === SupportedLanguages.CPlusPlus || language === SupportedLanguages.C) && - nodeLabel === 'Function' - ) { - let ancestor = captureMap['definition.function']?.parent; - while (ancestor) { - if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') { - break; // inside a class body — duplicate of @definition.method - } - ancestor = ancestor.parent; - } - if (ancestor) continue; // found a class/struct ancestor → skip - } - - // Kotlin: function_declaration inside a class_body is a method, not a top-level function. - if (language === SupportedLanguages.Kotlin && nodeLabel === 'Function' && - isKotlinClassMethod(captureMap['definition.function'])) { - nodeLabel = 'Method'; - } - const nameNode = captureMap['name']; // Synthesize name for constructors without explicit @name capture (e.g. Swift init) if (!nameNode && nodeLabel !== 'Constructor') continue; diff --git a/gitnexus/test/integration/parsing.test.ts b/gitnexus/test/integration/parsing.test.ts index e918e181f..908c3349b 100644 --- a/gitnexus/test/integration/parsing.test.ts +++ b/gitnexus/test/integration/parsing.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import fs from 'fs/promises'; import path from 'path'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; -import { isNodeExported } from '../../src/core/ingestion/parsing-processor.js'; +import { isNodeExported } from '../../src/core/ingestion/export-detection.js'; import { loadParser, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js'; import { getLanguageFromFilename } from '../../src/core/ingestion/utils.js'; import { SupportedLanguages } from '../../src/config/supported-languages.js'; diff --git a/gitnexus/test/unit/framework-detection.test.ts b/gitnexus/test/unit/framework-detection.test.ts index e81aed1c6..9cf889a34 100644 --- a/gitnexus/test/unit/framework-detection.test.ts +++ b/gitnexus/test/unit/framework-detection.test.ts @@ -299,8 +299,14 @@ describe('detectFrameworkFromAST', () => { expect(result!.framework).toBe('laravel'); }); - it('returns null for unsupported language', () => { - expect(detectFrameworkFromAST('rust', '#[get("/")]')).toBeNull(); + it('detects Actix-web route attributes in Rust', () => { + const result = detectFrameworkFromAST('rust', '#[get("/")]'); + expect(result).not.toBeNull(); + expect(result!.framework).toBe('actix-web'); + }); + + it('returns null for language with no matching pattern', () => { + expect(detectFrameworkFromAST('c', 'int main() { return 0; }')).toBeNull(); }); it('is case-insensitive', () => { @@ -313,8 +319,9 @@ describe('FRAMEWORK_AST_PATTERNS', () => { it('has patterns for all expected frameworks', () => { const expectedFrameworks = [ 'nestjs', 'express', 'fastapi', 'flask', 'spring', 'jaxrs', - 'aspnet', 'go-http', 'laravel', 'actix', 'axum', 'rocket', - 'uikit', 'swiftui', 'combine', + 'aspnet', 'go-http', 'gin', 'echo', 'fiber', 'go-grpc', + 'laravel', 'actix', 'axum', 'rocket', 'tokio', 'qt', + 'uikit', 'swiftui', 'vapor', 'rails', 'sinatra', ]; for (const fw of expectedFrameworks) { expect(FRAMEWORK_AST_PATTERNS).toHaveProperty(fw); diff --git a/gitnexus/test/unit/import-processor.test.ts b/gitnexus/test/unit/import-processor.test.ts index e41a51688..220264332 100644 --- a/gitnexus/test/unit/import-processor.test.ts +++ b/gitnexus/test/unit/import-processor.test.ts @@ -48,8 +48,8 @@ describe('buildImportResolutionContext', () => { }); it('creates a suffix index for O(1) lookups', () => { - expect(ctx.suffixIndex).toBeDefined(); - expect(typeof ctx.suffixIndex.get).toBe('function'); + expect(ctx.index).toBeDefined(); + expect(typeof ctx.index.get).toBe('function'); }); it('initializes empty resolve cache', () => { @@ -65,22 +65,22 @@ describe('buildImportResolutionContext', () => { describe('suffix index', () => { it('resolves file by suffix', () => { - const result = ctx.suffixIndex.get('utils.ts'); + const result = ctx.index.get('utils.ts'); expect(result).toBeDefined(); }); it('resolves file by full path', () => { - const result = ctx.suffixIndex.get('src/index.ts'); + const result = ctx.index.get('src/index.ts'); expect(result).toBeDefined(); }); it('resolves nested component path', () => { - const result = ctx.suffixIndex.get('components/Button.tsx'); + const result = ctx.index.get('components/Button.tsx'); expect(result).toBeDefined(); }); it('returns undefined for non-existent suffix', () => { - const result = ctx.suffixIndex.get('nonexistent.ts'); + const result = ctx.index.get('nonexistent.ts'); expect(result).toBeUndefined(); }); }); From 76ed0fa53b8b6cc1cbee8ecb41436376927a0d7b Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sat, 21 Mar 2026 14:15:15 +0000 Subject: [PATCH 2/2] fix: address PR #409 review findings (P0-P3) and simplify import resolution API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes (P0): - Narrow Go /cmd/ entry-point detection to only match /main.go - Fix Rust scoped grouped imports (use crate::models::{User, Repo}) resolution - Filter PHP use function/use const from class-type namedImportMap bindings Improvements (P1): - Add C# resolveStandard fallback when .csproj discovery fails - Change preprocessImportPath return type to string | null with caller guards - Add Q_SIGNALS/Q_SLOTS (standard plural Qt macros) - Fix stale "11 supported languages" comment → 13 API simplification (P2): - Replace buildImportResolvers() factory with const importResolvers table - Move configs onto ResolveCtx (extends ImportResolutionContext) - Eliminate tsconfigPaths parameter threading through 6 non-TS resolvers - Split utils.ts (1,476 lines) into ast-helpers.ts + call-analysis.ts + utils.ts - Consolidate findChild/findChildByType into single source of truth - Match multi-file import bindings to files by basename for namedImportMap Cleanup (P3): - EMPTY_INDEX returns shared frozen empty array instead of allocating per call - Type appendKotlinWildcard parameter as SyntaxNode instead of any - Document call-routing validation requirement on CallRouter type Tests: - Add unit tests for preprocessImportPath (13 tests) - Add integration tests: Rust scoped multi-file, PHP use function/const, C# without .csproj, Go cmd/ helper scoring (14 tests, 4 fixtures) All 3579 tests pass. --- gitnexus/src/config/supported-languages.ts | 2 +- gitnexus/src/core/ingestion/ast-helpers.ts | 710 ++++++++++ gitnexus/src/core/ingestion/call-analysis.ts | 539 +++++++ gitnexus/src/core/ingestion/call-routing.ts | 6 + .../src/core/ingestion/entry-point-scoring.ts | 2 +- .../src/core/ingestion/framework-detection.ts | 6 +- .../src/core/ingestion/import-processor.ts | 57 +- .../src/core/ingestion/import-resolution.ts | 134 +- .../ingestion/named-binding-extraction.ts | 7 + gitnexus/src/core/ingestion/resolvers/jvm.ts | 3 +- .../src/core/ingestion/resolvers/utils.ts | 4 +- .../core/ingestion/type-extractors/csharp.ts | 9 +- .../src/core/ingestion/type-extractors/go.ts | 2 +- .../core/ingestion/type-extractors/index.ts | 1 - .../src/core/ingestion/type-extractors/jvm.ts | 75 +- .../core/ingestion/type-extractors/shared.ts | 9 - .../core/ingestion/type-extractors/swift.ts | 13 +- gitnexus/src/core/ingestion/utils.ts | 1246 +---------------- .../core/ingestion/workers/parse-worker.ts | 1 + .../csharp-no-csproj/Models/User.cs | 8 + .../csharp-no-csproj/Services/UserService.cs | 13 + .../cmd/server/internal/config/config.go | 5 + .../go-cmd-helper/cmd/server/main.go | 7 + .../lang-resolution/go-cmd-helper/go.mod | 3 + .../app/Config/constants.php | 5 + .../app/Models/User.php | 10 + .../app/Services/Calculator.php | 17 + .../app/Utils/helpers.php | 7 + .../php-use-function-const/composer.json | 7 + .../rust-scoped-multi-file/src/main.rs | 11 + .../rust-scoped-multi-file/src/models/mod.rs | 5 + .../rust-scoped-multi-file/src/models/repo.rs | 13 + .../rust-scoped-multi-file/src/models/user.rs | 13 + .../test/integration/resolvers/csharp.test.ts | 39 + .../test/integration/resolvers/go.test.ts | 30 + .../test/integration/resolvers/php.test.ts | 50 + .../test/integration/resolvers/rust.test.ts | 48 + .../test/unit/framework-detection.test.ts | 5 + gitnexus/test/unit/import-resolution.test.ts | 149 ++ 39 files changed, 1879 insertions(+), 1392 deletions(-) create mode 100644 gitnexus/src/core/ingestion/ast-helpers.ts create mode 100644 gitnexus/src/core/ingestion/call-analysis.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-no-csproj/Models/User.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-no-csproj/Services/UserService.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/go-cmd-helper/cmd/server/internal/config/config.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-cmd-helper/cmd/server/main.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-cmd-helper/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Config/constants.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Models/User.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Services/Calculator.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Utils/helpers.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-use-function-const/composer.json create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-scoped-multi-file/src/main.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-scoped-multi-file/src/models/mod.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-scoped-multi-file/src/models/repo.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-scoped-multi-file/src/models/user.rs create mode 100644 gitnexus/test/unit/import-resolution.test.ts diff --git a/gitnexus/src/config/supported-languages.ts b/gitnexus/src/config/supported-languages.ts index e9f09213a..b4346c6f0 100644 --- a/gitnexus/src/config/supported-languages.ts +++ b/gitnexus/src/config/supported-languages.ts @@ -9,7 +9,7 @@ * ----------------------------------|------------------------------------------|--------------------------- * tree-sitter-queries.ts | Query string + LANGUAGE_QUERIES entry | (required) * export-detection.ts | ExportChecker function + table entry | (required) - * import-resolution.ts | Resolver in buildImportResolvers | resolveStandard(...) + * import-resolution.ts | Resolver in importResolvers | resolveStandard(...) * import-resolution.ts | namedBindingExtractors entry | undefined * call-routing.ts | callRouters entry | noRouting * entry-point-scoring.ts | ENTRY_POINT_PATTERNS entry | [] diff --git a/gitnexus/src/core/ingestion/ast-helpers.ts b/gitnexus/src/core/ingestion/ast-helpers.ts new file mode 100644 index 000000000..d93f43796 --- /dev/null +++ b/gitnexus/src/core/ingestion/ast-helpers.ts @@ -0,0 +1,710 @@ +import type Parser from 'tree-sitter'; +import { SupportedLanguages } from '../../config/supported-languages.js'; +import type { NodeLabel } from '../graph/types.js'; +import { generateId } from '../../lib/utils.js'; +import { extractSimpleTypeName } from './type-extractors/shared.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. + * Used to extract the definition node from a capture map. + */ +export const DEFINITION_CAPTURE_KEYS = [ + 'definition.function', + 'definition.class', + 'definition.interface', + 'definition.method', + 'definition.struct', + 'definition.enum', + 'definition.namespace', + 'definition.module', + 'definition.trait', + 'definition.impl', + 'definition.type', + 'definition.const', + 'definition.static', + 'definition.typedef', + 'definition.macro', + 'definition.union', + 'definition.property', + 'definition.record', + 'definition.delegate', + 'definition.annotation', + 'definition.constructor', + 'definition.template', +] as const; + +/** Extract the definition node from a tree-sitter query capture map. */ +export const getDefinitionNodeFromCaptures = (captureMap: Record): SyntaxNode | null => { + for (const key of DEFINITION_CAPTURE_KEYS) { + if (captureMap[key]) return captureMap[key]; + } + return null; +}; + +/** + * Node types that represent function/method definitions across languages. + * Used to find the enclosing function for a call site. + */ +export const FUNCTION_NODE_TYPES = new Set([ + // TypeScript/JavaScript + 'function_declaration', + 'arrow_function', + 'function_expression', + 'method_definition', + 'generator_function_declaration', + // Python + 'function_definition', + // Common async variants + 'async_function_declaration', + 'async_arrow_function', + // Java + 'method_declaration', + 'constructor_declaration', + // C/C++ + // 'function_definition' already included above + // Go + // 'method_declaration' already included from Java + // C# + 'local_function_statement', + // Rust + 'function_item', + 'impl_item', // Methods inside impl blocks + // PHP + 'anonymous_function', + // Kotlin + 'lambda_literal', + // Swift + 'init_declaration', + 'deinit_declaration', + // Ruby + 'method', // def foo + 'singleton_method', // def self.foo +]); + +/** + * Node types for standard function declarations that need C/C++ declarator handling. + * Used by extractFunctionName to determine how to extract the function name. + */ +export const FUNCTION_DECLARATION_TYPES = new Set([ + 'function_declaration', + 'function_definition', + 'async_function_declaration', + 'generator_function_declaration', + 'function_item', +]); + +/** 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', 'struct_item', 'enum_item', + 'class_definition', + 'trait_declaration', + 'protocol_declaration', + // Ruby + 'class', + 'module', + // Kotlin + 'object_declaration', + 'companion_object', +]); + +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', + struct_item: 'Struct', + enum_item: 'Enum', + trait_declaration: 'Trait', + record_declaration: 'Record', + protocol_declaration: 'Interface', + class: 'Class', + module: 'Module', + object_declaration: 'Class', + companion_object: 'Class', +}; + +/** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method). + * Kotlin grammar uses function_declaration for both top-level functions and class methods. + * Returns true when the captured definition node has a class_body ancestor. */ +export function isKotlinClassMethod(captureNode: { parent?: any } | null | undefined): boolean { + let ancestor = captureNode?.parent; + while (ancestor) { + if (ancestor.type === 'class_body') return true; + ancestor = ancestor.parent; + } + return false; +} + +/** + * C/C++: check if a Function capture is inside a class/struct body. + * If true, the function is already captured by @definition.method and should be skipped + * to prevent double-indexing in globalIndex. + */ +export function isCppDuplicateClassFunction( + functionNode: { parent?: any } | null | undefined, + nodeLabel: string, + language: SupportedLanguages, +): boolean { + if (nodeLabel !== 'Function') return false; + if (language !== SupportedLanguages.CPlusPlus && language !== SupportedLanguages.C) return false; + let ancestor = functionNode?.parent; + while (ancestor) { + if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') return true; + ancestor = ancestor.parent; + } + return false; +} + +/** + * Determine the graph node label from a tree-sitter capture map. + * Handles language-specific reclassification (C/C++ duplicate skipping, Kotlin Method promotion). + * Returns null if the capture should be skipped (import, call, C/C++ duplicate, missing name). + */ +export function getLabelFromCaptures( + captureMap: Record, + language: SupportedLanguages, +): NodeLabel | null { + if (captureMap['import'] || captureMap['call']) return null; + if (!captureMap['name'] && !captureMap['definition.constructor']) return null; + + if (captureMap['definition.function']) { + if (isCppDuplicateClassFunction(captureMap['definition.function'], 'Function', language)) return null; + if (language === SupportedLanguages.Kotlin && isKotlinClassMethod(captureMap['definition.function'])) return 'Method'; + return 'Function'; + } + if (captureMap['definition.class']) return 'Class'; + if (captureMap['definition.interface']) return 'Interface'; + if (captureMap['definition.method']) return 'Method'; + if (captureMap['definition.struct']) return 'Struct'; + if (captureMap['definition.enum']) return 'Enum'; + if (captureMap['definition.namespace']) return 'Namespace'; + if (captureMap['definition.module']) return 'Module'; + if (captureMap['definition.trait']) return 'Trait'; + if (captureMap['definition.impl']) return 'Impl'; + if (captureMap['definition.type']) return 'TypeAlias'; + if (captureMap['definition.const']) return 'Const'; + if (captureMap['definition.static']) return 'Static'; + if (captureMap['definition.typedef']) return 'Typedef'; + if (captureMap['definition.macro']) return 'Macro'; + if (captureMap['definition.union']) return 'Union'; + if (captureMap['definition.property']) return 'Property'; + if (captureMap['definition.record']) return 'Record'; + if (captureMap['definition.delegate']) return 'Delegate'; + if (captureMap['definition.annotation']) return 'Annotation'; + if (captureMap['definition.constructor']) return 'Constructor'; + if (captureMap['definition.template']) return 'Template'; + return 'CodeElement'; +} + +/** 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}`); + } + } + } + } + } + // Go: type_declaration wrapping a struct_type (type User struct { ... }) + // field_declaration → field_declaration_list → struct_type → type_spec → type_declaration + if (current.type === 'type_declaration') { + const typeSpec = current.children?.find((c: any) => c.type === 'type_spec'); + if (typeSpec) { + const typeBody = typeSpec.childForFieldName?.('type'); + if (typeBody?.type === 'struct_type' || typeBody?.type === 'interface_type') { + const nameNode = typeSpec.childForFieldName?.('name'); + if (nameNode) { + const label = typeBody.type === 'struct_type' ? 'Struct' : 'Interface'; + return generateId(label, `${filePath}:${nameNode.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' || c.type === 'constant' + ); + if (nameNode) { + const label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class'; + return generateId(label, `${filePath}:${nameNode.text}`); + } + } + current = current.parent; + } + return null; +}; + +/** + * Find a child of `childType` within a sibling node of `siblingType`. + * Used for Kotlin AST traversal where visibility_modifier lives inside a modifiers sibling. + */ +export const findSiblingChild = (parent: any, siblingType: string, childType: string): any | null => { + for (let i = 0; i < parent.childCount; i++) { + const sibling = parent.child(i); + if (sibling?.type === siblingType) { + for (let j = 0; j < sibling.childCount; j++) { + const child = sibling.child(j); + if (child?.type === childType) return child; + } + } + } + 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. + */ +export const extractFunctionName = (node: SyntaxNode): { funcName: string | null; label: string } => { + let funcName: string | null = null; + let label = 'Function'; + + // Swift init/deinit + if (node.type === 'init_declaration' || node.type === 'deinit_declaration') { + return { + funcName: node.type === 'init_declaration' ? 'init' : 'deinit', + label: 'Constructor', + }; + } + + if (FUNCTION_DECLARATION_TYPES.has(node.type)) { + // C/C++: function_definition -> [pointer_declarator ->] function_declarator -> qualified_identifier/identifier + // Unwrap pointer_declarator / reference_declarator wrappers to reach function_declarator + let declarator = node.childForFieldName?.('declarator'); + if (!declarator) { + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c?.type === 'function_declarator') { declarator = c; break; } + } + } + while (declarator && (declarator.type === 'pointer_declarator' || declarator.type === 'reference_declarator')) { + let nextDeclarator = declarator.childForFieldName?.('declarator'); + if (!nextDeclarator) { + for (let i = 0; i < declarator.childCount; i++) { + const c = declarator.child(i); + if (c?.type === 'function_declarator' || c?.type === 'pointer_declarator' || c?.type === 'reference_declarator') { nextDeclarator = c; break; } + } + } + declarator = nextDeclarator; + } + if (declarator) { + let innerDeclarator = declarator.childForFieldName?.('declarator'); + if (!innerDeclarator) { + for (let i = 0; i < declarator.childCount; i++) { + const c = declarator.child(i); + if (c?.type === 'qualified_identifier' || c?.type === 'identifier' + || c?.type === 'field_identifier' || c?.type === 'parenthesized_declarator') { innerDeclarator = c; break; } + } + } + + if (innerDeclarator?.type === 'qualified_identifier') { + let nameNode = innerDeclarator.childForFieldName?.('name'); + if (!nameNode) { + for (let i = 0; i < innerDeclarator.childCount; i++) { + const c = innerDeclarator.child(i); + if (c?.type === 'identifier') { nameNode = c; break; } + } + } + if (nameNode?.text) { + funcName = nameNode.text; + label = 'Method'; + } + } else if (innerDeclarator?.type === 'identifier' || innerDeclarator?.type === 'field_identifier') { + // field_identifier is used for method names inside C++ class bodies + funcName = innerDeclarator.text; + if (innerDeclarator.type === 'field_identifier') label = 'Method'; + } else if (innerDeclarator?.type === 'parenthesized_declarator') { + let nestedId: SyntaxNode | null = null; + for (let i = 0; i < innerDeclarator.childCount; i++) { + const c = innerDeclarator.child(i); + if (c?.type === 'qualified_identifier' || c?.type === 'identifier') { nestedId = c; break; } + } + if (nestedId?.type === 'qualified_identifier') { + let nameNode = nestedId.childForFieldName?.('name'); + if (!nameNode) { + for (let i = 0; i < nestedId.childCount; i++) { + const c = nestedId.child(i); + if (c?.type === 'identifier') { nameNode = c; break; } + } + } + if (nameNode?.text) { + funcName = nameNode.text; + label = 'Method'; + } + } else if (nestedId?.type === 'identifier') { + funcName = nestedId.text; + } + } + } + + // Fallback for other languages (Kotlin uses simple_identifier, Swift uses simple_identifier) + if (!funcName) { + let nameNode = node.childForFieldName?.('name'); + if (!nameNode) { + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c?.type === 'identifier' || c?.type === 'property_identifier' || c?.type === 'simple_identifier') { nameNode = c; break; } + } + } + funcName = nameNode?.text; + + // Kotlin: function_declaration inside a class_body is a method, not a top-level function. + // Must match the label assigned in parse-worker.ts for consistent generateId() output. + if (funcName && node.type === 'function_declaration' && isKotlinClassMethod(node)) { + label = 'Method'; + } + } + } else if (node.type === 'impl_item') { + let funcItem: SyntaxNode | null = null; + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c?.type === 'function_item') { funcItem = c; break; } + } + if (funcItem) { + let nameNode = funcItem.childForFieldName?.('name'); + if (!nameNode) { + for (let i = 0; i < funcItem.childCount; i++) { + const c = funcItem.child(i); + if (c?.type === 'identifier') { nameNode = c; break; } + } + } + funcName = nameNode?.text; + label = 'Method'; + } + } else if (node.type === 'method_definition') { + let nameNode = node.childForFieldName?.('name'); + if (!nameNode) { + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c?.type === 'property_identifier') { nameNode = c; break; } + } + } + funcName = nameNode?.text; + label = 'Method'; + } else if (node.type === 'method_declaration' || node.type === 'constructor_declaration') { + let nameNode = node.childForFieldName?.('name'); + if (!nameNode) { + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c?.type === 'identifier') { nameNode = c; break; } + } + } + funcName = nameNode?.text; + label = 'Method'; + } else if (node.type === 'arrow_function' || node.type === 'function_expression') { + const parent = node.parent; + if (parent?.type === 'variable_declarator') { + let nameNode = parent.childForFieldName?.('name'); + if (!nameNode) { + for (let i = 0; i < parent.childCount; i++) { + const c = parent.child(i); + if (c?.type === 'identifier') { nameNode = c; break; } + } + } + funcName = nameNode?.text; + } + } else if (node.type === 'method' || node.type === 'singleton_method') { + let nameNode = node.childForFieldName?.('name'); + if (!nameNode) { + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c?.type === 'identifier') { nameNode = c; break; } + } + } + funcName = nameNode?.text; + label = 'Method'; + } + + return { funcName, label }; +}; + +export interface MethodSignature { + parameterCount: number | undefined; + /** Number of required (non-optional, non-default) parameters. + * Only set when fewer than parameterCount — enables range-based arity filtering. + * undefined means all parameters are required (or metadata unavailable). */ + requiredParameterCount: number | undefined; + /** Per-parameter type names extracted via extractSimpleTypeName. + * Only populated for languages with method overloading (Java, Kotlin, C#, C++). + * undefined (not []) when no types are extractable — avoids empty array allocations. */ + parameterTypes: string[] | undefined; + returnType: string | undefined; +} + +/** Argument list node types shared between extractMethodSignature and countCallArguments. */ +export 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 requiredCount = 0; + let returnType: string | undefined; + let isVariadic = false; + const paramTypes: string[] = []; + + if (!node) return { parameterCount, requiredParameterCount: undefined, parameterTypes: undefined, 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 + ]); + + /** AST node types that represent parameters with default values. */ + const OPTIONAL_PARAM_TYPES = new Set([ + 'optional_parameter', // TypeScript, Ruby: (x?: number), (x: number = 5), def f(x = 5) + 'default_parameter', // Python: def f(x=5) + 'typed_default_parameter', // Python: def f(x: int = 5) + 'optional_parameter_declaration', // C++: void f(int x = 5) + ]); + + /** Check if a parameter node has a default value (handles Kotlin, C#, Swift, PHP + * where defaults are expressed as child nodes rather than distinct node types). */ + const hasDefaultValue = (paramNode: SyntaxNode): boolean => { + if (OPTIONAL_PARAM_TYPES.has(paramNode.type)) return true; + // C#, Swift, PHP: check for '=' token or equals_value_clause child + for (let i = 0; i < paramNode.childCount; i++) { + const c = paramNode.child(i); + if (!c) continue; + if (c.type === '=' || c.type === 'equals_value_clause') return true; + } + // Kotlin: default values are siblings of the parameter node, not children. + // The AST is: parameter, =, — all at function_value_parameters level. + // Check if the immediately following sibling is '=' (default value separator). + const sib = paramNode.nextSibling; + if (sib && sib.type === '=') return true; + return false; + }; + + 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; + } + // Kotlin: default values are siblings of the parameter node inside + // function_value_parameters, so they appear as named children (e.g. + // string_literal, integer_literal, boolean_literal, call_expression). + // Skip any named child that isn't a parameter-like or modifier node. + if (param.type.endsWith('_literal') || param.type === 'call_expression' + || param.type === 'navigation_expression' || param.type === 'prefix_expression' + || param.type === 'parenthesized_expression') { + 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; + } + } + // Extract parameter type name for overload disambiguation. + // Works for Java (formal_parameter), Kotlin (parameter), C# (parameter), + // C++ (parameter_declaration). Uses childForFieldName('type') which is the + // standard tree-sitter field for typed parameters across these languages. + // Kotlin uses positional children instead of 'type' field — fall back to + // searching for user_type/nullable_type/predefined_type children. + const paramTypeNode = param.childForFieldName('type'); + if (paramTypeNode) { + const typeName = extractSimpleTypeName(paramTypeNode); + paramTypes.push(typeName ?? 'unknown'); + } else { + // Kotlin: parameter → [simple_identifier, user_type|nullable_type] + let found = false; + for (const child of param.namedChildren) { + if (child.type === 'user_type' || child.type === 'nullable_type' + || child.type === 'type_identifier' || child.type === 'predefined_type') { + const typeName = extractSimpleTypeName(child); + paramTypes.push(typeName ?? 'unknown'); + found = true; + break; + } + } + if (!found) paramTypes.push('unknown'); + } + if (!hasDefaultValue(param)) requiredCount++; + 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) { + if (goResult.type === 'parameter_list') { + // Multi-return: extract first parameter's type only (e.g. (*User, error) → *User) + const firstParam = goResult.firstNamedChild; + if (firstParam?.type === 'parameter_declaration') { + const typeNode = firstParam.childForFieldName('type'); + if (typeNode) returnType = typeNode.text; + } else if (firstParam) { + // Unnamed return types: (string, error) — first child is a bare type node + returnType = firstParam.text; + } + } else { + returnType = goResult.text; + } + } + + // 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; + } + } + + // C#: 'returns' field on method_declaration + if (!returnType) { + const csReturn = node.childForFieldName?.('returns'); + if (csReturn && csReturn.text !== 'void') { + returnType = csReturn.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; + } + } + } + + // Kotlin: fun getUser(): User — return type is a bare user_type child of + // function_declaration. The Kotlin grammar does NOT wrap it in type_annotation + // or return_type; it appears as a direct child after function_value_parameters. + // Note: Kotlin uses function_value_parameters (not a field), so we find it by type. + if (!returnType) { + let paramsEnd = -1; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child) continue; + if (child.type === 'function_value_parameters' || child.type === 'value_parameters') { + paramsEnd = child.endIndex; + } + if (paramsEnd >= 0 && child.type === 'user_type' && child.startIndex > paramsEnd) { + returnType = child.text; + break; + } + } + } + + if (isVariadic) parameterCount = undefined; + + // Only include parameterTypes when at least one type was successfully extracted. + // Use undefined (not []) to avoid empty array allocations for untyped parameters. + const hasTypes = paramTypes.length > 0 && paramTypes.some(t => t !== 'unknown'); + // Only set requiredParameterCount when it differs from total — saves memory on the common case. + const requiredParameterCount = (!isVariadic && requiredCount < (parameterCount ?? 0)) + ? requiredCount : undefined; + return { parameterCount, requiredParameterCount, parameterTypes: hasTypes ? paramTypes : undefined, returnType }; +}; + diff --git a/gitnexus/src/core/ingestion/call-analysis.ts b/gitnexus/src/core/ingestion/call-analysis.ts new file mode 100644 index 000000000..0506098a9 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-analysis.ts @@ -0,0 +1,539 @@ +import type { SyntaxNode } from './ast-helpers.js'; +import { CALL_ARGUMENT_LIST_TYPES } from './ast-helpers.js'; + +/** Node types representing call expressions across supported languages. */ +export const CALL_EXPRESSION_TYPES = new Set([ + 'call_expression', // TS/JS/C/C++/Go/Rust + 'method_invocation', // Java + 'member_call_expression', // PHP + 'nullsafe_member_call_expression', // PHP ?. + 'call', // Python/Ruby + 'invocation_expression', // C# +]); + +/** + * Hard limit on chain depth to prevent runaway recursion. + * For `a.b().c().d()`, the chain has depth 2 (b and c before d). + */ +export const MAX_CHAIN_DEPTH = 3; + +/** + * 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 + 'member_binding_expression', // C#: user?.Method() — null-conditional access +]); + +/** + * 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'; + } + + // 4b. Ruby call with receiver: obj.method + if (callNode.type === 'call' && callNode.childForFieldName('receiver')) { + 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() + 'super', // TS/JS/Java/Kotlin/Ruby super.method() + 'super_expression', // Kotlin wraps super in super_expression + 'base', // C# base.Method() + 'parent', // PHP parent::method() + 'constant', // Ruby CONSTANT.method() (uppercase identifiers) +]); + +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'); + } + + // Ruby: call node has 'receiver' field + if (!receiver && parent.type === 'call') { + receiver = parent.childForFieldName('receiver'); + } + + // PHP scoped_call_expression (parent::method(), self::method()): + // nameNode's direct parent IS the scoped_call_expression (name is a direct child) + if (!receiver && (parent.type === 'scoped_call_expression' || callNode.type === 'scoped_call_expression')) { + const scopedCall = parent.type === 'scoped_call_expression' ? parent : callNode; + receiver = scopedCall.childForFieldName('scope'); + // relative_scope wraps 'parent'/'self'/'static' — unwrap to get the keyword + if (receiver?.type === 'relative_scope') { + receiver = receiver.firstChild; + } + } + + // C# null-conditional: user?.Save() → conditional_access_expression wraps member_binding_expression + if (!receiver && parent.type === 'member_binding_expression') { + const condAccess = parent.parent; + if (condAccess?.type === 'conditional_access_expression') { + receiver = condAccess.firstNamedChild; + } + } + + // 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; + } + + // Python super().method(): receiver is a call node `super()` — extract the function name + if (receiver.type === 'call') { + const func = receiver.childForFieldName('function'); + if (func?.text === 'super') return 'super'; + } + + return undefined; +}; + +/** + * Extract the raw receiver AST node for a member call. + * Unlike extractReceiverName, this returns the receiver node regardless of its type — + * including call_expression / method_invocation nodes that appear in chained calls + * like `svc.getUser().save()`. + * + * Returns undefined when the call is not a member call or when no receiver node + * can be found (e.g. top-level free calls). + */ +export const extractReceiverNode = ( + nameNode: SyntaxNode, +): SyntaxNode | undefined => { + const parent = nameNode.parent; + if (!parent) return undefined; + + const callNode = parent.parent ?? parent; + + let receiver: SyntaxNode | null = null; + + receiver = parent.childForFieldName('object') + ?? parent.childForFieldName('value') + ?? parent.childForFieldName('operand') + ?? parent.childForFieldName('expression') + ?? parent.childForFieldName('argument'); + + if (!receiver && callNode.type === 'method_invocation') { + receiver = callNode.childForFieldName('object'); + } + + if (!receiver && (callNode.type === 'member_call_expression' || callNode.type === 'nullsafe_member_call_expression')) { + receiver = callNode.childForFieldName('object'); + } + + if (!receiver && parent.type === 'call') { + receiver = parent.childForFieldName('receiver'); + } + + if (!receiver && (parent.type === 'scoped_call_expression' || callNode.type === 'scoped_call_expression')) { + const scopedCall = parent.type === 'scoped_call_expression' ? parent : callNode; + receiver = scopedCall.childForFieldName('scope'); + if (receiver?.type === 'relative_scope') { + receiver = receiver.firstChild; + } + } + + if (!receiver && parent.type === 'member_binding_expression') { + const condAccess = parent.parent; + if (condAccess?.type === 'conditional_access_expression') { + receiver = condAccess.firstNamedChild; + } + } + + if (!receiver && parent.type === 'navigation_suffix') { + const navExpr = parent.parent; + if (navExpr?.type === 'navigation_expression') { + for (const child of navExpr.children) { + if (child.isNamed && child !== parent) { + receiver = child; + break; + } + } + } + } + + return receiver ?? undefined; +}; + +// ── Chained-call extraction ─────────────────────────────────────────────── + +/** Node types representing member/field access across languages. */ +const FIELD_ACCESS_NODE_TYPES = new Set([ + 'member_expression', // TS/JS + 'member_access_expression', // C# + 'selector_expression', // Go + 'field_expression', // Rust/C++ + 'field_access', // Java + 'attribute', // Python + 'navigation_expression', // Kotlin/Swift + 'member_binding_expression', // C# null-conditional (user?.Address) +]); + +/** One step in a mixed receiver chain. */ +export type MixedChainStep = { kind: 'field' | 'call'; name: string }; + +/** + * Walk a receiver AST node that is itself a call expression, accumulating the + * chain of intermediate method names up to MAX_CHAIN_DEPTH. + * + * For `svc.getUser().save()`, called with the receiver of `save` (getUser() call): + * returns { chain: ['getUser'], baseReceiverName: 'svc' } + * + * For `a.b().c().d()`, called with the receiver of `d` (c() call): + * returns { chain: ['b', 'c'], baseReceiverName: 'a' } + */ +export function extractCallChain( + receiverCallNode: SyntaxNode, +): { chain: string[]; baseReceiverName: string | undefined } | undefined { + const chain: string[] = []; + let current: SyntaxNode = receiverCallNode; + + while (CALL_EXPRESSION_TYPES.has(current.type) && chain.length < MAX_CHAIN_DEPTH) { + // Extract the method name from this call node. + const funcNode = current.childForFieldName?.('function') + ?? current.childForFieldName?.('name') + ?? current.childForFieldName?.('method'); // Ruby `call` node + let methodName: string | undefined; + let innerReceiver: SyntaxNode | null = null; + if (funcNode) { + // member_expression / attribute: last named child is the method identifier + methodName = funcNode.lastNamedChild?.text ?? funcNode.text; + } + // Kotlin/Swift: call_expression exposes callee as firstNamedChild, not a field. + // navigation_expression: method name is in navigation_suffix → simple_identifier. + if (!funcNode && current.type === 'call_expression') { + const callee = current.firstNamedChild; + if (callee?.type === 'navigation_expression') { + const suffix = callee.lastNamedChild; + if (suffix?.type === 'navigation_suffix') { + methodName = suffix.lastNamedChild?.text; + // The receiver is the part of navigation_expression before the suffix + for (let i = 0; i < callee.namedChildCount; i++) { + const child = callee.namedChild(i); + if (child && child.type !== 'navigation_suffix') { + innerReceiver = child; + break; + } + } + } + } + } + if (!methodName) break; + chain.unshift(methodName); // build chain outermost-last + + // Walk into the receiver of this call to continue the chain + if (!innerReceiver && funcNode) { + innerReceiver = funcNode.childForFieldName?.('object') + ?? funcNode.childForFieldName?.('value') + ?? funcNode.childForFieldName?.('operand') + ?? funcNode.childForFieldName?.('expression'); + } + // Java method_invocation: object field is on the call node + if (!innerReceiver && current.type === 'method_invocation') { + innerReceiver = current.childForFieldName?.('object'); + } + // PHP member_call_expression + if (!innerReceiver && (current.type === 'member_call_expression' || current.type === 'nullsafe_member_call_expression')) { + innerReceiver = current.childForFieldName?.('object'); + } + // Ruby `call` node: receiver field is on the call node itself + if (!innerReceiver && current.type === 'call') { + innerReceiver = current.childForFieldName?.('receiver'); + } + + if (!innerReceiver) break; + + if (CALL_EXPRESSION_TYPES.has(innerReceiver.type)) { + current = innerReceiver; // continue walking + } else { + // Reached a simple identifier — the base receiver + return { chain, baseReceiverName: innerReceiver.text || undefined }; + } + } + + return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined; +} + +/** + * Walk a receiver AST node that may interleave field accesses and method calls, + * building a unified chain of steps up to MAX_CHAIN_DEPTH. + * + * For `svc.getUser().address.save()`, called with the receiver of `save` + * (`svc.getUser().address`, a field access node): + * returns { chain: [{ kind:'call', name:'getUser' }, { kind:'field', name:'address' }], + * baseReceiverName: 'svc' } + * + * For `user.getAddress().city.getName()`, called with receiver of `getName` + * (`user.getAddress().city`): + * returns { chain: [{ kind:'call', name:'getAddress' }, { kind:'field', name:'city' }], + * baseReceiverName: 'user' } + * + * Pure field chains and pure call chains are special cases (all steps same kind). + */ +export function extractMixedChain( + receiverNode: SyntaxNode, +): { chain: MixedChainStep[]; baseReceiverName: string | undefined } | undefined { + const chain: MixedChainStep[] = []; + let current: SyntaxNode = receiverNode; + + while (chain.length < MAX_CHAIN_DEPTH) { + if (CALL_EXPRESSION_TYPES.has(current.type)) { + // ── Call expression: extract method name + inner receiver ──────────── + const funcNode = current.childForFieldName?.('function') + ?? current.childForFieldName?.('name') + ?? current.childForFieldName?.('method'); + let methodName: string | undefined; + let innerReceiver: SyntaxNode | null = null; + + if (funcNode) { + methodName = funcNode.lastNamedChild?.text ?? funcNode.text; + } + // Kotlin/Swift: call_expression → navigation_expression + if (!funcNode && current.type === 'call_expression') { + const callee = current.firstNamedChild; + if (callee?.type === 'navigation_expression') { + const suffix = callee.lastNamedChild; + if (suffix?.type === 'navigation_suffix') { + methodName = suffix.lastNamedChild?.text; + for (let i = 0; i < callee.namedChildCount; i++) { + const child = callee.namedChild(i); + if (child && child.type !== 'navigation_suffix') { innerReceiver = child; break; } + } + } + } + } + if (!methodName) break; + chain.unshift({ kind: 'call', name: methodName }); + + if (!innerReceiver && funcNode) { + innerReceiver = funcNode.childForFieldName?.('object') + ?? funcNode.childForFieldName?.('value') + ?? funcNode.childForFieldName?.('operand') + ?? funcNode.childForFieldName?.('argument') // C/C++ field_expression + ?? funcNode.childForFieldName?.('expression') + ?? null; + } + if (!innerReceiver && current.type === 'method_invocation') { + innerReceiver = current.childForFieldName?.('object') ?? null; + } + if (!innerReceiver && (current.type === 'member_call_expression' || current.type === 'nullsafe_member_call_expression')) { + innerReceiver = current.childForFieldName?.('object') ?? null; + } + if (!innerReceiver && current.type === 'call') { + innerReceiver = current.childForFieldName?.('receiver') ?? null; + } + if (!innerReceiver) break; + + if (CALL_EXPRESSION_TYPES.has(innerReceiver.type) || FIELD_ACCESS_NODE_TYPES.has(innerReceiver.type)) { + current = innerReceiver; + } else { + return { chain, baseReceiverName: innerReceiver.text || undefined }; + } + } else if (FIELD_ACCESS_NODE_TYPES.has(current.type)) { + // ── Field/member access: extract property name + inner object ───────── + let propertyName: string | undefined; + let innerObject: SyntaxNode | null = null; + + if (current.type === 'navigation_expression') { + for (const child of current.children ?? []) { + if (child.type === 'navigation_suffix') { + for (const sc of child.children ?? []) { + if (sc.isNamed && sc.type !== '.') { propertyName = sc.text; break; } + } + } else if (child.isNamed && !innerObject) { + innerObject = child; + } + } + } else if (current.type === 'attribute') { + innerObject = current.childForFieldName?.('object') ?? null; + propertyName = current.childForFieldName?.('attribute')?.text; + } else { + innerObject = current.childForFieldName?.('object') + ?? current.childForFieldName?.('value') + ?? current.childForFieldName?.('operand') + ?? current.childForFieldName?.('argument') // C/C++ field_expression + ?? current.childForFieldName?.('expression') + ?? null; + propertyName = (current.childForFieldName?.('property') + ?? current.childForFieldName?.('field') + ?? current.childForFieldName?.('name'))?.text; + } + + if (!propertyName) break; + chain.unshift({ kind: 'field', name: propertyName }); + + if (!innerObject) break; + + if (CALL_EXPRESSION_TYPES.has(innerObject.type) || FIELD_ACCESS_NODE_TYPES.has(innerObject.type)) { + current = innerObject; + } else { + return { chain, baseReceiverName: innerObject.text || undefined }; + } + } else { + // Simple identifier — this is the base receiver + return chain.length > 0 + ? { chain, baseReceiverName: current.text || undefined } + : undefined; + } + } + + return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined; +} diff --git a/gitnexus/src/core/ingestion/call-routing.ts b/gitnexus/src/core/ingestion/call-routing.ts index c86f18074..d5bd8e9b6 100644 --- a/gitnexus/src/core/ingestion/call-routing.ts +++ b/gitnexus/src/core/ingestion/call-routing.ts @@ -18,6 +18,12 @@ import { SupportedLanguages } from '../../config/supported-languages.js'; /** null = this call was not routed; fall through to default call handling */ export type CallRoutingResult = RubyCallRouting | null; +/** + * Per-language call router. + * IMPORTANT: Call-routed imports bypass preprocessImportPath(), so any router that + * returns an importPath MUST validate it independently (length cap, control-char + * rejection). See routeRubyCall for the reference implementation. + */ export type CallRouter = ( calledName: string, callNode: any, diff --git a/gitnexus/src/core/ingestion/entry-point-scoring.ts b/gitnexus/src/core/ingestion/entry-point-scoring.ts index 1da37a1df..58eb0d891 100644 --- a/gitnexus/src/core/ingestion/entry-point-scoring.ts +++ b/gitnexus/src/core/ingestion/entry-point-scoring.ts @@ -14,7 +14,7 @@ import { detectFrameworkFromPath } from './framework-detection.js'; import { SupportedLanguages } from '../../config/supported-languages.js'; // ============================================================================ -// NAME PATTERNS - All 11 supported languages +// NAME PATTERNS - All 13 supported languages // ============================================================================ /** diff --git a/gitnexus/src/core/ingestion/framework-detection.ts b/gitnexus/src/core/ingestion/framework-detection.ts index 3cf30421c..37263b8dd 100644 --- a/gitnexus/src/core/ingestion/framework-detection.ts +++ b/gitnexus/src/core/ingestion/framework-detection.ts @@ -236,8 +236,8 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null return { framework: 'go-mvc', entryPointMultiplier: 2.5, reason: 'go-controller' }; } - // Go main.go files (THE entry point) - if (p.endsWith('/main.go') || (p.includes('/cmd/') && p.endsWith('.go'))) { + // Go main.go files (THE entry point) — only match main.go, not arbitrary .go files under cmd/ + if (p.endsWith('/main.go')) { return { framework: 'go', entryPointMultiplier: 3.0, reason: 'go-main' }; } @@ -451,7 +451,7 @@ export const FRAMEWORK_AST_PATTERNS = { 'tokio': ['#[tokio::main]', '#[tokio::test]'], // C++ patterns (Qt, Boost) - 'qt': ['Q_OBJECT', 'Q_INVOKABLE', 'Q_PROPERTY', 'Q_SIGNAL', 'Q_SLOT', 'QWidget', 'QApplication'], + 'qt': ['Q_OBJECT', 'Q_INVOKABLE', 'Q_PROPERTY', 'Q_SIGNALS', 'Q_SLOTS', 'Q_SIGNAL', 'Q_SLOT', 'QWidget', 'QApplication'], // Swift/iOS 'uikit': ['viewDidLoad', 'viewWillAppear', 'viewDidAppear', 'UIViewController', '@IBOutlet', '@IBAction', '@objc'], diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 308d77f5f..eef7543da 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -11,14 +11,8 @@ import { loadImportConfigs } from './language-config.js'; import { buildSuffixIndex } from './resolvers/index.js'; import { callRouters } from './call-routing.js'; import type { ResolutionContext } from './resolution-context.js'; -import type { - SuffixIndex, - TsconfigPaths, - GoModuleConfig, - CSharpProjectConfig, - ComposerConfig -} from './resolvers/index.js'; -import { buildImportResolvers, namedBindingExtractors, preprocessImportPath } from './import-resolution.js'; +import type { SuffixIndex } from './resolvers/index.js'; +import { importResolvers, namedBindingExtractors, preprocessImportPath } from './import-resolution.js'; import type { ImportResult, ResolveCtx, NamedBinding } from './import-resolution.js'; // Re-export resolver types for consumers @@ -138,17 +132,39 @@ function applyImportResult( // If the same local name is imported from multiple files (e.g., Java static imports // of overloaded methods), remove the entry so resolution falls through to Tier 2a // import-scoped which sees all candidates and can apply arity narrowing. - if (namedBindings && namedImportMap && files.length === 1) { - const resolvedFile = files[0]; + if (namedBindings && namedImportMap) { if (!namedImportMap.has(filePath)) namedImportMap.set(filePath, new Map()); const fileBindings = namedImportMap.get(filePath)!; - for (const binding of namedBindings) { - const existing = fileBindings.get(binding.local); - if (existing && existing.sourcePath !== resolvedFile) { - // Ambiguous: same name imported from different files — remove to fall through - fileBindings.delete(binding.local); - } else { - fileBindings.set(binding.local, { sourcePath: resolvedFile, exportedName: binding.exported }); + + if (files.length === 1) { + const resolvedFile = files[0]; + for (const binding of namedBindings) { + const existing = fileBindings.get(binding.local); + if (existing && existing.sourcePath !== resolvedFile) { + fileBindings.delete(binding.local); + } else { + fileBindings.set(binding.local, { sourcePath: resolvedFile, exportedName: binding.exported }); + } + } + } else { + // Multi-file resolution (e.g., Rust `use crate::models::{User, Repo}`). + // Match each binding to a resolved file by comparing the lowercase binding name + // to the file's basename (without extension). If no match, skip the binding. + for (const binding of namedBindings) { + const lowerName = binding.exported.toLowerCase(); + const matchedFile = files.find(f => { + const base = f.replace(/\\/g, '/').split('/').pop() ?? ''; + const nameWithoutExt = base.substring(0, base.lastIndexOf('.')).toLowerCase(); + return nameWithoutExt === lowerName; + }); + if (matchedFile) { + const existing = fileBindings.get(binding.local); + if (existing && existing.sourcePath !== matchedFile) { + fileBindings.delete(binding.local); + } else { + fileBindings.set(binding.local, { sourcePath: matchedFile, exportedName: binding.exported }); + } + } } } } @@ -188,8 +204,7 @@ export const processImports = async ( // Load language-specific configs once before the file loop const configs = await loadImportConfigs(repoRoot || ''); - const importResolvers = buildImportResolvers(configs); - const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache }; + const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache, configs }; const { addImportEdge, addImportGraphEdge, getResolvedCount } = createImportEdgeHelpers(graph, importMap); for (let i = 0; i < files.length; i++) { @@ -264,6 +279,7 @@ export const processImports = async ( } const rawImportPath = preprocessImportPath(sourceNode.text, captureMap['import'], language); + if (!rawImportPath) return; totalImportsFound++; const result = importResolvers[language](rawImportPath, file.path, resolveCtx); @@ -325,8 +341,7 @@ export const processImportsFromExtracted = async ( let totalImportsFound = 0; const configs = await loadImportConfigs(repoRoot || ''); - const importResolvers = buildImportResolvers(configs); - const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache }; + const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache, configs }; const { addImportEdge, addImportGraphEdge, getResolvedCount } = createImportEdgeHelpers(graph, importMap); // Group by file for progress reporting (users see file count, not import count) diff --git a/gitnexus/src/core/ingestion/import-resolution.ts b/gitnexus/src/core/ingestion/import-resolution.ts index c29571341..1dc30d205 100644 --- a/gitnexus/src/core/ingestion/import-resolution.ts +++ b/gitnexus/src/core/ingestion/import-resolution.ts @@ -8,7 +8,7 @@ * Follows the existing ExportChecker / CallRouter pattern: * - Function aliases (not interfaces) to avoid megamorphic inline-cache issues * - `satisfies Record` for compile-time exhaustiveness - * - Factory function that closes over per-language configs at build time + * - Const dispatch table — configs are accessed via ctx.configs at call time */ import { SupportedLanguages } from '../../config/supported-languages.js'; @@ -45,6 +45,7 @@ import { extractCsharpNamedBindings, extractJavaNamedBindings, } from './named-binding-extraction.js'; +import type { ImportResolutionContext } from './import-processor.js'; // ============================================================================ // Types @@ -61,6 +62,20 @@ export type ImportResult = | { kind: 'package'; files: string[]; dirSuffix: string } | null; +/** Bundled language-specific configs loaded once per ingestion run. */ +export interface ImportConfigs { + tsconfigPaths: TsconfigPaths | null; + goModule: GoModuleConfig | null; + composerConfig: ComposerConfig | null; + swiftPackageConfig: SwiftPackageConfig | null; + csharpConfigs: CSharpProjectConfig[]; +} + +/** Full context for import resolution: file lookups + language configs. */ +export interface ResolveCtx extends ImportResolutionContext { + configs: ImportConfigs; +} + /** Per-language import resolver -- function alias matching ExportChecker/CallRouter pattern. */ export type ImportResolverFn = ( rawImportPath: string, @@ -74,24 +89,6 @@ export interface NamedBinding { local: string; exported: string } /** Per-language named binding extractor -- optional (returns undefined if language has no named imports). */ type NamedBindingExtractorFn = (importNode: SyntaxNode) => NamedBinding[] | undefined; -/** Bundled language-specific configs loaded once per ingestion run. */ -export interface ImportConfigs { - tsconfigPaths: TsconfigPaths | null; - goModule: GoModuleConfig | null; - composerConfig: ComposerConfig | null; - swiftPackageConfig: SwiftPackageConfig | null; - csharpConfigs: CSharpProjectConfig[]; -} - -/** Context for import path resolution — narrowed from ImportResolutionContext with non-null index. */ -export interface ResolveCtx { - allFilePaths: Set; - allFileList: string[]; - normalizedFileList: string[]; - index: SuffixIndex; - resolveCache: Map; -} - // ============================================================================ // Import path preprocessing // ============================================================================ @@ -105,10 +102,10 @@ export function preprocessImportPath( sourceText: string, importNode: SyntaxNode, language: SupportedLanguages, -): string { +): string | null { const cleaned = sourceText.replace(/['"<>]/g, ''); // Defense-in-depth: reject null bytes and control characters (matches Ruby call-routing pattern) - if (!cleaned || cleaned.length > 2048 || /[\x00-\x1f]/.test(cleaned)) return ''; + if (!cleaned || cleaned.length > 2048 || /[\x00-\x1f]/.test(cleaned)) return null; if (language === SupportedLanguages.Kotlin) { return appendKotlinWildcard(cleaned, importNode); } @@ -128,7 +125,6 @@ function resolveStandard( filePath: string, ctx: ResolveCtx, language: SupportedLanguages, - tsconfigPaths: TsconfigPaths | null, ): ImportResult { const resolvedPath = resolveImportPath( filePath, @@ -138,7 +134,7 @@ function resolveStandard( ctx.normalizedFileList, ctx.resolveCache, language, - tsconfigPaths, + ctx.configs.tsconfigPaths, ctx.index, ); return resolvedPath ? { kind: 'files', files: [resolvedPath] } : null; @@ -149,7 +145,6 @@ function resolveJavaImport( rawImportPath: string, filePath: string, ctx: ResolveCtx, - tsconfigPaths: TsconfigPaths | null, ): ImportResult { if (rawImportPath.endsWith('.*')) { const matchedFiles = resolveJvmWildcard(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index); @@ -158,7 +153,7 @@ function resolveJavaImport( const memberResolved = resolveJvmMemberImport(rawImportPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index); if (memberResolved) return { kind: 'files', files: [memberResolved] }; } - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Java, tsconfigPaths); + return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Java); } /** @@ -169,7 +164,6 @@ function resolveKotlinImport( rawImportPath: string, filePath: string, ctx: ResolveCtx, - tsconfigPaths: TsconfigPaths | null, ): ImportResult { if (rawImportPath.endsWith('.*')) { const matchedFiles = resolveJvmWildcard(rawImportPath, ctx.normalizedFileList, ctx.allFileList, KOTLIN_EXTENSIONS, ctx.index); @@ -200,7 +194,7 @@ function resolveKotlinImport( if (dirFiles.length > 0) return { kind: 'files', files: dirFiles }; } } - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Kotlin, tsconfigPaths); + return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Kotlin); } /** Go: package-level imports via go.mod module path. */ @@ -208,9 +202,8 @@ function resolveGoImport( rawImportPath: string, filePath: string, ctx: ResolveCtx, - goModule: GoModuleConfig | null, - tsconfigPaths: TsconfigPaths | null, ): ImportResult { + const goModule = ctx.configs.goModule; if (goModule && rawImportPath.startsWith(goModule.modulePath)) { const pkgSuffix = resolveGoPackageDir(rawImportPath, goModule); if (pkgSuffix) { @@ -221,16 +214,16 @@ function resolveGoImport( } // Fall through if no files found (package might be external) } - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Go, tsconfigPaths); + return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Go); } -/** C#: namespace-based resolution via .csproj configs. */ +/** C#: namespace-based resolution via .csproj configs, with suffix-match fallback. */ function resolveCSharpImportDispatch( rawImportPath: string, - _filePath: string, + filePath: string, ctx: ResolveCtx, - csharpConfigs: CSharpProjectConfig[], ): ImportResult { + const csharpConfigs = ctx.configs.csharpConfigs; if (csharpConfigs.length > 0) { const resolvedFiles = resolveCSharpImportHelper(rawImportPath, csharpConfigs, ctx.normalizedFileList, ctx.allFileList, ctx.index); if (resolvedFiles.length > 1) { @@ -241,7 +234,7 @@ function resolveCSharpImportDispatch( } if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles }; } - return null; + return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.CSharp); } /** PHP: namespace-based resolution via composer.json PSR-4. */ @@ -249,9 +242,8 @@ function resolvePhpImportDispatch( rawImportPath: string, _filePath: string, ctx: ResolveCtx, - composerConfig: ComposerConfig | null, ): ImportResult { - const resolved = resolvePhpImportHelper(rawImportPath, composerConfig, ctx.allFilePaths, ctx.normalizedFileList, ctx.allFileList, ctx.index); + const resolved = resolvePhpImportHelper(rawImportPath, ctx.configs.composerConfig, ctx.allFilePaths, ctx.normalizedFileList, ctx.allFileList, ctx.index); return resolved ? { kind: 'files', files: [resolved] } : null; } @@ -260,8 +252,8 @@ function resolveSwiftImportDispatch( rawImportPath: string, _filePath: string, ctx: ResolveCtx, - swiftPackageConfig: SwiftPackageConfig | null, ): ImportResult { + const swiftPackageConfig = ctx.configs.swiftPackageConfig; if (swiftPackageConfig) { const targetDir = swiftPackageConfig.targets.get(rawImportPath); if (targetDir) { @@ -286,12 +278,11 @@ function resolvePythonImportDispatch( rawImportPath: string, filePath: string, ctx: ResolveCtx, - tsconfigPaths: TsconfigPaths | null, ): ImportResult { const resolved = resolvePythonImportHelper(filePath, rawImportPath, ctx.allFilePaths); if (resolved) return { kind: 'files', files: [resolved] }; if (rawImportPath.startsWith('.')) return null; // relative but unresolved -- don't suffix-match - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Python, tsconfigPaths); + return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Python); } /** Ruby: require / require_relative. */ @@ -304,13 +295,13 @@ function resolveRubyImportDispatch( return resolved ? { kind: 'files', files: [resolved] } : null; } -/** Rust: expand top-level grouped imports: use {crate::a, crate::b}. */ +/** Rust: expand grouped imports: use {crate::a, crate::b} and use crate::models::{User, Repo}. */ function resolveRustImportDispatch( rawImportPath: string, filePath: string, ctx: ResolveCtx, - tsconfigPaths: TsconfigPaths | null, ): ImportResult { + // Top-level grouped: use {crate::a, crate::b} if (rawImportPath.startsWith('{') && rawImportPath.endsWith('}')) { const inner = rawImportPath.slice(1, -1); const parts = inner.split(',').map(p => p.trim()).filter(Boolean); @@ -321,7 +312,27 @@ function resolveRustImportDispatch( } return resolved.length > 0 ? { kind: 'files', files: resolved } : null; } - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Rust, tsconfigPaths); + + // Scoped grouped: use crate::models::{User, Repo} + const braceIdx = rawImportPath.indexOf('::{'); + if (braceIdx !== -1 && rawImportPath.endsWith('}')) { + const pathPrefix = rawImportPath.substring(0, braceIdx); + const braceContent = rawImportPath.substring(braceIdx + 3, rawImportPath.length - 1); + const items = braceContent.split(',').map(s => s.trim()).filter(Boolean); + const resolved: string[] = []; + for (const item of items) { + // Handle `use crate::models::{User, Repo as R}` — strip alias for resolution + const itemName = item.includes(' as ') ? item.split(' as ')[0].trim() : item; + const r = resolveRustImportHelper(filePath, `${pathPrefix}::${itemName}`, ctx.allFilePaths); + if (r) resolved.push(r); + } + if (resolved.length > 0) return { kind: 'files', files: resolved }; + // Fallback: resolve the prefix path itself (e.g. crate::models -> models.rs) + const prefixResult = resolveRustImportHelper(filePath, pathPrefix, ctx.allFilePaths); + if (prefixResult) return { kind: 'files', files: [prefixResult] }; + } + + return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Rust); } // ============================================================================ @@ -329,29 +340,26 @@ function resolveRustImportDispatch( // ============================================================================ /** - * Build the import resolver dispatch table with per-language configs closed over at construction time. - * Each resolver function encapsulates the full resolution flow for its language, including + * Per-language import resolver dispatch table. + * Configs are accessed via ctx.configs at call time — no factory closure needed. + * Each resolver encapsulates the full resolution flow for its language, including * fallthrough to standard resolution where appropriate. */ -export function buildImportResolvers(configs: ImportConfigs): Record { - const { tsconfigPaths, goModule, composerConfig, swiftPackageConfig, csharpConfigs } = configs; - - return { - [SupportedLanguages.JavaScript]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.JavaScript, tsconfigPaths), - [SupportedLanguages.TypeScript]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.TypeScript, tsconfigPaths), - [SupportedLanguages.Python]: (raw, fp, ctx) => resolvePythonImportDispatch(raw, fp, ctx, tsconfigPaths), - [SupportedLanguages.Java]: (raw, fp, ctx) => resolveJavaImport(raw, fp, ctx, tsconfigPaths), - [SupportedLanguages.C]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.C, tsconfigPaths), - [SupportedLanguages.CPlusPlus]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.CPlusPlus, tsconfigPaths), - [SupportedLanguages.CSharp]: (raw, fp, ctx) => resolveCSharpImportDispatch(raw, fp, ctx, csharpConfigs), - [SupportedLanguages.Go]: (raw, fp, ctx) => resolveGoImport(raw, fp, ctx, goModule, tsconfigPaths), - [SupportedLanguages.Ruby]: (raw, fp, ctx) => resolveRubyImportDispatch(raw, fp, ctx), - [SupportedLanguages.Rust]: (raw, fp, ctx) => resolveRustImportDispatch(raw, fp, ctx, tsconfigPaths), - [SupportedLanguages.PHP]: (raw, fp, ctx) => resolvePhpImportDispatch(raw, fp, ctx, composerConfig), - [SupportedLanguages.Kotlin]: (raw, fp, ctx) => resolveKotlinImport(raw, fp, ctx, tsconfigPaths), - [SupportedLanguages.Swift]: (raw, fp, ctx) => resolveSwiftImportDispatch(raw, fp, ctx, swiftPackageConfig), - } satisfies Record; -} +export const importResolvers = { + [SupportedLanguages.JavaScript]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.JavaScript), + [SupportedLanguages.TypeScript]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.TypeScript), + [SupportedLanguages.Python]: (raw, fp, ctx) => resolvePythonImportDispatch(raw, fp, ctx), + [SupportedLanguages.Java]: (raw, fp, ctx) => resolveJavaImport(raw, fp, ctx), + [SupportedLanguages.C]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.C), + [SupportedLanguages.CPlusPlus]: (raw, fp, ctx) => resolveStandard(raw, fp, ctx, SupportedLanguages.CPlusPlus), + [SupportedLanguages.CSharp]: (raw, fp, ctx) => resolveCSharpImportDispatch(raw, fp, ctx), + [SupportedLanguages.Go]: (raw, fp, ctx) => resolveGoImport(raw, fp, ctx), + [SupportedLanguages.Ruby]: (raw, fp, ctx) => resolveRubyImportDispatch(raw, fp, ctx), + [SupportedLanguages.Rust]: (raw, fp, ctx) => resolveRustImportDispatch(raw, fp, ctx), + [SupportedLanguages.PHP]: (raw, fp, ctx) => resolvePhpImportDispatch(raw, fp, ctx), + [SupportedLanguages.Kotlin]: (raw, fp, ctx) => resolveKotlinImport(raw, fp, ctx), + [SupportedLanguages.Swift]: (raw, fp, ctx) => resolveSwiftImportDispatch(raw, fp, ctx), +} satisfies Record; /** * Per-language named binding extractor dispatch table. diff --git a/gitnexus/src/core/ingestion/named-binding-extraction.ts b/gitnexus/src/core/ingestion/named-binding-extraction.ts index 172faff13..760155bde 100644 --- a/gitnexus/src/core/ingestion/named-binding-extraction.ts +++ b/gitnexus/src/core/ingestion/named-binding-extraction.ts @@ -253,6 +253,13 @@ export function extractPhpNamedBindings(importNode: SyntaxNode): NamedBinding[] // namespace_use_declaration > namespace_use_group > namespace_use_clause* (grouped) if (importNode.type !== 'namespace_use_declaration') return undefined; + // Skip 'use function' and 'use const' declarations — these import callables/constants, + // not class types, and should not be added to namedImportMap as type bindings. + const useTypeNode = importNode.childForFieldName?.('type'); + if (useTypeNode && (useTypeNode.text === 'function' || useTypeNode.text === 'const')) { + return undefined; + } + const bindings: NamedBinding[] = []; // Collect all clauses — from direct children AND from namespace_use_group diff --git a/gitnexus/src/core/ingestion/resolvers/jvm.ts b/gitnexus/src/core/ingestion/resolvers/jvm.ts index 1b86b0e62..bd0f2f935 100644 --- a/gitnexus/src/core/ingestion/resolvers/jvm.ts +++ b/gitnexus/src/core/ingestion/resolvers/jvm.ts @@ -4,6 +4,7 @@ */ import type { SuffixIndex } from './utils.js'; +import type { SyntaxNode } from '../utils.js'; /** Kotlin file extensions for JVM resolver reuse */ export const KOTLIN_EXTENSIONS: readonly string[] = ['.kt', '.kts']; @@ -12,7 +13,7 @@ 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 => { +export const appendKotlinWildcard = (importPath: string, importNode: SyntaxNode): string => { for (let i = 0; i < importNode.childCount; i++) { if (importNode.child(i)?.type === 'wildcard_import') { return importPath.endsWith('.*') ? importPath : `${importPath}.*`; diff --git a/gitnexus/src/core/ingestion/resolvers/utils.ts b/gitnexus/src/core/ingestion/resolvers/utils.ts index 12a44d5d2..b49eee8f8 100644 --- a/gitnexus/src/core/ingestion/resolvers/utils.ts +++ b/gitnexus/src/core/ingestion/resolvers/utils.ts @@ -65,11 +65,13 @@ export interface SuffixIndex { getFilesInDir(dirSuffix: string, extension: string): string[]; } +const FROZEN_EMPTY_ARRAY: string[] = Object.freeze([]) as string[]; + /** Sentinel index that returns no results. Used to release memory after import resolution. */ export const EMPTY_INDEX: SuffixIndex = Object.freeze({ get: () => undefined, getInsensitive: () => undefined, - getFilesInDir: () => [], + getFilesInDir: () => FROZEN_EMPTY_ARRAY, }); export function buildSuffixIndex(normalizedFileList: string[], allFileList: string[]): SuffixIndex { diff --git a/gitnexus/src/core/ingestion/type-extractors/csharp.ts b/gitnexus/src/core/ingestion/type-extractors/csharp.ts index 64468b1e5..84978638a 100644 --- a/gitnexus/src/core/ingestion/type-extractors/csharp.ts +++ b/gitnexus/src/core/ingestion/type-extractors/csharp.ts @@ -1,6 +1,7 @@ import type { SyntaxNode } from '../utils.js'; import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor, PatternBindingExtractor, LiteralTypeInferrer } from './types.js'; -import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js'; +import { extractSimpleTypeName, extractVarName, unwrapAwait, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js'; +import { findChild } from '../resolvers/utils.js'; /** Known container property accessors that operate on the container itself (e.g., dict.Keys, dict.Values) */ const KNOWN_CONTAINER_PROPS: ReadonlySet = new Set(['Keys', 'Values']); @@ -50,8 +51,8 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map, IEnumerable, Dictionary // C# uses generic_name (not generic_type) if (typeNode.type === 'generic_name') { - const argList = findChildByType(typeNode, 'type_argument_list'); + const argList = findChild(typeNode, 'type_argument_list'); if (argList && argList.namedChildCount >= 1) { if (pos === 'first') { const firstArg = argList.namedChild(0); diff --git a/gitnexus/src/core/ingestion/type-extractors/go.ts b/gitnexus/src/core/ingestion/type-extractors/go.ts index 0b81301bb..138899a8d 100644 --- a/gitnexus/src/core/ingestion/type-extractors/go.ts +++ b/gitnexus/src/core/ingestion/type-extractors/go.ts @@ -1,6 +1,6 @@ import type { SyntaxNode } from '../utils.js'; import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor } from './types.js'; -import { extractSimpleTypeName, extractVarName, extractElementTypeFromString, extractGenericTypeArgs, findChildByType, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js'; +import { extractSimpleTypeName, extractVarName, extractElementTypeFromString, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'var_declaration', diff --git a/gitnexus/src/core/ingestion/type-extractors/index.ts b/gitnexus/src/core/ingestion/type-extractors/index.ts index b8705fc2e..adcf8fd9c 100644 --- a/gitnexus/src/core/ingestion/type-extractors/index.ts +++ b/gitnexus/src/core/ingestion/type-extractors/index.ts @@ -47,6 +47,5 @@ export { extractSimpleTypeName, extractGenericTypeArgs, extractVarName, - findChildByType, extractRubyConstructorAssignment } from './shared.js'; diff --git a/gitnexus/src/core/ingestion/type-extractors/jvm.ts b/gitnexus/src/core/ingestion/type-extractors/jvm.ts index 528afa080..f184e3330 100644 --- a/gitnexus/src/core/ingestion/type-extractors/jvm.ts +++ b/gitnexus/src/core/ingestion/type-extractors/jvm.ts @@ -1,6 +1,7 @@ import type { SyntaxNode } from '../utils.js'; import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ForLoopExtractor, PendingAssignmentExtractor, PatternBindingExtractor, LiteralTypeInferrer, ConstructorTypeDetector } from './types.js'; -import { extractSimpleTypeName, extractVarName, findChildByType, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js'; +import { extractSimpleTypeName, extractVarName, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js'; +import { findChild } from '../resolvers/utils.js'; // ── Java ────────────────────────────────────────────────────────────────── @@ -73,7 +74,7 @@ const scanJavaConstructorBinding: ConstructorBindingScanner = (node) => { const typeNode = node.childForFieldName('type'); if (!typeNode) return undefined; if (typeNode.text !== 'var') return undefined; - const declarator = findChildByType(node, 'variable_declarator'); + const declarator = findChild(node, 'variable_declarator'); if (!declarator) return undefined; const nameNode = declarator.childForFieldName('name'); const value = declarator.childForFieldName('value'); @@ -325,11 +326,11 @@ const KOTLIN_DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 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'); + const varDecl = findChild(node, 'variable_declaration'); if (varDecl) { - const nameNode = findChildByType(varDecl, 'simple_identifier'); - const typeNode = findChildByType(varDecl, 'user_type') - ?? findChildByType(varDecl, 'nullable_type'); + const nameNode = findChild(varDecl, 'simple_identifier'); + const typeNode = findChild(varDecl, 'user_type') + ?? findChild(varDecl, 'nullable_type'); if (!nameNode || !typeNode) return; const varName = extractVarName(nameNode); const typeName = extractSimpleTypeName(typeNode); @@ -338,17 +339,17 @@ const extractKotlinDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: M } // Fallback: try direct fields const nameNode = node.childForFieldName('name') - ?? findChildByType(node, 'simple_identifier'); + ?? findChild(node, 'simple_identifier'); const typeNode = node.childForFieldName('type') - ?? findChildByType(node, 'user_type'); + ?? findChild(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'); + const nameNode = findChild(node, 'simple_identifier'); + const typeNode = findChild(node, 'user_type'); if (nameNode && typeNode) { const varName = extractVarName(nameNode); const typeName = extractSimpleTypeName(typeNode); @@ -360,7 +361,7 @@ const extractKotlinDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: M /** Kotlin: parameter / formal_parameter → type name. * Kotlin's tree-sitter grammar uses positional children (simple_identifier, user_type) * rather than named fields (name, type) on `parameter` nodes, so we fall back to - * findChildByType when childForFieldName returns null. */ + * findChild when childForFieldName returns null. */ const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map): void => { let nameNode: SyntaxNode | null = null; let typeNode: SyntaxNode | null = null; @@ -374,9 +375,9 @@ const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map { if (node.type !== 'property_declaration') return undefined; const value = node.childForFieldName('value') - ?? findChildByType(node, 'call_expression'); + ?? findChild(node, 'call_expression'); if (!value || value.type !== 'call_expression') return undefined; const callee = value.firstNamedChild; if (!callee || callee.type !== 'simple_identifier') return undefined; @@ -403,16 +404,16 @@ const findKotlinConstructorCallee = (node: SyntaxNode, classNames: ClassNameLook * against classNames (which may include cross-file SymbolTable lookups). */ const extractKotlinInitializer: InitializerExtractor = (node: SyntaxNode, env: Map, classNames: ClassNameLookup): void => { // Skip if there's an explicit type annotation — Tier 0 already handled it - const varDecl = findChildByType(node, 'variable_declaration'); - if (varDecl && findChildByType(varDecl, 'user_type')) return; + const varDecl = findChild(node, 'variable_declaration'); + if (varDecl && findChild(varDecl, 'user_type')) return; const calleeName = findKotlinConstructorCallee(node, classNames); if (!calleeName) return; // Extract the variable name from the variable_declaration inside property_declaration const nameNode = varDecl - ? findChildByType(varDecl, 'simple_identifier') - : findChildByType(node, 'simple_identifier'); + ? findChild(varDecl, 'simple_identifier') + : findChild(node, 'simple_identifier'); if (!nameNode) return; const varName = extractVarName(nameNode); @@ -430,10 +431,10 @@ const detectKotlinConstructorType: ConstructorTypeDetector = (node, classNames) /** Kotlin: val x = User(...) — constructor binding for property_declaration with call_expression */ const scanKotlinConstructorBinding: ConstructorBindingScanner = (node) => { if (node.type !== 'property_declaration') return undefined; - const varDecl = findChildByType(node, 'variable_declaration'); + const varDecl = findChild(node, 'variable_declaration'); if (!varDecl) return undefined; - if (findChildByType(varDecl, 'user_type')) return undefined; - const callExpr = findChildByType(node, 'call_expression'); + if (findChild(varDecl, 'user_type')) return undefined; + const callExpr = findChild(node, 'call_expression'); if (!callExpr) return undefined; const callee = callExpr.firstNamedChild; if (!callee) return undefined; @@ -452,7 +453,7 @@ const scanKotlinConstructorBinding: ConstructorBindingScanner = (node) => { } } if (!calleeName) return undefined; - const nameNode = findChildByType(varDecl, 'simple_identifier'); + const nameNode = findChild(varDecl, 'simple_identifier'); if (!nameNode) return undefined; return { varName: nameNode.text, calleeName }; }; @@ -466,7 +467,7 @@ const KOTLIN_FOR_LOOP_NODE_TYPES: ReadonlySet = new Set([ * Handles the type_projection wrapper that Kotlin uses for generic type arguments. */ const extractKotlinElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => { if (typeNode.type === 'user_type') { - const argsNode = findChildByType(typeNode, 'type_arguments'); + const argsNode = findChild(typeNode, 'type_arguments'); if (argsNode && argsNode.namedChildCount >= 1) { const targetArg = pos === 'first' ? argsNode.namedChild(0) @@ -488,14 +489,14 @@ const findKotlinParamElementType = (iterableName: string, startNode: SyntaxNode, let current: SyntaxNode | null = startNode.parent; while (current) { if (current.type === 'function_declaration') { - const paramsNode = findChildByType(current, 'function_value_parameters'); + const paramsNode = findChild(current, 'function_value_parameters'); if (paramsNode) { for (let i = 0; i < paramsNode.namedChildCount; i++) { const param = paramsNode.namedChild(i); if (!param || param.type !== 'parameter') continue; - const nameNode = findChildByType(param, 'simple_identifier'); + const nameNode = findChild(param, 'simple_identifier'); if (nameNode?.text !== iterableName) continue; - const typeNode = findChildByType(param, 'user_type'); + const typeNode = findChild(param, 'user_type'); if (typeNode) return extractKotlinElementTypeFromTypeNode(typeNode, pos); } } @@ -510,15 +511,15 @@ const findKotlinParamElementType = (iterableName: string, startNode: SyntaxNode, * Tier 1c: for `for (user in users)` without annotation, resolves from iterable. */ const extractKotlinForLoopBinding: ForLoopExtractor = (node, ctx): void => { const { scopeEnv, declarationTypeNodes, scope, returnTypeLookup } = ctx; - const varDecl = findChildByType(node, 'variable_declaration'); + const varDecl = findChild(node, 'variable_declaration'); if (!varDecl) return; - const nameNode = findChildByType(varDecl, 'simple_identifier'); + const nameNode = findChild(varDecl, 'simple_identifier'); if (!nameNode) return; const varName = extractVarName(nameNode); if (!varName) return; // Explicit type annotation (existing behavior): for (user: User in users) - const typeNode = findChildByType(varDecl, 'user_type'); + const typeNode = findChild(varDecl, 'user_type'); if (typeNode) { const typeName = extractSimpleTypeName(typeNode); if (typeName) scopeEnv.set(varName, typeName); @@ -544,9 +545,9 @@ const extractKotlinForLoopBinding: ForLoopExtractor = (node, ctx): void => { if (child.type === 'navigation_expression') { // data.keys → navigation_expression > simple_identifier(data) + navigation_suffix > simple_identifier(keys) const obj = child.firstNamedChild; - const suffix = findChildByType(child, 'navigation_suffix'); - const prop = suffix ? findChildByType(suffix, 'simple_identifier') : null; - const hasCallSuffix = suffix ? findChildByType(suffix, 'call_suffix') !== null : false; + const suffix = findChild(child, 'navigation_suffix'); + const prop = suffix ? findChild(suffix, 'simple_identifier') : null; + const hasCallSuffix = suffix ? findChild(suffix, 'call_suffix') !== null : false; // Always try object as iterable + property as method first (handles data.values, data.keys). // For bare property access without call_suffix, also save property as fallback // (handles this.users, repo.items where the property IS the iterable). @@ -563,9 +564,9 @@ const extractKotlinForLoopBinding: ForLoopExtractor = (node, ctx): void => { if (callee?.type === 'navigation_expression') { const obj = callee.firstNamedChild; if (obj?.type === 'simple_identifier') iterableName = obj.text; - const suffix = findChildByType(callee, 'navigation_suffix'); + const suffix = findChild(callee, 'navigation_suffix'); if (suffix) { - const prop = findChildByType(suffix, 'simple_identifier'); + const prop = findChild(suffix, 'simple_identifier'); if (prop) methodName = prop.text; } } else if (callee?.type === 'simple_identifier') { @@ -607,7 +608,7 @@ const extractKotlinForLoopBinding: ForLoopExtractor = (node, ctx): void => { const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => { if (node.type === 'property_declaration') { // Find the variable name from variable_declaration child - const varDecl = findChildByType(node, 'variable_declaration'); + const varDecl = findChild(node, 'variable_declaration'); if (!varDecl) return undefined; const nameNode = varDecl.firstNamedChild; if (!nameNode || nameNode.type !== 'simple_identifier') return undefined; @@ -653,7 +654,7 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE if (node.type === 'variable_declaration') { // variable_declaration directly inside functions: simple_identifier children - const nameNode = findChildByType(node, 'simple_identifier'); + const nameNode = findChild(node, 'simple_identifier'); if (!nameNode) return undefined; const lhs = nameNode.text; if (scopeEnv.has(lhs)) return undefined; diff --git a/gitnexus/src/core/ingestion/type-extractors/shared.ts b/gitnexus/src/core/ingestion/type-extractors/shared.ts index 12a0f4f80..ff402adce 100644 --- a/gitnexus/src/core/ingestion/type-extractors/shared.ts +++ b/gitnexus/src/core/ingestion/type-extractors/shared.ts @@ -497,15 +497,6 @@ export const extractCalleeName = (callNode: SyntaxNode): string | undefined => { return extractSimpleTypeName(func); }; -/** 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; -}; - // Internal helper: extract the first comma-separated argument from a string, // respecting nested angle-bracket and square-bracket depth. function extractFirstArg(args: string): string { diff --git a/gitnexus/src/core/ingestion/type-extractors/swift.ts b/gitnexus/src/core/ingestion/type-extractors/swift.ts index 3d3dbe729..e142497b6 100644 --- a/gitnexus/src/core/ingestion/type-extractors/swift.ts +++ b/gitnexus/src/core/ingestion/type-extractors/swift.ts @@ -1,6 +1,7 @@ import type { SyntaxNode } from '../utils.js'; import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner } from './types.js'; -import { extractSimpleTypeName, extractVarName, findChildByType, hasTypeAnnotation } from './shared.js'; +import { extractSimpleTypeName, extractVarName, hasTypeAnnotation } from './shared.js'; +import { findChild } from '../resolvers/utils.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'property_declaration', @@ -10,9 +11,9 @@ const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { // Swift property_declaration has pattern and type_annotation const pattern = node.childForFieldName('pattern') - ?? findChildByType(node, 'pattern'); + ?? findChild(node, 'pattern'); const typeAnnotation = node.childForFieldName('type') - ?? findChildByType(node, 'type_annotation'); + ?? findChild(node, 'type_annotation'); if (!pattern || !typeAnnotation) return; const varName = extractVarName(pattern) ?? pattern.text; const typeName = extractSimpleTypeName(typeAnnotation); @@ -45,14 +46,14 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map, classNames: ClassNameLookup): void => { if (node.type !== 'property_declaration') return; // Skip if has type annotation — extractDeclaration handled it - if (node.childForFieldName('type') || findChildByType(node, 'type_annotation')) return; + if (node.childForFieldName('type') || findChild(node, 'type_annotation')) return; // Find pattern (variable name) - const pattern = node.childForFieldName('pattern') ?? findChildByType(node, 'pattern'); + const pattern = node.childForFieldName('pattern') ?? findChild(node, 'pattern'); if (!pattern) return; const varName = extractVarName(pattern) ?? pattern.text; if (!varName || env.has(varName)) return; // Find call_expression in the value - const callExpr = findChildByType(node, 'call_expression'); + const callExpr = findChild(node, 'call_expression'); if (!callExpr) return; const callee = callExpr.firstNamedChild; if (!callee) return; diff --git a/gitnexus/src/core/ingestion/utils.ts b/gitnexus/src/core/ingestion/utils.ts index 7e6b16c9a..510321d4f 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -1,100 +1,4 @@ -import type Parser from 'tree-sitter'; import { SupportedLanguages } from '../../config/supported-languages.js'; -import type { NodeLabel } from '../graph/types.js'; -import { generateId } from '../../lib/utils.js'; -import { extractSimpleTypeName } from './type-extractors/shared.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. - * Used to extract the definition node from a capture map. - */ -export const DEFINITION_CAPTURE_KEYS = [ - 'definition.function', - 'definition.class', - 'definition.interface', - 'definition.method', - 'definition.struct', - 'definition.enum', - 'definition.namespace', - 'definition.module', - 'definition.trait', - 'definition.impl', - 'definition.type', - 'definition.const', - 'definition.static', - 'definition.typedef', - 'definition.macro', - 'definition.union', - 'definition.property', - 'definition.record', - 'definition.delegate', - 'definition.annotation', - 'definition.constructor', - 'definition.template', -] as const; - -/** Extract the definition node from a tree-sitter query capture map. */ -export const getDefinitionNodeFromCaptures = (captureMap: Record): SyntaxNode | null => { - for (const key of DEFINITION_CAPTURE_KEYS) { - if (captureMap[key]) return captureMap[key]; - } - return null; -}; - -/** - * Node types that represent function/method definitions across languages. - * Used to find the enclosing function for a call site. - */ -export const FUNCTION_NODE_TYPES = new Set([ - // TypeScript/JavaScript - 'function_declaration', - 'arrow_function', - 'function_expression', - 'method_definition', - 'generator_function_declaration', - // Python - 'function_definition', - // Common async variants - 'async_function_declaration', - 'async_arrow_function', - // Java - 'method_declaration', - 'constructor_declaration', - // C/C++ - // 'function_definition' already included above - // Go - // 'method_declaration' already included from Java - // C# - 'local_function_statement', - // Rust - 'function_item', - 'impl_item', // Methods inside impl blocks - // PHP - 'anonymous_function', - // Kotlin - 'lambda_literal', - // Swift - 'init_declaration', - 'deinit_declaration', - // Ruby - 'method', // def foo - 'singleton_method', // def self.foo -]); - -/** - * Node types for standard function declarations that need C/C++ declarator handling. - * Used by extractFunctionName to determine how to extract the function name. - */ -export const FUNCTION_DECLARATION_TYPES = new Set([ - 'function_declaration', - 'function_definition', - 'async_function_declaration', - 'generator_function_declaration', - 'function_item', -]); /** * Built-in function/method names that should not be tracked as call targets. @@ -261,351 +165,6 @@ export const BUILT_IN_NAMES = new Set([ /** Check if a name is a built-in function or common noise that should be filtered out */ export const isBuiltInOrNoise = (name: string): boolean => BUILT_IN_NAMES.has(name); -/** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method). - * Kotlin grammar uses function_declaration for both top-level functions and class methods. - * Returns true when the captured definition node has a class_body ancestor. */ -export function isKotlinClassMethod(captureNode: { parent?: any } | null | undefined): boolean { - let ancestor = captureNode?.parent; - while (ancestor) { - if (ancestor.type === 'class_body') return true; - ancestor = ancestor.parent; - } - return false; -} - -/** - * C/C++: check if a Function capture is inside a class/struct body. - * If true, the function is already captured by @definition.method and should be skipped - * to prevent double-indexing in globalIndex. - */ -export function isCppDuplicateClassFunction( - functionNode: { parent?: any } | null | undefined, - nodeLabel: string, - language: SupportedLanguages, -): boolean { - if (nodeLabel !== 'Function') return false; - if (language !== SupportedLanguages.CPlusPlus && language !== SupportedLanguages.C) return false; - let ancestor = functionNode?.parent; - while (ancestor) { - if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') return true; - ancestor = ancestor.parent; - } - return false; -} - -/** - * Determine the graph node label from a tree-sitter capture map. - * Handles language-specific reclassification (C/C++ duplicate skipping, Kotlin Method promotion). - * Returns null if the capture should be skipped (import, call, C/C++ duplicate, missing name). - */ -export function getLabelFromCaptures( - captureMap: Record, - language: SupportedLanguages, -): NodeLabel | null { - if (captureMap['import'] || captureMap['call']) return null; - if (!captureMap['name'] && !captureMap['definition.constructor']) return null; - - if (captureMap['definition.function']) { - if (isCppDuplicateClassFunction(captureMap['definition.function'], 'Function', language)) return null; - if (language === SupportedLanguages.Kotlin && isKotlinClassMethod(captureMap['definition.function'])) return 'Method'; - return 'Function'; - } - if (captureMap['definition.class']) return 'Class'; - if (captureMap['definition.interface']) return 'Interface'; - if (captureMap['definition.method']) return 'Method'; - if (captureMap['definition.struct']) return 'Struct'; - if (captureMap['definition.enum']) return 'Enum'; - if (captureMap['definition.namespace']) return 'Namespace'; - if (captureMap['definition.module']) return 'Module'; - if (captureMap['definition.trait']) return 'Trait'; - if (captureMap['definition.impl']) return 'Impl'; - if (captureMap['definition.type']) return 'TypeAlias'; - if (captureMap['definition.const']) return 'Const'; - if (captureMap['definition.static']) return 'Static'; - if (captureMap['definition.typedef']) return 'Typedef'; - if (captureMap['definition.macro']) return 'Macro'; - if (captureMap['definition.union']) return 'Union'; - if (captureMap['definition.property']) return 'Property'; - if (captureMap['definition.record']) return 'Record'; - if (captureMap['definition.delegate']) return 'Delegate'; - if (captureMap['definition.annotation']) return 'Annotation'; - if (captureMap['definition.constructor']) return 'Constructor'; - if (captureMap['definition.template']) return 'Template'; - return 'CodeElement'; -} - -/** 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', 'struct_item', 'enum_item', - 'class_definition', - 'trait_declaration', - 'protocol_declaration', - // Ruby - 'class', - 'module', - // Kotlin - 'object_declaration', - 'companion_object', -]); - -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', - struct_item: 'Struct', - enum_item: 'Enum', - trait_declaration: 'Trait', - record_declaration: 'Record', - protocol_declaration: 'Interface', - class: 'Class', - module: 'Module', - object_declaration: 'Class', - companion_object: 'Class', -}; - -/** 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}`); - } - } - } - } - } - // Go: type_declaration wrapping a struct_type (type User struct { ... }) - // field_declaration → field_declaration_list → struct_type → type_spec → type_declaration - if (current.type === 'type_declaration') { - const typeSpec = current.children?.find((c: any) => c.type === 'type_spec'); - if (typeSpec) { - const typeBody = typeSpec.childForFieldName?.('type'); - if (typeBody?.type === 'struct_type' || typeBody?.type === 'interface_type') { - const nameNode = typeSpec.childForFieldName?.('name'); - if (nameNode) { - const label = typeBody.type === 'struct_type' ? 'Struct' : 'Interface'; - return generateId(label, `${filePath}:${nameNode.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' || c.type === 'constant' - ); - 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. - */ -export const extractFunctionName = (node: SyntaxNode): { funcName: string | null; label: string } => { - let funcName: string | null = null; - let label = 'Function'; - - // Swift init/deinit - if (node.type === 'init_declaration' || node.type === 'deinit_declaration') { - return { - funcName: node.type === 'init_declaration' ? 'init' : 'deinit', - label: 'Constructor', - }; - } - - if (FUNCTION_DECLARATION_TYPES.has(node.type)) { - // C/C++: function_definition -> [pointer_declarator ->] function_declarator -> qualified_identifier/identifier - // Unwrap pointer_declarator / reference_declarator wrappers to reach function_declarator - let declarator = node.childForFieldName?.('declarator'); - if (!declarator) { - for (let i = 0; i < node.childCount; i++) { - const c = node.child(i); - if (c?.type === 'function_declarator') { declarator = c; break; } - } - } - while (declarator && (declarator.type === 'pointer_declarator' || declarator.type === 'reference_declarator')) { - let nextDeclarator = declarator.childForFieldName?.('declarator'); - if (!nextDeclarator) { - for (let i = 0; i < declarator.childCount; i++) { - const c = declarator.child(i); - if (c?.type === 'function_declarator' || c?.type === 'pointer_declarator' || c?.type === 'reference_declarator') { nextDeclarator = c; break; } - } - } - declarator = nextDeclarator; - } - if (declarator) { - let innerDeclarator = declarator.childForFieldName?.('declarator'); - if (!innerDeclarator) { - for (let i = 0; i < declarator.childCount; i++) { - const c = declarator.child(i); - if (c?.type === 'qualified_identifier' || c?.type === 'identifier' - || c?.type === 'field_identifier' || c?.type === 'parenthesized_declarator') { innerDeclarator = c; break; } - } - } - - if (innerDeclarator?.type === 'qualified_identifier') { - let nameNode = innerDeclarator.childForFieldName?.('name'); - if (!nameNode) { - for (let i = 0; i < innerDeclarator.childCount; i++) { - const c = innerDeclarator.child(i); - if (c?.type === 'identifier') { nameNode = c; break; } - } - } - if (nameNode?.text) { - funcName = nameNode.text; - label = 'Method'; - } - } else if (innerDeclarator?.type === 'identifier' || innerDeclarator?.type === 'field_identifier') { - // field_identifier is used for method names inside C++ class bodies - funcName = innerDeclarator.text; - if (innerDeclarator.type === 'field_identifier') label = 'Method'; - } else if (innerDeclarator?.type === 'parenthesized_declarator') { - let nestedId: SyntaxNode | null = null; - for (let i = 0; i < innerDeclarator.childCount; i++) { - const c = innerDeclarator.child(i); - if (c?.type === 'qualified_identifier' || c?.type === 'identifier') { nestedId = c; break; } - } - if (nestedId?.type === 'qualified_identifier') { - let nameNode = nestedId.childForFieldName?.('name'); - if (!nameNode) { - for (let i = 0; i < nestedId.childCount; i++) { - const c = nestedId.child(i); - if (c?.type === 'identifier') { nameNode = c; break; } - } - } - if (nameNode?.text) { - funcName = nameNode.text; - label = 'Method'; - } - } else if (nestedId?.type === 'identifier') { - funcName = nestedId.text; - } - } - } - - // Fallback for other languages (Kotlin uses simple_identifier, Swift uses simple_identifier) - if (!funcName) { - let nameNode = node.childForFieldName?.('name'); - if (!nameNode) { - for (let i = 0; i < node.childCount; i++) { - const c = node.child(i); - if (c?.type === 'identifier' || c?.type === 'property_identifier' || c?.type === 'simple_identifier') { nameNode = c; break; } - } - } - funcName = nameNode?.text; - - // Kotlin: function_declaration inside a class_body is a method, not a top-level function. - // Must match the label assigned in parse-worker.ts for consistent generateId() output. - if (funcName && node.type === 'function_declaration' && isKotlinClassMethod(node)) { - label = 'Method'; - } - } - } else if (node.type === 'impl_item') { - let funcItem: SyntaxNode | null = null; - for (let i = 0; i < node.childCount; i++) { - const c = node.child(i); - if (c?.type === 'function_item') { funcItem = c; break; } - } - if (funcItem) { - let nameNode = funcItem.childForFieldName?.('name'); - if (!nameNode) { - for (let i = 0; i < funcItem.childCount; i++) { - const c = funcItem.child(i); - if (c?.type === 'identifier') { nameNode = c; break; } - } - } - funcName = nameNode?.text; - label = 'Method'; - } - } else if (node.type === 'method_definition') { - let nameNode = node.childForFieldName?.('name'); - if (!nameNode) { - for (let i = 0; i < node.childCount; i++) { - const c = node.child(i); - if (c?.type === 'property_identifier') { nameNode = c; break; } - } - } - funcName = nameNode?.text; - label = 'Method'; - } else if (node.type === 'method_declaration' || node.type === 'constructor_declaration') { - let nameNode = node.childForFieldName?.('name'); - if (!nameNode) { - for (let i = 0; i < node.childCount; i++) { - const c = node.child(i); - if (c?.type === 'identifier') { nameNode = c; break; } - } - } - funcName = nameNode?.text; - label = 'Method'; - } else if (node.type === 'arrow_function' || node.type === 'function_expression') { - const parent = node.parent; - if (parent?.type === 'variable_declarator') { - let nameNode = parent.childForFieldName?.('name'); - if (!nameNode) { - for (let i = 0; i < parent.childCount; i++) { - const c = parent.child(i); - if (c?.type === 'identifier') { nameNode = c; break; } - } - } - funcName = nameNode?.text; - } - } else if (node.type === 'method' || node.type === 'singleton_method') { - let nameNode = node.childForFieldName?.('name'); - if (!nameNode) { - for (let i = 0; i < node.childCount; i++) { - const c = node.child(i); - if (c?.type === 'identifier') { nameNode = c; break; } - } - } - funcName = nameNode?.text; - label = 'Method'; - } - - return { funcName, label }; -}; - /** * Yield control to the event loop so spinners/progress can render. * Call periodically in hot loops to prevent UI freezes. @@ -615,23 +174,6 @@ export const yieldToEventLoop = (): Promise => new Promise(resolve => setI /** Ruby extensionless filenames recognised as Ruby source */ const RUBY_EXTENSIONLESS_FILES = new Set(['Rakefile', 'Gemfile', 'Guardfile', 'Vagrantfile', 'Brewfile']); -/** - * Find a child of `childType` within a sibling node of `siblingType`. - * Used for Kotlin AST traversal where visibility_modifier lives inside a modifiers sibling. - */ -export const findSiblingChild = (parent: any, siblingType: string, childType: string): any | null => { - for (let i = 0; i < parent.childCount; i++) { - const sibling = parent.child(i); - if (sibling?.type === siblingType) { - for (let j = 0; j < sibling.childCount; j++) { - const child = sibling.child(j); - if (child?.type === childType) return child; - } - } - } - return null; -}; - /** * Map file extension to SupportedLanguage enum */ @@ -681,551 +223,6 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages | return null; }; -export interface MethodSignature { - parameterCount: number | undefined; - /** Number of required (non-optional, non-default) parameters. - * Only set when fewer than parameterCount — enables range-based arity filtering. - * undefined means all parameters are required (or metadata unavailable). */ - requiredParameterCount: number | undefined; - /** Per-parameter type names extracted via extractSimpleTypeName. - * Only populated for languages with method overloading (Java, Kotlin, C#, C++). - * undefined (not []) when no types are extractable — avoids empty array allocations. */ - parameterTypes: string[] | 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 requiredCount = 0; - let returnType: string | undefined; - let isVariadic = false; - const paramTypes: string[] = []; - - if (!node) return { parameterCount, requiredParameterCount: undefined, parameterTypes: undefined, 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 - ]); - - /** AST node types that represent parameters with default values. */ - const OPTIONAL_PARAM_TYPES = new Set([ - 'optional_parameter', // TypeScript, Ruby: (x?: number), (x: number = 5), def f(x = 5) - 'default_parameter', // Python: def f(x=5) - 'typed_default_parameter', // Python: def f(x: int = 5) - 'optional_parameter_declaration', // C++: void f(int x = 5) - ]); - - /** Check if a parameter node has a default value (handles Kotlin, C#, Swift, PHP - * where defaults are expressed as child nodes rather than distinct node types). */ - const hasDefaultValue = (paramNode: SyntaxNode): boolean => { - if (OPTIONAL_PARAM_TYPES.has(paramNode.type)) return true; - // C#, Swift, PHP: check for '=' token or equals_value_clause child - for (let i = 0; i < paramNode.childCount; i++) { - const c = paramNode.child(i); - if (!c) continue; - if (c.type === '=' || c.type === 'equals_value_clause') return true; - } - // Kotlin: default values are siblings of the parameter node, not children. - // The AST is: parameter, =, — all at function_value_parameters level. - // Check if the immediately following sibling is '=' (default value separator). - const sib = paramNode.nextSibling; - if (sib && sib.type === '=') return true; - return false; - }; - - 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; - } - // Kotlin: default values are siblings of the parameter node inside - // function_value_parameters, so they appear as named children (e.g. - // string_literal, integer_literal, boolean_literal, call_expression). - // Skip any named child that isn't a parameter-like or modifier node. - if (param.type.endsWith('_literal') || param.type === 'call_expression' - || param.type === 'navigation_expression' || param.type === 'prefix_expression' - || param.type === 'parenthesized_expression') { - 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; - } - } - // Extract parameter type name for overload disambiguation. - // Works for Java (formal_parameter), Kotlin (parameter), C# (parameter), - // C++ (parameter_declaration). Uses childForFieldName('type') which is the - // standard tree-sitter field for typed parameters across these languages. - // Kotlin uses positional children instead of 'type' field — fall back to - // searching for user_type/nullable_type/predefined_type children. - const paramTypeNode = param.childForFieldName('type'); - if (paramTypeNode) { - const typeName = extractSimpleTypeName(paramTypeNode); - paramTypes.push(typeName ?? 'unknown'); - } else { - // Kotlin: parameter → [simple_identifier, user_type|nullable_type] - let found = false; - for (const child of param.namedChildren) { - if (child.type === 'user_type' || child.type === 'nullable_type' - || child.type === 'type_identifier' || child.type === 'predefined_type') { - const typeName = extractSimpleTypeName(child); - paramTypes.push(typeName ?? 'unknown'); - found = true; - break; - } - } - if (!found) paramTypes.push('unknown'); - } - if (!hasDefaultValue(param)) requiredCount++; - 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) { - if (goResult.type === 'parameter_list') { - // Multi-return: extract first parameter's type only (e.g. (*User, error) → *User) - const firstParam = goResult.firstNamedChild; - if (firstParam?.type === 'parameter_declaration') { - const typeNode = firstParam.childForFieldName('type'); - if (typeNode) returnType = typeNode.text; - } else if (firstParam) { - // Unnamed return types: (string, error) — first child is a bare type node - returnType = firstParam.text; - } - } else { - returnType = goResult.text; - } - } - - // 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; - } - } - - // C#: 'returns' field on method_declaration - if (!returnType) { - const csReturn = node.childForFieldName?.('returns'); - if (csReturn && csReturn.text !== 'void') { - returnType = csReturn.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; - } - } - } - - // Kotlin: fun getUser(): User — return type is a bare user_type child of - // function_declaration. The Kotlin grammar does NOT wrap it in type_annotation - // or return_type; it appears as a direct child after function_value_parameters. - // Note: Kotlin uses function_value_parameters (not a field), so we find it by type. - if (!returnType) { - let paramsEnd = -1; - for (let i = 0; i < node.childCount; i++) { - const child = node.child(i); - if (!child) continue; - if (child.type === 'function_value_parameters' || child.type === 'value_parameters') { - paramsEnd = child.endIndex; - } - if (paramsEnd >= 0 && child.type === 'user_type' && child.startIndex > paramsEnd) { - returnType = child.text; - break; - } - } - } - - if (isVariadic) parameterCount = undefined; - - // Only include parameterTypes when at least one type was successfully extracted. - // Use undefined (not []) to avoid empty array allocations for untyped parameters. - const hasTypes = paramTypes.length > 0 && paramTypes.some(t => t !== 'unknown'); - // Only set requiredParameterCount when it differs from total — saves memory on the common case. - const requiredParameterCount = (!isVariadic && requiredCount < (parameterCount ?? 0)) - ? requiredCount : undefined; - return { parameterCount, requiredParameterCount, parameterTypes: hasTypes ? paramTypes : undefined, 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 - 'member_binding_expression', // C#: user?.Method() — null-conditional access -]); - -/** - * 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'; - } - - // 4b. Ruby call with receiver: obj.method - if (callNode.type === 'call' && callNode.childForFieldName('receiver')) { - 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() - 'super', // TS/JS/Java/Kotlin/Ruby super.method() - 'super_expression', // Kotlin wraps super in super_expression - 'base', // C# base.Method() - 'parent', // PHP parent::method() - 'constant', // Ruby CONSTANT.method() (uppercase identifiers) -]); - -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'); - } - - // Ruby: call node has 'receiver' field - if (!receiver && parent.type === 'call') { - receiver = parent.childForFieldName('receiver'); - } - - // PHP scoped_call_expression (parent::method(), self::method()): - // nameNode's direct parent IS the scoped_call_expression (name is a direct child) - if (!receiver && (parent.type === 'scoped_call_expression' || callNode.type === 'scoped_call_expression')) { - const scopedCall = parent.type === 'scoped_call_expression' ? parent : callNode; - receiver = scopedCall.childForFieldName('scope'); - // relative_scope wraps 'parent'/'self'/'static' — unwrap to get the keyword - if (receiver?.type === 'relative_scope') { - receiver = receiver.firstChild; - } - } - - // C# null-conditional: user?.Save() → conditional_access_expression wraps member_binding_expression - if (!receiver && parent.type === 'member_binding_expression') { - const condAccess = parent.parent; - if (condAccess?.type === 'conditional_access_expression') { - receiver = condAccess.firstNamedChild; - } - } - - // 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; - } - - // Python super().method(): receiver is a call node `super()` — extract the function name - if (receiver.type === 'call') { - const func = receiver.childForFieldName('function'); - if (func?.text === 'super') return 'super'; - } - - return undefined; -}; - -/** - * Extract the raw receiver AST node for a member call. - * Unlike extractReceiverName, this returns the receiver node regardless of its type — - * including call_expression / method_invocation nodes that appear in chained calls - * like `svc.getUser().save()`. - * - * Returns undefined when the call is not a member call or when no receiver node - * can be found (e.g. top-level free calls). - */ -export const extractReceiverNode = ( - nameNode: SyntaxNode, -): SyntaxNode | undefined => { - const parent = nameNode.parent; - if (!parent) return undefined; - - const callNode = parent.parent ?? parent; - - let receiver: SyntaxNode | null = null; - - receiver = parent.childForFieldName('object') - ?? parent.childForFieldName('value') - ?? parent.childForFieldName('operand') - ?? parent.childForFieldName('expression') - ?? parent.childForFieldName('argument'); - - if (!receiver && callNode.type === 'method_invocation') { - receiver = callNode.childForFieldName('object'); - } - - if (!receiver && (callNode.type === 'member_call_expression' || callNode.type === 'nullsafe_member_call_expression')) { - receiver = callNode.childForFieldName('object'); - } - - if (!receiver && parent.type === 'call') { - receiver = parent.childForFieldName('receiver'); - } - - if (!receiver && (parent.type === 'scoped_call_expression' || callNode.type === 'scoped_call_expression')) { - const scopedCall = parent.type === 'scoped_call_expression' ? parent : callNode; - receiver = scopedCall.childForFieldName('scope'); - if (receiver?.type === 'relative_scope') { - receiver = receiver.firstChild; - } - } - - if (!receiver && parent.type === 'member_binding_expression') { - const condAccess = parent.parent; - if (condAccess?.type === 'conditional_access_expression') { - receiver = condAccess.firstNamedChild; - } - } - - if (!receiver && parent.type === 'navigation_suffix') { - const navExpr = parent.parent; - if (navExpr?.type === 'navigation_expression') { - for (const child of navExpr.children) { - if (child.isNamed && child !== parent) { - receiver = child; - break; - } - } - } - } - - return receiver ?? undefined; -}; - export const isVerboseIngestionEnabled = (): boolean => { const raw = process.env.GITNEXUS_VERBOSE; if (!raw) return false; @@ -1233,243 +230,6 @@ export const isVerboseIngestionEnabled = (): boolean => { return value === '1' || value === 'true' || value === 'yes'; }; -// ── Chained-call extraction ─────────────────────────────────────────────── - -/** Node types representing call expressions across supported languages. */ -export const CALL_EXPRESSION_TYPES = new Set([ - 'call_expression', // TS/JS/C/C++/Go/Rust - 'method_invocation', // Java - 'member_call_expression', // PHP - 'nullsafe_member_call_expression', // PHP ?. - 'call', // Python/Ruby - 'invocation_expression', // C# -]); - -/** - * Hard limit on chain depth to prevent runaway recursion. - * For `a.b().c().d()`, the chain has depth 2 (b and c before d). - */ -export const MAX_CHAIN_DEPTH = 3; - -/** - * Walk a receiver AST node that is itself a call expression, accumulating the - * chain of intermediate method names up to MAX_CHAIN_DEPTH. - * - * For `svc.getUser().save()`, called with the receiver of `save` (getUser() call): - * returns { chain: ['getUser'], baseReceiverName: 'svc' } - * - * For `a.b().c().d()`, called with the receiver of `d` (c() call): - * returns { chain: ['b', 'c'], baseReceiverName: 'a' } - */ -export function extractCallChain( - receiverCallNode: SyntaxNode, -): { chain: string[]; baseReceiverName: string | undefined } | undefined { - const chain: string[] = []; - let current: SyntaxNode = receiverCallNode; - - while (CALL_EXPRESSION_TYPES.has(current.type) && chain.length < MAX_CHAIN_DEPTH) { - // Extract the method name from this call node. - const funcNode = current.childForFieldName?.('function') - ?? current.childForFieldName?.('name') - ?? current.childForFieldName?.('method'); // Ruby `call` node - let methodName: string | undefined; - let innerReceiver: SyntaxNode | null = null; - if (funcNode) { - // member_expression / attribute: last named child is the method identifier - methodName = funcNode.lastNamedChild?.text ?? funcNode.text; - } - // Kotlin/Swift: call_expression exposes callee as firstNamedChild, not a field. - // navigation_expression: method name is in navigation_suffix → simple_identifier. - if (!funcNode && current.type === 'call_expression') { - const callee = current.firstNamedChild; - if (callee?.type === 'navigation_expression') { - const suffix = callee.lastNamedChild; - if (suffix?.type === 'navigation_suffix') { - methodName = suffix.lastNamedChild?.text; - // The receiver is the part of navigation_expression before the suffix - for (let i = 0; i < callee.namedChildCount; i++) { - const child = callee.namedChild(i); - if (child && child.type !== 'navigation_suffix') { - innerReceiver = child; - break; - } - } - } - } - } - if (!methodName) break; - chain.unshift(methodName); // build chain outermost-last - - // Walk into the receiver of this call to continue the chain - if (!innerReceiver && funcNode) { - innerReceiver = funcNode.childForFieldName?.('object') - ?? funcNode.childForFieldName?.('value') - ?? funcNode.childForFieldName?.('operand') - ?? funcNode.childForFieldName?.('expression'); - } - // Java method_invocation: object field is on the call node - if (!innerReceiver && current.type === 'method_invocation') { - innerReceiver = current.childForFieldName?.('object'); - } - // PHP member_call_expression - if (!innerReceiver && (current.type === 'member_call_expression' || current.type === 'nullsafe_member_call_expression')) { - innerReceiver = current.childForFieldName?.('object'); - } - // Ruby `call` node: receiver field is on the call node itself - if (!innerReceiver && current.type === 'call') { - innerReceiver = current.childForFieldName?.('receiver'); - } - - if (!innerReceiver) break; - - if (CALL_EXPRESSION_TYPES.has(innerReceiver.type)) { - current = innerReceiver; // continue walking - } else { - // Reached a simple identifier — the base receiver - return { chain, baseReceiverName: innerReceiver.text || undefined }; - } - } - - return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined; -} - -/** Node types representing member/field access across languages. */ -const FIELD_ACCESS_NODE_TYPES = new Set([ - 'member_expression', // TS/JS - 'member_access_expression', // C# - 'selector_expression', // Go - 'field_expression', // Rust/C++ - 'field_access', // Java - 'attribute', // Python - 'navigation_expression', // Kotlin/Swift - 'member_binding_expression', // C# null-conditional (user?.Address) -]); - -/** One step in a mixed receiver chain. */ -export type MixedChainStep = { kind: 'field' | 'call'; name: string }; - -/** - * Walk a receiver AST node that may interleave field accesses and method calls, - * building a unified chain of steps up to MAX_CHAIN_DEPTH. - * - * For `svc.getUser().address.save()`, called with the receiver of `save` - * (`svc.getUser().address`, a field access node): - * returns { chain: [{ kind:'call', name:'getUser' }, { kind:'field', name:'address' }], - * baseReceiverName: 'svc' } - * - * For `user.getAddress().city.getName()`, called with receiver of `getName` - * (`user.getAddress().city`): - * returns { chain: [{ kind:'call', name:'getAddress' }, { kind:'field', name:'city' }], - * baseReceiverName: 'user' } - * - * Pure field chains and pure call chains are special cases (all steps same kind). - */ -export function extractMixedChain( - receiverNode: SyntaxNode, -): { chain: MixedChainStep[]; baseReceiverName: string | undefined } | undefined { - const chain: MixedChainStep[] = []; - let current: SyntaxNode = receiverNode; - - while (chain.length < MAX_CHAIN_DEPTH) { - if (CALL_EXPRESSION_TYPES.has(current.type)) { - // ── Call expression: extract method name + inner receiver ──────────── - const funcNode = current.childForFieldName?.('function') - ?? current.childForFieldName?.('name') - ?? current.childForFieldName?.('method'); - let methodName: string | undefined; - let innerReceiver: SyntaxNode | null = null; - - if (funcNode) { - methodName = funcNode.lastNamedChild?.text ?? funcNode.text; - } - // Kotlin/Swift: call_expression → navigation_expression - if (!funcNode && current.type === 'call_expression') { - const callee = current.firstNamedChild; - if (callee?.type === 'navigation_expression') { - const suffix = callee.lastNamedChild; - if (suffix?.type === 'navigation_suffix') { - methodName = suffix.lastNamedChild?.text; - for (let i = 0; i < callee.namedChildCount; i++) { - const child = callee.namedChild(i); - if (child && child.type !== 'navigation_suffix') { innerReceiver = child; break; } - } - } - } - } - if (!methodName) break; - chain.unshift({ kind: 'call', name: methodName }); - - if (!innerReceiver && funcNode) { - innerReceiver = funcNode.childForFieldName?.('object') - ?? funcNode.childForFieldName?.('value') - ?? funcNode.childForFieldName?.('operand') - ?? funcNode.childForFieldName?.('argument') // C/C++ field_expression - ?? funcNode.childForFieldName?.('expression') - ?? null; - } - if (!innerReceiver && current.type === 'method_invocation') { - innerReceiver = current.childForFieldName?.('object') ?? null; - } - if (!innerReceiver && (current.type === 'member_call_expression' || current.type === 'nullsafe_member_call_expression')) { - innerReceiver = current.childForFieldName?.('object') ?? null; - } - if (!innerReceiver && current.type === 'call') { - innerReceiver = current.childForFieldName?.('receiver') ?? null; - } - if (!innerReceiver) break; - - if (CALL_EXPRESSION_TYPES.has(innerReceiver.type) || FIELD_ACCESS_NODE_TYPES.has(innerReceiver.type)) { - current = innerReceiver; - } else { - return { chain, baseReceiverName: innerReceiver.text || undefined }; - } - } else if (FIELD_ACCESS_NODE_TYPES.has(current.type)) { - // ── Field/member access: extract property name + inner object ───────── - let propertyName: string | undefined; - let innerObject: SyntaxNode | null = null; - - if (current.type === 'navigation_expression') { - for (const child of current.children ?? []) { - if (child.type === 'navigation_suffix') { - for (const sc of child.children ?? []) { - if (sc.isNamed && sc.type !== '.') { propertyName = sc.text; break; } - } - } else if (child.isNamed && !innerObject) { - innerObject = child; - } - } - } else if (current.type === 'attribute') { - innerObject = current.childForFieldName?.('object') ?? null; - propertyName = current.childForFieldName?.('attribute')?.text; - } else { - innerObject = current.childForFieldName?.('object') - ?? current.childForFieldName?.('value') - ?? current.childForFieldName?.('operand') - ?? current.childForFieldName?.('argument') // C/C++ field_expression - ?? current.childForFieldName?.('expression') - ?? null; - propertyName = (current.childForFieldName?.('property') - ?? current.childForFieldName?.('field') - ?? current.childForFieldName?.('name'))?.text; - } - - if (!propertyName) break; - chain.unshift({ kind: 'field', name: propertyName }); - - if (!innerObject) break; - - if (CALL_EXPRESSION_TYPES.has(innerObject.type) || FIELD_ACCESS_NODE_TYPES.has(innerObject.type)) { - current = innerObject; - } else { - return { chain, baseReceiverName: innerObject.text || undefined }; - } - } else { - // Simple identifier — this is the base receiver - return chain.length > 0 - ? { chain, baseReceiverName: current.text || undefined } - : undefined; - } - } - - return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined; -} +// Re-exports for backward compatibility +export * from './ast-helpers.js'; +export * from './call-analysis.js'; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 618c54980..fc31469d0 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -932,6 +932,7 @@ const processFileGroup = ( // Extract import paths before skipping if (captureMap['import'] && captureMap['import.source']) { const rawImportPath = preprocessImportPath(captureMap['import.source'].text, captureMap['import'], language); + if (!rawImportPath) continue; const extractor = namedBindingExtractors[language]; const namedBindings = extractor ? extractor(captureMap['import']) : undefined; result.imports.push({ diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-no-csproj/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-no-csproj/Models/User.cs new file mode 100644 index 000000000..c7adb7aad --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-no-csproj/Models/User.cs @@ -0,0 +1,8 @@ +namespace Models +{ + public class User + { + public void Save() { } + public string GetName() { return "alice"; } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-no-csproj/Services/UserService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-no-csproj/Services/UserService.cs new file mode 100644 index 000000000..e2648a657 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-no-csproj/Services/UserService.cs @@ -0,0 +1,13 @@ +using Models; + +namespace Services +{ + public class UserService + { + public void ProcessUser() + { + var user = new User(); + user.Save(); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-cmd-helper/cmd/server/internal/config/config.go b/gitnexus/test/fixtures/lang-resolution/go-cmd-helper/cmd/server/internal/config/config.go new file mode 100644 index 000000000..326ce8291 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-cmd-helper/cmd/server/internal/config/config.go @@ -0,0 +1,5 @@ +package config + +func Load() string { + return "loaded" +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-cmd-helper/cmd/server/main.go b/gitnexus/test/fixtures/lang-resolution/go-cmd-helper/cmd/server/main.go new file mode 100644 index 000000000..b9913521a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-cmd-helper/cmd/server/main.go @@ -0,0 +1,7 @@ +package main + +import "myapp/cmd/server/internal/config" + +func main() { + config.Load() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-cmd-helper/go.mod b/gitnexus/test/fixtures/lang-resolution/go-cmd-helper/go.mod new file mode 100644 index 000000000..505b313a9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-cmd-helper/go.mod @@ -0,0 +1,3 @@ +module myapp + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Config/constants.php b/gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Config/constants.php new file mode 100644 index 000000000..fe5fb8d14 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Config/constants.php @@ -0,0 +1,5 @@ +save(); + + $name = formatName("test"); + echo MAX_RETRIES; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Utils/helpers.php b/gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Utils/helpers.php new file mode 100644 index 000000000..dc2532223 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-use-function-const/app/Utils/helpers.php @@ -0,0 +1,7 @@ + Self { + Repo { name: name.to_string() } + } + + pub fn clone_repo(&self) { + println!("Cloning repo {}", self.name); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-scoped-multi-file/src/models/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-scoped-multi-file/src/models/user.rs new file mode 100644 index 000000000..23a1d2368 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-scoped-multi-file/src/models/user.rs @@ -0,0 +1,13 @@ +pub struct User { + name: String, +} + +impl User { + pub fn new(name: &str) -> Self { + User { name: name.to_string() } + } + + pub fn save(&self) { + println!("Saving user {}", self.name); + } +} diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index 5f8d9d1be..ef048a780 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -1587,3 +1587,42 @@ describe('C# cross-file binding propagation', () => { expect(getNameEdge).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// C# fallback without .csproj (P1-4 fix) +// When no .csproj file is found, import resolution should fall back to +// suffix-based matching rather than returning null. +// --------------------------------------------------------------------------- + +describe('C# import resolution without .csproj (suffix fallback)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-no-csproj'), + () => {}, + ); + }, 60000); + + it('detects User class with Save and GetName methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('Save'); + }); + + it('detects UserService class with ProcessUser method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('UserService'); + expect(getNodesByLabel(result, 'Method')).toContain('ProcessUser'); + }); + + // C# 'using Models;' is a namespace import — suffix matching cannot resolve + // namespace-to-directory mappings without .csproj. The fallback prevents a null + // return (so other resolution paths can attempt it), but namespace imports + // inherently require project config for file discovery. + it('does not crash on namespace import without .csproj (graceful fallback)', () => { + // Pipeline completes without errors and detects symbols from both files, + // even though no IMPORTS edge is created for the namespace import. + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('User'); + expect(classes).toContain('UserService'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 69bb68da6..6a464e9a6 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -1247,3 +1247,33 @@ describe('Go cross-file binding propagation', () => { expect(getNameEdge).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Go cmd/ helper files should NOT get entry-point multiplier (P0-1 fix) +// Only main.go files should get the 3.0 entry-point boost, not arbitrary +// .go files under cmd/ subdirectories. +// --------------------------------------------------------------------------- + +describe('Go cmd/ helper files entry-point scoring', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-cmd-helper'), + () => {}, + ); + }, 60000); + + it('detects main function and Load function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('main'); + expect(getNodesByLabel(result, 'Function')).toContain('Load'); + }); + + it('emits IMPORTS edge from main.go to config/config.go', () => { + const imports = getRelationships(result, 'IMPORTS'); + const edge = imports.find(e => + e.sourceFilePath.includes('main') && e.targetFilePath.includes('config'), + ); + expect(edge).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index 4eca7a0af..c739936cb 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -1515,3 +1515,53 @@ describe('PHP cross-file binding propagation', () => { expect(getNameEdge).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// PHP use function / use const filtering (P0-3 fix) +// Verifies that `use function` and `use const` declarations do NOT produce +// class-type namedImportMap entries, while regular `use` class imports still work. +// --------------------------------------------------------------------------- + +describe('PHP use function / use const filtering', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-use-function-const'), + () => {}, + ); + }, 60000); + + it('detects User class with save and getName methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('getName'); + }); + + it('detects Calculator class with process method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Calculator'); + expect(getNodesByLabel(result, 'Method')).toContain('process'); + }); + + it('detects formatName as a standalone function (not a class)', () => { + expect(getNodesByLabel(result, 'Function')).toContain('formatName'); + // formatName should NOT appear as a Class + expect(getNodesByLabel(result, 'Class')).not.toContain('formatName'); + }); + + it('emits IMPORTS edge from Calculator.php to User.php (class import)', () => { + const imports = getRelationships(result, 'IMPORTS'); + const edge = imports.find(e => + e.sourceFilePath.includes('Calculator') && e.targetFilePath.includes('User'), + ); + expect(edge).toBeDefined(); + }); + + it('resolves $user->save() to User#save via class import binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('User'), + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index 4ea635f38..ee5d8d5c0 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -408,6 +408,54 @@ describe('Rust grouped import resolution', () => { }); }); +// --------------------------------------------------------------------------- +// Scoped grouped imports with multi-file resolution: +// use crate::models::{User, Repo} where User and Repo are in separate files. +// Verifies IMPORTS edges are created for each file AND namedImportMap entries +// match bindings to files by basename. +// --------------------------------------------------------------------------- + +describe('Rust scoped grouped imports (multi-file)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-scoped-multi-file'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs', () => { + const classes = getNodesByLabel(result, 'Struct'); + expect(classes).toContain('User'); + expect(classes).toContain('Repo'); + }); + + it('emits IMPORTS edge from main.rs to models/mod.rs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const edge = imports.find(e => + e.sourceFilePath.includes('main') && e.targetFilePath.includes('models'), + ); + expect(edge).toBeDefined(); + }); + + it('resolves user.save() call to User#save in models/user.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'main' && c.targetFilePath.includes('user'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves repo.clone_repo() call to Repo#clone_repo in models/repo.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const cloneCall = calls.find(c => + c.target === 'clone_repo' && c.source === 'main' && c.targetFilePath.includes('repo'), + ); + expect(cloneCall).toBeDefined(); + }); +}); + // --------------------------------------------------------------------------- // Constructor-inferred type resolution: let user = User::new(); user.save() // Rust scoped_identifier constructor pattern (no explicit type annotations) diff --git a/gitnexus/test/unit/framework-detection.test.ts b/gitnexus/test/unit/framework-detection.test.ts index 9cf889a34..94ed5194a 100644 --- a/gitnexus/test/unit/framework-detection.test.ts +++ b/gitnexus/test/unit/framework-detection.test.ts @@ -140,6 +140,11 @@ describe('detectFrameworkFromPath', () => { expect(result).not.toBeNull(); expect(result!.entryPointMultiplier).toBe(3.0); }); + + it('does NOT treat Go helper files under cmd/ as entry points', () => { + expect(detectFrameworkFromPath('cmd/server/internal/util.go')).toBeNull(); + expect(detectFrameworkFromPath('cmd/foo/config/setup.go')).toBeNull(); + }); }); describe('Rust frameworks', () => { diff --git a/gitnexus/test/unit/import-resolution.test.ts b/gitnexus/test/unit/import-resolution.test.ts new file mode 100644 index 000000000..a486ac389 --- /dev/null +++ b/gitnexus/test/unit/import-resolution.test.ts @@ -0,0 +1,149 @@ +/** + * Unit tests for import-resolution.ts + * + * Coverage notes: + * - `preprocessImportPath` is tested directly below (no tree-sitter required for most paths). + * - Rust scoped grouped import logic (`resolveRustImportDispatch`) requires a live file system + * and ResolveCtx — that path is covered by test/integration/resolvers/rust.test.ts. + * - PHP `use function` / `use const` filtering (via `extractPhpNamedBindings`) requires + * tree-sitter PHP nodes — covered by test/integration/resolvers/php.test.ts. + */ + +import { describe, it, expect } from 'vitest'; +import { preprocessImportPath } from '../../src/core/ingestion/import-resolution.js'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; + +// --------------------------------------------------------------------------- +// Minimal SyntaxNode stub — only the fields preprocessImportPath touches. +// For non-Kotlin languages preprocessImportPath never reads the node, so an +// empty stub satisfies the type requirement without loading tree-sitter. +// --------------------------------------------------------------------------- + +function makeNode(overrides: Partial<{ childCount: number; child: (i: number) => any }> = {}): any { + return { + childCount: overrides.childCount ?? 0, + child: overrides.child ?? (() => null), + }; +} + +// --------------------------------------------------------------------------- +// preprocessImportPath — universal cleaning behaviour +// --------------------------------------------------------------------------- + +describe('preprocessImportPath', () => { + describe('quote and bracket stripping', () => { + it('strips double quotes from a bare module path', () => { + const node = makeNode(); + expect(preprocessImportPath('"foo"', node, SupportedLanguages.TypeScript)).toBe('foo'); + }); + + it('strips single quotes from a bare module path', () => { + const node = makeNode(); + expect(preprocessImportPath("'bar/baz'", node, SupportedLanguages.JavaScript)).toBe('bar/baz'); + }); + + it('strips angle brackets from a C-style include path', () => { + const node = makeNode(); + expect(preprocessImportPath('', node, SupportedLanguages.C)).toBe('stdio.h'); + }); + + it('strips mixed quote and angle bracket characters', () => { + const node = makeNode(); + // Pathological input — all stripped characters removed + expect(preprocessImportPath('""', node, SupportedLanguages.TypeScript)).toBe('hello'); + }); + }); + + describe('null returns for invalid inputs', () => { + it('returns null for an empty string (after cleaning)', () => { + const node = makeNode(); + // Only quote characters — cleaned result is empty string + expect(preprocessImportPath('""', node, SupportedLanguages.TypeScript)).toBeNull(); + }); + + it('returns null for a string containing control characters', () => { + const node = makeNode(); + // \x01 is a control character that passes the length check but fails the regex guard + expect(preprocessImportPath('foo\x01bar', node, SupportedLanguages.Rust)).toBeNull(); + }); + + it('returns null for a string containing a null byte', () => { + const node = makeNode(); + expect(preprocessImportPath('foo\x00bar', node, SupportedLanguages.Go)).toBeNull(); + }); + + it('returns null for a path exceeding 2048 characters', () => { + const node = makeNode(); + const longPath = 'a'.repeat(2049); + expect(preprocessImportPath(longPath, node, SupportedLanguages.Python)).toBeNull(); + }); + + it('accepts a path of exactly 2048 characters', () => { + const node = makeNode(); + const maxPath = 'a'.repeat(2048); + expect(preprocessImportPath(maxPath, node, SupportedLanguages.Python)).toBe(maxPath); + }); + }); + + describe('Kotlin wildcard pass-through', () => { + it('delegates to appendKotlinWildcard when language is Kotlin — no wildcard child', () => { + // Node with no children -> appendKotlinWildcard returns the path unchanged + const node = makeNode({ childCount: 0 }); + const result = preprocessImportPath('com.example.models', node, SupportedLanguages.Kotlin); + // Without a wildcard_import child the path is returned as-is + expect(result).toBe('com.example.models'); + }); + + it('delegates to appendKotlinWildcard when language is Kotlin — wildcard_import child present', () => { + // Simulate a node that has a wildcard_import child at index 0 + const wildcardChild = { type: 'wildcard_import' }; + const node = makeNode({ + childCount: 1, + child: (i: number) => (i === 0 ? wildcardChild : null), + }); + const result = preprocessImportPath('com.example.models', node, SupportedLanguages.Kotlin); + // appendKotlinWildcard appends .* when the wildcard_import child is found + expect(result).toBe('com.example.models.*'); + }); + }); + + describe('non-Kotlin languages are returned unchanged (after cleaning)', () => { + it('returns the cleaned path for Rust without modification', () => { + const node = makeNode(); + expect(preprocessImportPath('"crate::models"', node, SupportedLanguages.Rust)).toBe('crate::models'); + }); + + it('returns the cleaned path for PHP without modification', () => { + const node = makeNode(); + expect(preprocessImportPath('"App\\\\Models\\\\User"', node, SupportedLanguages.PHP)).toBe('App\\\\Models\\\\User'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Rust scoped grouped import logic (resolveRustImportDispatch) +// --------------------------------------------------------------------------- +// The dispatch function requires a live ResolveCtx with file lists — unit +// testing it without a file system would duplicate the integration fixtures. +// The following comment documents what the integration tests verify: +// +// test/integration/resolvers/rust.test.ts covers: +// - Top-level grouped: use {crate::a, crate::b} +// - Scoped grouped: use crate::models::{User, Repo} +// - Alias stripping: use crate::models::{User, Repo as R} -> resolves User + Repo +// - Prefix fallback: when no individual items resolve, resolves the prefix path +// +// The ::{ detection and alias-stripping logic lives in resolveRustImportDispatch() +// at import-resolution.ts lines 328-344. + +// --------------------------------------------------------------------------- +// PHP use function / use const filtering (extractPhpNamedBindings) +// --------------------------------------------------------------------------- +// extractPhpNamedBindings requires live tree-sitter PHP SyntaxNode objects. +// The filtering of `use function` and `use const` declarations is covered by: +// +// test/integration/resolvers/php.test.ts +// +// which runs the full ingestion pipeline over PHP fixture repositories and +// asserts that function/const use-declarations do not produce spurious IMPORTS +// edges to non-existent class files.