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.