From 0757ef69e220edcd9ca58b6df52eae3196480eae Mon Sep 17 00:00:00 2001 From: Gary Magyar Date: Sat, 28 Feb 2026 10:06:52 +0000 Subject: [PATCH] feat: add full Kotlin language support --- gitnexus/src/core/ingestion/call-processor.ts | 12 ++- .../src/core/ingestion/framework-detection.ts | 63 +++++++++++- .../src/core/ingestion/import-processor.ts | 58 +++++++---- .../src/core/ingestion/parsing-processor.ts | 68 +++++++------ .../src/core/ingestion/tree-sitter-queries.ts | 6 +- .../core/ingestion/workers/parse-worker.ts | 96 +++++++++++-------- 6 files changed, 206 insertions(+), 97 deletions(-) diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 05148981b5..e82236e51c 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -339,12 +339,22 @@ const BUILT_IN_NAMES = new Set([ 'mutex_lock', 'mutex_unlock', 'mutex_init', 'kfree', 'kmalloc', 'kzalloc', 'kcalloc', 'krealloc', 'kvmalloc', 'kvfree', 'get', 'put', - // Kotlin stdlib + // Kotlin stdlib (IMPORTANT: keep in sync with parse-worker.ts BUILT_IN_NAMES) 'println', 'print', 'readLine', 'require', 'requireNotNull', 'check', 'assert', 'lazy', 'error', 'listOf', 'mapOf', 'setOf', 'mutableListOf', 'mutableMapOf', 'mutableSetOf', 'arrayOf', 'sequenceOf', 'also', 'apply', 'run', 'with', 'takeIf', 'takeUnless', 'TODO', 'buildString', 'buildList', 'buildMap', 'buildSet', 'repeat', 'synchronized', + // Kotlin coroutine builders & scope functions + 'launch', 'async', 'runBlocking', 'withContext', 'coroutineScope', + 'supervisorScope', 'delay', + // Kotlin Flow operators + 'flow', 'flowOf', 'collect', 'emit', 'onEach', 'catch', + 'buffer', 'conflate', 'distinctUntilChanged', + 'flatMapLatest', 'flatMapMerge', 'combine', + 'stateIn', 'shareIn', 'launchIn', + // Kotlin infix stdlib functions + 'to', 'until', 'downTo', 'step', ]); const isBuiltInOrNoise = (name: string): boolean => BUILT_IN_NAMES.has(name); diff --git a/gitnexus/src/core/ingestion/framework-detection.ts b/gitnexus/src/core/ingestion/framework-detection.ts index 299966c7e0..c3ab00bcad 100644 --- a/gitnexus/src/core/ingestion/framework-detection.ts +++ b/gitnexus/src/core/ingestion/framework-detection.ts @@ -129,6 +129,49 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null return { framework: 'java-service', entryPointMultiplier: 1.8, reason: 'java-service' }; } + // ========== KOTLIN FRAMEWORKS ========== + + // Spring Boot Kotlin controllers + if ((p.includes('/controller/') || p.includes('/controllers/')) && p.endsWith('.kt')) { + return { framework: 'spring-kotlin', entryPointMultiplier: 3.0, reason: 'spring-kotlin-controller' }; + } + + // Spring Boot - files ending in Controller.kt + if (p.endsWith('controller.kt')) { + return { framework: 'spring-kotlin', entryPointMultiplier: 3.0, reason: 'spring-kotlin-controller-file' }; + } + + // Ktor routes + if (p.includes('/routes/') && p.endsWith('.kt')) { + return { framework: 'ktor', entryPointMultiplier: 2.5, reason: 'ktor-routes' }; + } + + // Ktor plugins folder or Routing.kt files + if (p.includes('/plugins/') && p.endsWith('.kt')) { + return { framework: 'ktor', entryPointMultiplier: 2.0, reason: 'ktor-plugin' }; + } + if (p.endsWith('routing.kt') || p.endsWith('routes.kt')) { + return { framework: 'ktor', entryPointMultiplier: 2.5, reason: 'ktor-routing-file' }; + } + + // Android Activities, Fragments + if ((p.includes('/activity/') || p.includes('/ui/')) && p.endsWith('.kt')) { + return { framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-ui' }; + } + if (p.endsWith('activity.kt') || p.endsWith('fragment.kt')) { + return { framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-component' }; + } + + // Kotlin main entry point + if (p.endsWith('/main.kt')) { + return { framework: 'kotlin', entryPointMultiplier: 3.0, reason: 'kotlin-main' }; + } + + // Kotlin Application entry point (common naming) + if (p.endsWith('/application.kt')) { + return { framework: 'kotlin', entryPointMultiplier: 2.5, reason: 'kotlin-application' }; + } + // ========== C# / .NET FRAMEWORKS ========== // ASP.NET Controllers @@ -332,6 +375,12 @@ const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record> = + Object.fromEntries( + Object.entries(AST_FRAMEWORK_PATTERNS_BY_LANGUAGE).map(([lang, cfgs]) => [ + lang, + cfgs.map(cfg => ({ ...cfg, patterns: cfg.patterns.map(p => p.toLowerCase()) })), + ]) + ); + /** * Detect framework entry points from AST definition text (decorators/annotations/attributes). * Returns null if no known pattern is found. + * Note: callers should slice definitionText to ~300 chars since annotations appear at the start. */ export function detectFrameworkFromAST( language: string, @@ -350,14 +409,14 @@ export function detectFrameworkFromAST( ): FrameworkHint | null { if (!language || !definitionText) return null; - const configs = AST_FRAMEWORK_PATTERNS_BY_LANGUAGE[language.toLowerCase()]; + const configs = AST_PATTERNS_LOWERED[language.toLowerCase()]; if (!configs || configs.length === 0) return null; const normalized = definitionText.toLowerCase(); for (const cfg of configs) { for (const pattern of cfg.patterns) { - if (normalized.includes(pattern.toLowerCase())) { + if (normalized.includes(pattern)) { return { framework: cfg.framework, entryPointMultiplier: cfg.entryPointMultiplier, diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 85b150b3d3..e3e4f74c00 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -495,6 +495,19 @@ function tryRustModulePath(modulePath: string, allFiles: Set): string | return null; } +/** + * Append .* to a Kotlin import path if the AST has a wildcard_import sibling node. + * Pure function — returns a new string without mutating the input. + */ +const appendKotlinWildcard = (importPath: string, importNode: any): string => { + for (let i = 0; i < importNode.childCount; i++) { + if (importNode.child(i)?.type === 'wildcard_import') { + return importPath.endsWith('.*') ? importPath : `${importPath}.*`; + } + } + return importPath; +}; + // ============================================================================ // JVM MULTI-FILE RESOLUTION (Java + Kotlin) // ============================================================================ @@ -787,20 +800,9 @@ export const processImports = async ( } // Clean path (remove quotes and angle brackets for C/C++ includes) - let rawImportPath = sourceNode.text.replace(/['"<>]/g, ''); - // Kotlin wildcard imports: wildcard_import is a separate AST node - // sibling to identifier, so check the import_header for it and append .* - if (language === SupportedLanguages.Kotlin) { - const importNode = captureMap['import']; - for (let ci = 0; ci < importNode.childCount; ci++) { - if (importNode.child(ci)?.type === 'wildcard_import') { - if (!rawImportPath.endsWith('.*')) { - rawImportPath += '.*'; - } - break; - } - } - } + const rawImportPath = language === SupportedLanguages.Kotlin + ? appendKotlinWildcard(sourceNode.text.replace(/['"<>]/g, ''), captureMap['import']) + : sourceNode.text.replace(/['"<>]/g, ''); totalImportsFound++; // ---- JVM languages (Java + Kotlin): handle wildcards and member imports ---- @@ -809,6 +811,14 @@ export const processImports = async ( if (rawImportPath.endsWith('.*')) { const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index); + // Kotlin can import Java files in mixed codebases — try .java as fallback + if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) { + const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index); + for (const matchedFile of javaMatches) { + addImportEdge(file.path, matchedFile); + } + if (javaMatches.length > 0) return; + } for (const matchedFile of matchedFiles) { addImportEdge(file.path, matchedFile); } @@ -816,7 +826,11 @@ export const processImports = async ( } // Try member/static import resolution (strip member name) - const memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index); + let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index); + // Kotlin can import Java files in mixed codebases — try .java as fallback + if (!memberResolved && language === SupportedLanguages.Kotlin) { + memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index); + } if (memberResolved) { addImportEdge(file.path, memberResolved); return; @@ -971,13 +985,25 @@ export const processImportsFromExtracted = async ( if (rawImportPath.endsWith('.*')) { const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index); + // Kotlin can import Java files in mixed codebases — try .java as fallback + if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) { + const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index); + for (const matchedFile of javaMatches) { + addImportEdge(filePath, matchedFile); + } + if (javaMatches.length > 0) continue; + } for (const matchedFile of matchedFiles) { addImportEdge(filePath, matchedFile); } continue; } - const memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index); + let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index); + // Kotlin can import Java files in mixed codebases — try .java as fallback + if (!memberResolved && language === SupportedLanguages.Kotlin) { + memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index); + } if (memberResolved) { resolveCache.set(cacheKey, memberResolved); addImportEdge(filePath, memberResolved); diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index a8242c58f5..7d753aae59 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -18,33 +18,33 @@ export interface WorkerExtractedData { heritage: ExtractedHeritage[]; } -const getDefinitionNodeFromCaptures = (captureMap: Record): any | null => { - const definitionKeys = [ - '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', - ]; +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; - for (const key of definitionKeys) { +const getDefinitionNodeFromCaptures = (captureMap: Record): any | null => { + for (const key of DEFINITION_CAPTURE_KEYS) { if (captureMap[key]) return captureMap[key]; } return null; @@ -334,16 +334,15 @@ const processParsingSequential = async ( const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`); + const definitionNode = getDefinitionNodeFromCaptures(captureMap); + const frameworkHint = definitionNode + ? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300)) + : null; + const node: GraphNode = { id: nodeId, label: nodeLabel as any, - properties: (() => { - const definitionNode = getDefinitionNodeFromCaptures(captureMap); - const frameworkHint = definitionNode - ? detectFrameworkFromAST(language, definitionNode.text || '') - : null; - - return { + properties: { name: nodeName, filePath: file.path, startLine: nameNode.startPosition.row, @@ -354,8 +353,7 @@ const processParsingSequential = async ( astFrameworkMultiplier: frameworkHint.entryPointMultiplier, astFrameworkReason: frameworkHint.reason, } : {}), - }; - })() + }, }; graph.addNode(node); diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 66644a6d09..b98a1d653a 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -432,7 +432,7 @@ export const KOTLIN_QUERIES = ` ; ── Enum entries ───────────────────────────────────────────────────────── (enum_entry - (simple_identifier) @name) @definition.property + (simple_identifier) @name) @definition.enum ; ── Type aliases ───────────────────────────────────────────────────────── (type_alias @@ -457,6 +457,10 @@ export const KOTLIN_QUERIES = ` (user_type (type_identifier) @call.name)) @call +; ── Infix function calls (e.g., a to b, x until y) ────────────────────── +(infix_expression + (simple_identifier) @call.name) @call + ; ── Heritage: extends / implements via delegation_specifier ────────────── ; Interface implementation (bare user_type): class Foo : Bar (class_declaration diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 3cb3fc3eb6..cd9c03eaf5 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -360,12 +360,22 @@ const BUILT_INS = new Set([ 'preg_match', 'preg_match_all', 'preg_replace', 'preg_split', 'header', 'session_start', 'session_destroy', 'ob_start', 'ob_end_clean', 'ob_get_clean', 'dd', 'dump', - // Kotlin stdlib + // Kotlin stdlib (IMPORTANT: keep in sync with call-processor.ts BUILT_IN_NAMES) 'println', 'print', 'readLine', 'require', 'requireNotNull', 'check', 'assert', 'lazy', 'error', 'listOf', 'mapOf', 'setOf', 'mutableListOf', 'mutableMapOf', 'mutableSetOf', 'arrayOf', 'sequenceOf', 'also', 'apply', 'run', 'with', 'takeIf', 'takeUnless', 'TODO', 'buildString', 'buildList', 'buildMap', 'buildSet', 'repeat', 'synchronized', + // Kotlin coroutine builders & scope functions + 'launch', 'async', 'runBlocking', 'withContext', 'coroutineScope', + 'supervisorScope', 'delay', + // Kotlin Flow operators + 'flow', 'flowOf', 'collect', 'emit', 'onEach', 'catch', + 'buffer', 'conflate', 'distinctUntilChanged', + 'flatMapLatest', 'flatMapMerge', 'combine', + 'stateIn', 'shareIn', 'launchIn', + // Kotlin infix stdlib functions + 'to', 'until', 'downTo', 'step', ]); // ============================================================================ @@ -402,38 +412,51 @@ const getLabelFromCaptures = (captureMap: Record): string | null => return 'CodeElement'; }; -const getDefinitionNodeFromCaptures = (captureMap: Record): any | null => { - const definitionKeys = [ - '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', - ]; +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; - for (const key of definitionKeys) { +const getDefinitionNodeFromCaptures = (captureMap: Record): any | null => { + for (const key of DEFINITION_CAPTURE_KEYS) { if (captureMap[key]) return captureMap[key]; } return null; }; +/** + * Append .* to a Kotlin import path if the AST has a wildcard_import sibling node. + * Pure function — returns a new string without mutating the input. + */ +const appendKotlinWildcard = (importPath: string, importNode: any): string => { + for (let i = 0; i < importNode.childCount; i++) { + if (importNode.child(i)?.type === 'wildcard_import') { + return importPath.endsWith('.*') ? importPath : `${importPath}.*`; + } + } + return importPath; +}; + // ============================================================================ // Process a batch of files // ============================================================================ @@ -657,20 +680,9 @@ const processFileGroup = ( // Extract import paths before skipping if (captureMap['import'] && captureMap['import.source']) { - let rawImportPath = captureMap['import.source'].text.replace(/['"<>]/g, ''); - // Kotlin wildcard imports: wildcard_import is a separate AST node - // sibling to identifier, so check the import_header for it and append .* - if (language === SupportedLanguages.Kotlin) { - const importNode = captureMap['import']; - for (let i = 0; i < importNode.childCount; i++) { - if (importNode.child(i)?.type === 'wildcard_import') { - if (!rawImportPath.endsWith('.*')) { - rawImportPath += '.*'; - } - break; - } - } - } + const rawImportPath = language === SupportedLanguages.Kotlin + ? appendKotlinWildcard(captureMap['import.source'].text.replace(/['"<>]/g, ''), captureMap['import']) + : captureMap['import.source'].text.replace(/['"<>]/g, ''); result.imports.push({ filePath: file.path, rawImportPath, @@ -743,7 +755,7 @@ const processFileGroup = ( const definitionNode = getDefinitionNodeFromCaptures(captureMap); const frameworkHint = definitionNode - ? detectFrameworkFromAST(language, definitionNode.text || '') + ? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300)) : null; result.nodes.push({