diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index 9ab2733ed3..e33de66478 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -78,6 +78,9 @@ export type NodeProperties = { visibility?: string; isStatic?: boolean; isReadonly?: boolean; + isAbstract?: boolean; + isFinal?: boolean; + annotations?: string[]; // Route/response responseKeys?: string[]; errorKeys?: string[]; diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 6864777d11..070a4acb06 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -14,6 +14,7 @@ import type { LanguageTypeConfig } from './type-extractors/types.js'; import type { CallRouter } from './call-routing.js'; import type { ExportChecker } from './export-detection.js'; import type { FieldExtractor } from './field-extractor.js'; +import type { MethodExtractor } from './method-types.js'; import type { ImportResolverFn } from './import-resolvers/types.js'; import type { NamedBindingExtractorFn } from './named-bindings/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; @@ -126,6 +127,10 @@ interface LanguageProviderConfig { * declarations. Produces FieldInfo[] with name, type, visibility, static, * readonly metadata. Default: undefined (no field extraction). */ readonly fieldExtractor?: FieldExtractor; + /** Method extractor for extracting method/function definitions from class/struct/interface + * declarations. Produces MethodInfo[] with name, parameters, visibility, isAbstract, + * isFinal, annotations metadata. Default: undefined (no method extraction). */ + readonly methodExtractor?: MethodExtractor; /** Extract a semantic description for a definition node (e.g., PHP Eloquent * property arrays, relation method descriptions). * Default: undefined (no description extraction). */ diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index c7a1f34747..8f18056d3c 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -16,6 +16,8 @@ import { extractJavaNamedBindings } from '../named-bindings/java.js'; import { JAVA_QUERIES } from '../tree-sitter-queries.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { javaConfig } from '../field-extractors/configs/jvm.js'; +import { createMethodExtractor } from '../method-extractors/generic.js'; +import { javaMethodConfig } from '../method-extractors/configs/jvm.js'; export const javaProvider = defineLanguage({ id: SupportedLanguages.Java, @@ -28,4 +30,5 @@ export const javaProvider = defineLanguage({ interfaceNamePattern: /^I[A-Z]/, mroStrategy: 'implements-split', fieldExtractor: createFieldExtractor(javaConfig), + methodExtractor: createMethodExtractor(javaMethodConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index 45df2a0e29..6e93912f01 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -18,6 +18,8 @@ import { KOTLIN_QUERIES } from '../tree-sitter-queries.js'; import { isKotlinClassMethod } from '../utils/ast-helpers.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { kotlinConfig } from '../field-extractors/configs/jvm.js'; +import { createMethodExtractor } from '../method-extractors/generic.js'; +import { kotlinMethodConfig } from '../method-extractors/configs/jvm.js'; const BUILT_INS: ReadonlySet = new Set([ 'println', @@ -89,6 +91,7 @@ export const kotlinProvider = defineLanguage({ importPathPreprocessor: appendKotlinWildcard, mroStrategy: 'implements-split', fieldExtractor: createFieldExtractor(kotlinConfig), + methodExtractor: createMethodExtractor(kotlinMethodConfig), builtInNames: BUILT_INS, labelOverride: (functionNode, defaultLabel) => { if (defaultLabel !== 'Function') return defaultLabel; diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/jvm.ts b/gitnexus/src/core/ingestion/method-extractors/configs/jvm.ts new file mode 100644 index 0000000000..67af290bc6 --- /dev/null +++ b/gitnexus/src/core/ingestion/method-extractors/configs/jvm.ts @@ -0,0 +1,352 @@ +// gitnexus/src/core/ingestion/method-extractors/configs/jvm.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { + MethodExtractionConfig, + ParameterInfo, + MethodVisibility, +} from '../../method-types.js'; +import { findVisibility, hasModifier } from '../../field-extractors/configs/helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +// --------------------------------------------------------------------------- +// Shared JVM helpers +// --------------------------------------------------------------------------- + +const INTERFACE_OWNER_TYPES = new Set(['interface_declaration', 'annotation_type_declaration']); + +function extractReturnTypeFromField(node: SyntaxNode): string | undefined { + const typeNode = node.childForFieldName('type'); + if (!typeNode) return undefined; + return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); +} + +function extractAnnotations(node: SyntaxNode, modifierType: string): string[] { + const annotations: string[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type === modifierType) { + for (let j = 0; j < child.namedChildCount; j++) { + const mod = child.namedChild(j); + if (mod && (mod.type === 'marker_annotation' || mod.type === 'annotation')) { + const nameNode = mod.childForFieldName('name') ?? mod.firstNamedChild; + if (nameNode) annotations.push('@' + nameNode.text); + } + } + } + } + return annotations; +} + +// --------------------------------------------------------------------------- +// Java +// --------------------------------------------------------------------------- + +const JAVA_VIS = new Set(['public', 'private', 'protected']); + +function extractJavaParameters(node: SyntaxNode): ParameterInfo[] { + const params: ParameterInfo[] = []; + let paramList = node.childForFieldName('parameters'); + // Compact constructors have no parameter list — inherit from parent record_declaration + if (!paramList && node.type === 'compact_constructor_declaration') { + const recordNode = node.parent?.parent; // compact_ctor → class_body → record_declaration + if (recordNode?.type === 'record_declaration') { + paramList = recordNode.childForFieldName('parameters'); + } + } + if (!paramList) return params; + + for (let i = 0; i < paramList.namedChildCount; i++) { + const param = paramList.namedChild(i); + if (!param) continue; + + if (param.type === 'formal_parameter') { + const nameNode = param.childForFieldName('name'); + const typeNode = param.childForFieldName('type'); + if (nameNode) { + params.push({ + name: nameNode.text, + type: typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim()) : null, + isOptional: false, + isVariadic: false, + }); + } + } else if (param.type === 'spread_parameter') { + // Varargs: type_identifier + "..." + variable_declarator + let paramName: string | undefined; + let paramType: string | null = null; + for (let j = 0; j < param.namedChildCount; j++) { + const c = param.namedChild(j); + if (!c) continue; + if (c.type === 'variable_declarator') { + const nameChild = c.childForFieldName('name'); + paramName = nameChild?.text ?? c.text; + } else if ( + c.type === 'type_identifier' || + c.type === 'generic_type' || + c.type === 'scoped_type_identifier' || + c.type === 'integral_type' || + c.type === 'floating_point_type' || + c.type === 'boolean_type' + ) { + paramType = extractSimpleTypeName(c) ?? c.text?.trim(); + } + } + if (paramName) { + params.push({ + name: paramName, + type: paramType, + isOptional: false, + isVariadic: true, + }); + } + } + } + return params; +} + +export const javaMethodConfig: MethodExtractionConfig = { + language: SupportedLanguages.Java, + typeDeclarationNodes: [ + 'class_declaration', + 'interface_declaration', + 'enum_declaration', + 'record_declaration', + 'annotation_type_declaration', + ], + methodNodeTypes: [ + 'method_declaration', + 'constructor_declaration', + 'compact_constructor_declaration', + 'annotation_type_element_declaration', + ], + bodyNodeTypes: [ + 'class_body', + 'interface_body', + 'enum_body', + 'enum_body_declarations', + 'annotation_type_body', + ], + extractName(node) { + const nameNode = node.childForFieldName('name'); + return nameNode?.text; + }, + + extractReturnType: extractReturnTypeFromField, + + extractParameters: extractJavaParameters, + + extractVisibility(node) { + return findVisibility(node, JAVA_VIS, 'package', 'modifiers'); + }, + + isStatic(node) { + return hasModifier(node, 'modifiers', 'static'); + }, + + isAbstract(node, ownerNode) { + if (hasModifier(node, 'modifiers', 'abstract')) return true; + // Interface methods are implicitly abstract unless they have a body (default methods) + if (INTERFACE_OWNER_TYPES.has(ownerNode.type)) { + const body = node.childForFieldName('body'); + return !body; + } + return false; + }, + + isFinal(node) { + return hasModifier(node, 'modifiers', 'final'); + }, + + extractAnnotations(node) { + return extractAnnotations(node, 'modifiers'); + }, +}; + +// --------------------------------------------------------------------------- +// Kotlin +// --------------------------------------------------------------------------- + +const KOTLIN_VIS = new Set(['public', 'private', 'protected', 'internal']); + +function extractKotlinParameters(node: SyntaxNode): ParameterInfo[] { + const params: ParameterInfo[] = []; + // Kotlin: function_declaration > function_value_parameters > parameter + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type === 'function_value_parameters') { + let nextIsVariadic = false; + for (let j = 0; j < child.namedChildCount; j++) { + const param = child.namedChild(j); + if (!param) continue; + // parameter_modifiers containing vararg precedes the parameter node + if (param.type === 'parameter_modifiers') { + for (let m = 0; m < param.namedChildCount; m++) { + const mod = param.namedChild(m); + if (mod && mod.text === 'vararg') nextIsVariadic = true; + } + continue; + } + if (param.type !== 'parameter') continue; + + let paramName: string | undefined; + let paramType: string | null = null; + let hasDefault = false; + const isVariadic = nextIsVariadic; + nextIsVariadic = false; + + for (let k = 0; k < param.namedChildCount; k++) { + const part = param.namedChild(k); + if (!part) continue; + if (part.type === 'simple_identifier') { + paramName = part.text; + } else if ( + part.type === 'user_type' || + part.type === 'nullable_type' || + part.type === 'function_type' + ) { + paramType = extractSimpleTypeName(part) ?? part.text?.trim(); + } + } + + // Check for default value: `= expr` + for (let k = 0; k < param.childCount; k++) { + const c = param.child(k); + if (c && c.text === '=') { + hasDefault = true; + break; + } + } + + if (paramName) { + params.push({ + name: paramName, + type: paramType, + isOptional: hasDefault, + isVariadic: isVariadic, + }); + } + } + break; + } + } + + return params; +} + +function extractKotlinReturnType(node: SyntaxNode): string | undefined { + // Kotlin: return type appears after `:` following the parameter list + // In tree-sitter-kotlin, it's a user_type/nullable_type child after function_value_parameters + let seenParams = false; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if (child.type === 'function_value_parameters') { + seenParams = true; + continue; + } + if ( + seenParams && + (child.type === 'user_type' || + child.type === 'nullable_type' || + child.type === 'function_type') + ) { + return extractSimpleTypeName(child) ?? child.text?.trim(); + } + if (child.type === 'function_body') break; + } + return undefined; +} + +export const kotlinMethodConfig: MethodExtractionConfig = { + language: SupportedLanguages.Kotlin, + typeDeclarationNodes: ['class_declaration', 'object_declaration', 'companion_object'], + methodNodeTypes: ['function_declaration'], + bodyNodeTypes: ['class_body'], + extractName(node) { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'simple_identifier') return child.text; + } + return undefined; + }, + + extractReturnType: extractKotlinReturnType, + + extractParameters: extractKotlinParameters, + + extractVisibility(node) { + return findVisibility(node, KOTLIN_VIS, 'public', 'modifiers'); + }, + + isStatic(_node) { + // Kotlin has no static — companion object members are separate + return false; + }, + + isAbstract(node, ownerNode) { + if (hasModifier(node, 'modifiers', 'abstract')) return true; + // Interface methods without a body are abstract + // Kotlin interfaces: class_declaration with "interface" keyword child + for (let i = 0; i < ownerNode.childCount; i++) { + const child = ownerNode.child(i); + if (child && child.text === 'interface') { + const body = node.childForFieldName('body'); + // function_declaration > function_body + let hasBody = !!body; + if (!hasBody) { + for (let j = 0; j < node.namedChildCount; j++) { + const c = node.namedChild(j); + if (c && c.type === 'function_body') { + hasBody = true; + break; + } + } + } + return !hasBody; + } + } + return false; + }, + + isFinal(node) { + // Kotlin functions are closed (final) by default — only open/abstract/override makes them overridable + if (hasModifier(node, 'modifiers', 'open')) return false; + if (hasModifier(node, 'modifiers', 'abstract')) return false; + if (hasModifier(node, 'modifiers', 'override')) return false; + return true; + }, + + extractAnnotations(node) { + const annotations: string[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type === 'modifiers') { + for (let j = 0; j < child.namedChildCount; j++) { + const mod = child.namedChild(j); + if (mod && mod.type === 'annotation') { + // Kotlin annotation text includes the @ prefix + const text = mod.text.trim(); + annotations.push(text.startsWith('@') ? text : '@' + text); + } + } + } + } + return annotations; + }, + + extractReceiverType(node) { + // Extension function: user_type appears before the simple_identifier (name) + // e.g., fun String.format(template: String) → receiver is "String" + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if (child.type === 'simple_identifier') break; // past the name — no receiver + if (child.type === 'user_type' || child.type === 'nullable_type') { + return extractSimpleTypeName(child) ?? child.text?.trim(); + } + } + return undefined; + }, +}; diff --git a/gitnexus/src/core/ingestion/method-extractors/generic.ts b/gitnexus/src/core/ingestion/method-extractors/generic.ts new file mode 100644 index 0000000000..9bd16a6753 --- /dev/null +++ b/gitnexus/src/core/ingestion/method-extractors/generic.ts @@ -0,0 +1,167 @@ +// gitnexus/src/core/ingestion/method-extractors/generic.ts + +/** + * Generic table-driven method extractor factory. + * + * Mirrors field-extractors/generic.ts — define a config per language and + * generate extractors from configs. No class hierarchy needed. + */ + +import type { SyntaxNode } from '../utils/ast-helpers.js'; +import type { + MethodExtractor, + MethodExtractorContext, + MethodExtractionConfig, + ExtractedMethods, + MethodInfo, +} from '../method-types.js'; + +/** Owner node types where member functions are effectively static (JVM semantics). */ +const STATIC_OWNER_TYPES = new Set(['companion_object', 'object_declaration']); + +/** + * Create a MethodExtractor from a declarative config. + */ +export function createMethodExtractor(config: MethodExtractionConfig): MethodExtractor { + const typeDeclarationSet = new Set(config.typeDeclarationNodes); + const methodNodeSet = new Set(config.methodNodeTypes); + const bodyNodeSet = new Set(config.bodyNodeTypes); + + return { + language: config.language, + + isTypeDeclaration(node: SyntaxNode): boolean { + return typeDeclarationSet.has(node.type); + }, + + extract(node: SyntaxNode, context: MethodExtractorContext): ExtractedMethods | null { + if (!typeDeclarationSet.has(node.type)) return null; + + // Resolve owner name: field-based → type_identifier → simple_identifier → "Companion" + let ownerName: string | undefined; + const nameField = node.childForFieldName('name'); + if (nameField) { + ownerName = nameField.text; + } else { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && (child.type === 'type_identifier' || child.type === 'simple_identifier')) { + ownerName = child.text; + break; + } + } + } + // Unnamed companion objects use "Companion" (Kotlin convention) + if (!ownerName && node.type === 'companion_object') { + ownerName = 'Companion'; + } + if (!ownerName) return null; + + const methods: MethodInfo[] = []; + const bodies = findBodies(node, bodyNodeSet); + for (const body of bodies) { + extractMethodsFromBody(body, node, context, config, methodNodeSet, methods); + } + + return { ownerName, methods }; + }, + }; +} + +function findBodies(node: SyntaxNode, bodyNodeSet: Set): SyntaxNode[] { + const result: SyntaxNode[] = []; + const bodyField = node.childForFieldName('body'); + if (bodyField && bodyNodeSet.has(bodyField.type)) { + result.push(bodyField); + addNestedBodies(bodyField, bodyNodeSet, result); + return result; + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && bodyNodeSet.has(child.type)) { + result.push(child); + } + } + if (result.length === 0 && bodyField) { + // Fallback: body field exists but its type is not in bodyNodeTypes. + // This may indicate a config typo — log for debugging if NODE_ENV is development. + if (process.env.NODE_ENV === 'development') { + console.warn( + `[MethodExtractor] body field type '${bodyField.type}' not in bodyNodeTypes for node '${node.type}'`, + ); + } + result.push(bodyField); + addNestedBodies(bodyField, bodyNodeSet, result); + } + return result; +} + +function addNestedBodies(parent: SyntaxNode, bodyNodeSet: Set, out: SyntaxNode[]): void { + for (let i = 0; i < parent.namedChildCount; i++) { + const child = parent.namedChild(i); + if (child && bodyNodeSet.has(child.type) && !out.includes(child)) { + out.push(child); + } + } +} + +function extractMethodsFromBody( + body: SyntaxNode, + ownerNode: SyntaxNode, + context: MethodExtractorContext, + config: MethodExtractionConfig, + methodNodeSet: Set, + out: MethodInfo[], +): void { + for (let i = 0; i < body.namedChildCount; i++) { + const child = body.namedChild(i); + if (!child) continue; + + if (methodNodeSet.has(child.type)) { + const method = buildMethod(child, ownerNode, context, config); + if (method) out.push(method); + } + + // Recurse into enum constant anonymous class bodies + if (child.type === 'enum_constant') { + for (let j = 0; j < child.namedChildCount; j++) { + const innerBody = child.namedChild(j); + if (innerBody && innerBody.type === 'class_body') { + extractMethodsFromBody(innerBody, ownerNode, context, config, methodNodeSet, out); + } + } + } + } +} + +function buildMethod( + node: SyntaxNode, + ownerNode: SyntaxNode, + context: MethodExtractorContext, + config: MethodExtractionConfig, +): MethodInfo | null { + const name = config.extractName(node); + if (!name) return null; + + const isAbstract = config.isAbstract(node, ownerNode); + let isFinal = config.isFinal(node); + // Domain invariant: abstract methods cannot be final + if (isAbstract) isFinal = false; + + // companion_object / object_declaration members are effectively static on JVM + const isStatic = STATIC_OWNER_TYPES.has(ownerNode.type) || config.isStatic(node); + + return { + name, + receiverType: config.extractReceiverType?.(node) ?? null, + returnType: config.extractReturnType(node) ?? null, + parameters: config.extractParameters(node), + visibility: config.extractVisibility(node), + isStatic, + isAbstract, + isFinal, + annotations: config.extractAnnotations?.(node) ?? [], + sourceFile: context.filePath, + line: node.startPosition.row + 1, + }; +} diff --git a/gitnexus/src/core/ingestion/method-types.ts b/gitnexus/src/core/ingestion/method-types.ts new file mode 100644 index 0000000000..a53c8aeb24 --- /dev/null +++ b/gitnexus/src/core/ingestion/method-types.ts @@ -0,0 +1,61 @@ +// gitnexus/src/core/ingestion/method-types.ts + +import type { SupportedLanguages } from 'gitnexus-shared'; +import type { FieldVisibility } from './field-types.js'; +import type { SyntaxNode } from './utils/ast-helpers.js'; + +// Reuse FieldVisibility — same set of language visibility levels +export type MethodVisibility = FieldVisibility; + +export interface ParameterInfo { + name: string; + type: string | null; + isOptional: boolean; + isVariadic: boolean; +} + +export interface MethodInfo { + name: string; + receiverType: string | null; + returnType: string | null; + parameters: ParameterInfo[]; + visibility: MethodVisibility; + isStatic: boolean; + isAbstract: boolean; + isFinal: boolean; + annotations: string[]; + sourceFile: string; + line: number; +} + +export interface MethodExtractorContext { + filePath: string; + language: SupportedLanguages; +} + +export interface ExtractedMethods { + ownerName: string; + methods: MethodInfo[]; +} + +export interface MethodExtractor { + language: SupportedLanguages; + extract(node: SyntaxNode, context: MethodExtractorContext): ExtractedMethods | null; + isTypeDeclaration(node: SyntaxNode): boolean; +} + +export interface MethodExtractionConfig { + language: SupportedLanguages; + typeDeclarationNodes: string[]; + methodNodeTypes: string[]; + bodyNodeTypes: string[]; + extractName: (node: SyntaxNode) => string | undefined; + extractReturnType: (node: SyntaxNode) => string | undefined; + extractParameters: (node: SyntaxNode) => ParameterInfo[]; + extractVisibility: (node: SyntaxNode) => MethodVisibility; + isStatic: (node: SyntaxNode) => boolean; + isAbstract: (node: SyntaxNode, ownerNode: SyntaxNode) => boolean; + isFinal: (node: SyntaxNode) => boolean; + extractAnnotations?: (node: SyntaxNode) => string[]; + extractReceiverType?: (node: SyntaxNode) => string | undefined; +} diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index d5b699f48e..654526ce8e 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -65,6 +65,8 @@ export const FUNCTION_NODE_TYPES = new Set([ // Java 'method_declaration', 'constructor_declaration', + 'compact_constructor_declaration', + 'annotation_type_element_declaration', // C/C++ // 'function_definition' already included above // Go diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index c374346071..3891ca71fc 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -68,6 +68,7 @@ import { preprocessImportPath } from '../import-processor.js'; import type { NamedBinding } from '../named-bindings/types.js'; import type { NodeLabel } from 'gitnexus-shared'; import type { FieldInfo, FieldExtractorContext } from '../field-types.js'; +import type { MethodInfo, MethodExtractorContext } from '../method-types.js'; import { CLASS_CONTAINER_TYPES } from '../utils/ast-helpers.js'; // ============================================================================ @@ -121,6 +122,9 @@ interface ParsedSymbol { visibility?: string; isStatic?: boolean; isReadonly?: boolean; + isAbstract?: boolean; + isFinal?: boolean; + annotations?: string[]; } export interface ExtractedImport { @@ -323,6 +327,7 @@ const clearCaches = (): void => { functionIdCache.clear(); exportCache.clear(); fieldInfoCache.clear(); + methodInfoCache.clear(); }; // ============================================================================ @@ -385,6 +390,41 @@ function getFieldInfo( return cached; } +// ============================================================================ +// MethodExtractor cache — extract method metadata once per class, reuse for each method. +// Keyed by class node startIndex (unique per AST node within a file). +// ============================================================================ + +const methodInfoCache = new Map>(); + +/** + * Get (or extract and cache) method info for a class node. + * Returns a "name:line" → MethodInfo map, or undefined if the provider has no method extractor + * or the class yielded no methods. + * Keyed by name:line (not name alone) to support overloaded methods in Java/Kotlin. + */ +function getMethodInfo( + classNode: SyntaxNode, + provider: LanguageProvider, + context: MethodExtractorContext, +): Map | undefined { + if (!provider.methodExtractor) return undefined; + + const cacheKey = classNode.startIndex; + let cached = methodInfoCache.get(cacheKey); + if (cached) return cached; + + const result = provider.methodExtractor.extract(classNode, context); + if (!result?.methods?.length) return undefined; + + cached = new Map(); + for (const method of result.methods) { + cached.set(`${method.name}:${method.line}`, method); + } + methodInfoCache.set(cacheKey, cached); + return cached; +} + // ============================================================================ // Enclosing function detection (for call extraction) — cached // ============================================================================ @@ -1660,12 +1700,52 @@ const processFileGroup = ( let visibility: string | undefined; let isStatic: boolean | undefined; let isReadonly: boolean | undefined; + let isAbstract: boolean | undefined; + let isFinal: boolean | undefined; + let annotations: string[] | undefined; if (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') { - const sig = extractMethodSignature(definitionNode); - parameterCount = sig.parameterCount; - requiredParameterCount = sig.requiredParameterCount; - parameterTypes = sig.parameterTypes; - returnType = sig.returnType; + // Try MethodExtractor first — it provides everything extractMethodSignature does, plus + // isAbstract/isFinal/annotations. Only fall back to extractMethodSignature when no + // MethodExtractor is available or the method isn't inside a class body. + let enrichedByMethodExtractor = false; + if (provider.methodExtractor && definitionNode) { + const classNode = findEnclosingClassNode(definitionNode); + if (classNode) { + const methodMap = getMethodInfo(classNode, provider, { + filePath: file.path, + language, + }); + const defLine = definitionNode.startPosition.row + 1; + const info = methodMap?.get(`${nodeName}:${defLine}`); + if (info) { + enrichedByMethodExtractor = true; + parameterCount = info.parameters.length; + const types: string[] = []; + let optionalCount = 0; + for (const p of info.parameters) { + if (p.type !== null) types.push(p.type); + if (p.isOptional) optionalCount++; + } + parameterTypes = types.length > 0 ? types : undefined; + requiredParameterCount = + optionalCount > 0 ? parameterCount - optionalCount : undefined; + returnType = info.returnType ?? undefined; + visibility = info.visibility; + isStatic = info.isStatic; + isAbstract = info.isAbstract; + isFinal = info.isFinal; + if (info.annotations.length > 0) annotations = info.annotations; + } + } + } + + if (!enrichedByMethodExtractor) { + const sig = extractMethodSignature(definitionNode); + parameterCount = sig.parameterCount; + requiredParameterCount = sig.requiredParameterCount; + parameterTypes = sig.parameterTypes; + returnType = sig.returnType; + } // Language-specific return type fallback (e.g. Ruby YARD @return [Type]) // Also upgrades uninformative AST types like PHP `array` with PHPDoc `@return User[]` @@ -1730,6 +1810,9 @@ const processFileGroup = ( ...(visibility !== undefined ? { visibility } : {}), ...(isStatic !== undefined ? { isStatic } : {}), ...(isReadonly !== undefined ? { isReadonly } : {}), + ...(isAbstract !== undefined ? { isAbstract } : {}), + ...(isFinal !== undefined ? { isFinal } : {}), + ...(annotations !== undefined ? { annotations } : {}), }, }); @@ -1758,6 +1841,9 @@ const processFileGroup = ( ...(visibility !== undefined ? { visibility } : {}), ...(isStatic !== undefined ? { isStatic } : {}), ...(isReadonly !== undefined ? { isReadonly } : {}), + ...(isAbstract !== undefined ? { isAbstract } : {}), + ...(isFinal !== undefined ? { isFinal } : {}), + ...(annotations !== undefined ? { annotations } : {}), }); const fileId = generateId('File', file.path); diff --git a/gitnexus/test/unit/method-extraction.test.ts b/gitnexus/test/unit/method-extraction.test.ts new file mode 100644 index 0000000000..4d774f67c7 --- /dev/null +++ b/gitnexus/test/unit/method-extraction.test.ts @@ -0,0 +1,628 @@ +import { describe, it, expect } from 'vitest'; +import { createMethodExtractor } from '../../src/core/ingestion/method-extractors/generic.js'; +import { + javaMethodConfig, + kotlinMethodConfig, +} from '../../src/core/ingestion/method-extractors/configs/jvm.js'; +import type { MethodExtractorContext } from '../../src/core/ingestion/method-types.js'; +import Parser from 'tree-sitter'; +import Java from 'tree-sitter-java'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; + +let Kotlin: unknown; +try { + Kotlin = require('tree-sitter-kotlin'); +} catch { + // Kotlin grammar may not be installed +} + +const parser = new Parser(); + +const parseJava = (code: string) => { + parser.setLanguage(Java); + return parser.parse(code); +}; + +const parseKotlin = (code: string) => { + if (!Kotlin) throw new Error('tree-sitter-kotlin not available'); + parser.setLanguage(Kotlin as Parser.Language); + return parser.parse(code); +}; + +const javaCtx: MethodExtractorContext = { + filePath: 'Test.java', + language: SupportedLanguages.Java, +}; + +const kotlinCtx: MethodExtractorContext = { + filePath: 'Test.kt', + language: SupportedLanguages.Kotlin, +}; + +// --------------------------------------------------------------------------- +// Java +// --------------------------------------------------------------------------- + +describe('Java MethodExtractor', () => { + const extractor = createMethodExtractor(javaMethodConfig); + + describe('isTypeDeclaration', () => { + it('recognizes class_declaration', () => { + const tree = parseJava('public class Foo { }'); + expect(extractor.isTypeDeclaration(tree.rootNode.child(0)!)).toBe(true); + }); + + it('recognizes interface_declaration', () => { + const tree = parseJava('public interface Bar { }'); + expect(extractor.isTypeDeclaration(tree.rootNode.child(0)!)).toBe(true); + }); + + it('recognizes enum_declaration', () => { + const tree = parseJava('public enum Color { RED, GREEN }'); + expect(extractor.isTypeDeclaration(tree.rootNode.child(0)!)).toBe(true); + }); + + it('rejects import_declaration', () => { + const tree = parseJava('import java.util.List;'); + expect(extractor.isTypeDeclaration(tree.rootNode.child(0)!)).toBe(false); + }); + }); + + describe('extract from class', () => { + it('extracts public method with parameters', () => { + const tree = parseJava(` + public class UserService { + public User findById(Long id, boolean active) { + return null; + } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + expect(result).not.toBeNull(); + expect(result!.ownerName).toBe('UserService'); + expect(result!.methods).toHaveLength(1); + + const m = result!.methods[0]; + expect(m.name).toBe('findById'); + expect(m.returnType).toBe('User'); + expect(m.visibility).toBe('public'); + expect(m.isStatic).toBe(false); + expect(m.isAbstract).toBe(false); + expect(m.isFinal).toBe(false); + expect(m.parameters).toHaveLength(2); + expect(m.parameters[0]).toEqual({ + name: 'id', + type: 'Long', + isOptional: false, + isVariadic: false, + }); + expect(m.parameters[1]).toEqual({ + name: 'active', + type: 'boolean', + isOptional: false, + isVariadic: false, + }); + }); + + it('extracts static method', () => { + const tree = parseJava(` + public class MathUtils { + public static int add(int a, int b) { + return a + b; + } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + expect(result!.methods[0].isStatic).toBe(true); + }); + + it('extracts final method', () => { + const tree = parseJava(` + public class Base { + public final void doSomething() { } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + expect(result!.methods[0].isFinal).toBe(true); + }); + + it('extracts private method', () => { + const tree = parseJava(` + public class Foo { + private void helper() { } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + expect(result!.methods[0].visibility).toBe('private'); + }); + + it('detects package-private (default) visibility', () => { + const tree = parseJava(` + public class Foo { + void internalMethod() { } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + expect(result!.methods[0].visibility).toBe('package'); + }); + + it('extracts annotations', () => { + const tree = parseJava(` + public class Service { + @Override + public String toString() { return ""; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + expect(result!.methods[0].annotations).toContain('@Override'); + }); + + it('extracts varargs parameter', () => { + const tree = parseJava(` + public class Formatter { + public String format(String template, Object... args) { return ""; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + const params = result!.methods[0].parameters; + + expect(params).toHaveLength(2); + expect(params[0].isVariadic).toBe(false); + expect(params[1].isVariadic).toBe(true); + expect(params[1].name).toBe('args'); + }); + + it('extracts void return type', () => { + const tree = parseJava(` + public class Foo { + public void doNothing() { } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + expect(result!.methods[0].returnType).toBe('void'); + }); + }); + + describe('extract overloaded methods', () => { + it('extracts all overloads without collision', () => { + const tree = parseJava(` + public class Repository { + public User find(Long id) { return null; } + public User find(String name, boolean active) { return null; } + public User find(String name, String email, int limit) { return null; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + expect(result).not.toBeNull(); + const finds = result!.methods.filter((m) => m.name === 'find'); + expect(finds).toHaveLength(3); + expect(finds.map((m) => m.parameters.length).sort()).toEqual([1, 2, 3]); + }); + }); + + describe('extract from abstract class', () => { + it('detects abstract methods', () => { + const tree = parseJava(` + public abstract class Shape { + public abstract double area(); + public double perimeter() { return 0; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + expect(result!.methods).toHaveLength(2); + + const areaMethod = result!.methods.find((m) => m.name === 'area'); + const perimeterMethod = result!.methods.find((m) => m.name === 'perimeter'); + + expect(areaMethod!.isAbstract).toBe(true); + expect(perimeterMethod!.isAbstract).toBe(false); + }); + }); + + describe('extract from interface', () => { + it('marks bodyless methods as abstract', () => { + const tree = parseJava(` + public interface Repository { + User findById(Long id); + List findAll(); + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + expect(result!.methods).toHaveLength(2); + expect(result!.methods[0].isAbstract).toBe(true); + expect(result!.methods[1].isAbstract).toBe(true); + }); + + it('marks default methods as non-abstract', () => { + const tree = parseJava(` + public interface Greeting { + void greet(); + default String name() { return "World"; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + const greet = result!.methods.find((m) => m.name === 'greet'); + const name = result!.methods.find((m) => m.name === 'name'); + + expect(greet!.isAbstract).toBe(true); + expect(name!.isAbstract).toBe(false); + }); + }); + + describe('extract from enum', () => { + it('extracts enum methods', () => { + const tree = parseJava(` + public enum Planet { + EARTH; + public double surfaceGravity() { return 9.8; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + expect(result!.methods.length).toBeGreaterThanOrEqual(1); + const sg = result!.methods.find((m) => m.name === 'surfaceGravity'); + expect(sg).toBeDefined(); + expect(sg!.returnType).toBe('double'); + }); + + it('extracts methods from enum constant anonymous class bodies', () => { + const tree = parseJava(` + public enum Operation { + PLUS { + public double apply(double x, double y) { return x + y; } + }; + public abstract double apply(double x, double y); + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + const applies = result!.methods.filter((m) => m.name === 'apply'); + expect(applies).toHaveLength(2); + const abstractApply = applies.find((m) => m.isAbstract); + const concreteApply = applies.find((m) => !m.isAbstract); + expect(abstractApply).toBeDefined(); + expect(concreteApply).toBeDefined(); + }); + }); + + describe('extract from annotation type', () => { + it('extracts annotation element declarations', () => { + const tree = parseJava(` + public @interface MyAnnotation { + String value(); + int count() default 0; + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + expect(result).not.toBeNull(); + expect(result!.ownerName).toBe('MyAnnotation'); + expect(result!.methods).toHaveLength(2); + expect(result!.methods.map((m) => m.name).sort()).toEqual(['count', 'value']); + }); + }); + + describe('extract from record', () => { + it('extracts compact constructor', () => { + const tree = parseJava(` + public record Point(int x, int y) { + public Point { + if (x < 0) throw new IllegalArgumentException(); + } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + expect(result).not.toBeNull(); + const ctor = result!.methods.find((m) => m.name === 'Point'); + expect(ctor).toBeDefined(); + // Compact constructors inherit parameters from the record components + expect(ctor!.parameters).toHaveLength(2); + expect(ctor!.parameters[0].name).toBe('x'); + expect(ctor!.parameters[1].name).toBe('y'); + }); + }); + + describe('extract primitive varargs', () => { + it('extracts int... vararg type', () => { + const tree = parseJava(` + public class MathUtils { + public int sum(int... nums) { return 0; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + const m = result!.methods[0]; + expect(m.parameters).toHaveLength(1); + expect(m.parameters[0].type).toBe('int'); + expect(m.parameters[0].isVariadic).toBe(true); + }); + }); + + describe('no methods', () => { + it('returns null for class with no methods', () => { + const tree = parseJava(` + public class Empty { + public int x; + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, javaCtx); + + // No method_declaration nodes → empty methods array + expect(result).not.toBeNull(); + expect(result!.methods).toHaveLength(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Kotlin +// --------------------------------------------------------------------------- + +const describeKotlin = Kotlin ? describe : describe.skip; + +describeKotlin('Kotlin MethodExtractor', () => { + const extractor = createMethodExtractor(kotlinMethodConfig); + + describe('extract from class', () => { + it('extracts public method with parameters', () => { + const tree = parseKotlin(` + class UserService { + fun findById(id: Long, active: Boolean): User? { + return null + } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, kotlinCtx); + + expect(result).not.toBeNull(); + expect(result!.ownerName).toBe('UserService'); + expect(result!.methods).toHaveLength(1); + + const m = result!.methods[0]; + expect(m.name).toBe('findById'); + expect(m.visibility).toBe('public'); + expect(m.isStatic).toBe(false); + expect(m.isAbstract).toBe(false); + expect(m.parameters).toHaveLength(2); + }); + + it('extracts private method', () => { + const tree = parseKotlin(` + class Foo { + private fun helper(): Int = 42 + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, kotlinCtx); + + const m = result!.methods.find((m) => m.name === 'helper'); + expect(m).toBeDefined(); + expect(m!.visibility).toBe('private'); + }); + }); + + describe('extract vararg parameter', () => { + it('detects vararg as isVariadic', () => { + const tree = parseKotlin(` + class Logger { + fun log(vararg messages: String) { } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, kotlinCtx); + + expect(result).not.toBeNull(); + const m = result!.methods[0]; + expect(m.parameters).toHaveLength(1); + expect(m.parameters[0].name).toBe('messages'); + expect(m.parameters[0].isVariadic).toBe(true); + }); + }); + + describe('extension functions', () => { + it('extracts receiverType for extension functions', () => { + const tree = parseKotlin(` + class StringUtils { + fun String.addBang(): String = this + "!" + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, kotlinCtx); + + expect(result).not.toBeNull(); + const m = result!.methods[0]; + expect(m.name).toBe('addBang'); + expect(m.receiverType).toBe('String'); + }); + + it('returns null receiverType for regular methods', () => { + const tree = parseKotlin(` + class Foo { + fun bar(): Int = 42 + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, kotlinCtx); + + expect(result!.methods[0].receiverType).toBeNull(); + }); + }); + + describe('extract from abstract class', () => { + it('detects abstract methods', () => { + const tree = parseKotlin(` + abstract class Shape { + abstract fun area(): Double + fun description(): String = "shape" + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, kotlinCtx); + + const area = result!.methods.find((m) => m.name === 'area'); + const desc = result!.methods.find((m) => m.name === 'description'); + + expect(area).toBeDefined(); + expect(area!.isAbstract).toBe(true); + expect(desc).toBeDefined(); + expect(desc!.isAbstract).toBe(false); + }); + }); + + describe('extract from interface', () => { + it('marks bodyless methods as abstract', () => { + const tree = parseKotlin(` + interface Repository { + fun findById(id: Long): Any? + fun findAll(): List + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, kotlinCtx); + + expect(result!.methods).toHaveLength(2); + for (const m of result!.methods) { + expect(m.isAbstract).toBe(true); + } + }); + }); + + describe('default visibility', () => { + it('defaults to public', () => { + const tree = parseKotlin(` + class Foo { + fun bar() { } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, kotlinCtx); + + expect(result!.methods[0].visibility).toBe('public'); + }); + }); + + describe('isFinal semantics', () => { + it('regular methods are final by default', () => { + const tree = parseKotlin(` + class Foo { + fun bar() { } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, kotlinCtx); + + expect(result!.methods[0].isFinal).toBe(true); + }); + + it('open methods are not final', () => { + const tree = parseKotlin(` + open class Foo { + open fun bar() { } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, kotlinCtx); + + expect(result!.methods[0].isFinal).toBe(false); + }); + + it('abstract methods are not final', () => { + const tree = parseKotlin(` + abstract class Foo { + abstract fun bar(): Int + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, kotlinCtx); + + expect(result!.methods[0].isFinal).toBe(false); + expect(result!.methods[0].isAbstract).toBe(true); + }); + + it('interface methods are not final (domain invariant)', () => { + const tree = parseKotlin(` + interface Foo { + fun bar(): Int + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, kotlinCtx); + + expect(result!.methods[0].isAbstract).toBe(true); + expect(result!.methods[0].isFinal).toBe(false); + }); + }); + + describe('companion object', () => { + it('extracts methods from companion object', () => { + const tree = parseKotlin(` + class UserService { + companion object { + fun create(): UserService = UserService() + } + } + `); + // companion_object is inside class_body + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.namedChild(1)!; + const companion = classBody.namedChild(0)!; + const result = extractor.extract(companion, kotlinCtx); + + expect(result).not.toBeNull(); + expect(result!.ownerName).toBe('Companion'); + expect(result!.methods).toHaveLength(1); + expect(result!.methods[0].name).toBe('create'); + expect(result!.methods[0].isStatic).toBe(true); + }); + + it('extracts methods from named companion object', () => { + const tree = parseKotlin(` + class Foo { + companion object Factory { + fun build(): Foo = Foo() + } + } + `); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.namedChild(1)!; + const companion = classBody.namedChild(0)!; + const result = extractor.extract(companion, kotlinCtx); + + expect(result).not.toBeNull(); + expect(result!.ownerName).toBe('Factory'); + expect(result!.methods[0].name).toBe('build'); + expect(result!.methods[0].isStatic).toBe(true); + }); + }); +});