diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 346667c50..990ce19c9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -58,8 +58,40 @@ This repository is a **monorepo** with two main products: the **CLI / MCP packag | Web UI behavior | `gitnexus-web/src/` (components, workers, graph client). | | CI | `.github/workflows/*.yml`, `.github/actions/setup-gitnexus/`. | +## Known limitations + +### Overloaded method resolution + +Method and Constructor node IDs include an arity suffix (`#`) to +disambiguate overloaded methods. Two overloads with different parameter counts +produce distinct graph nodes: `Method:file:Class.method#1` vs +`Method:file:Class.method#2`. + +**Remaining limitation — same-arity overloads:** When two overloads share the +same parameter count but differ only in types (e.g. `save(int)` vs +`save(String)`), they still share a node ID. This is rare in practice; a future +enhancement may add type-hash disambiguation for languages with reliable type +extraction (see issue #574). + +**Variadic method matching:** When one side is variadic (`parameterCount` +undefined) and the other has a fixed count, `METHOD_IMPLEMENTS` edges are +emitted with confidence 0.7 instead of 1.0. Variadic methods like +`foo(String... args)` may superficially match `foo(String s)` by type but +are not guaranteed to be interchangeable across all languages (Java/Kotlin +accept this via varargs sugar; TypeScript, C#, Rust do not). + +**Confidence tiering** for `METHOD_IMPLEMENTS` edges: + +| Match quality | Confidence | When | +|---|---|---| +| Exact parameter types match | 1.0 | Both sides have `parameterTypes` arrays and they match | +| Arity (count) matches | 1.0 | Both sides have `parameterCount`, types unavailable | +| Variadic vs fixed | 0.7 | One side is variadic, other has fixed count | +| Lenient (insufficient info) | 0.7 | One or both sides lack type and count data | + ## Related docs +- [MIGRATION.md](MIGRATION.md) — breaking changes and migration guidance. - [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery. - [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents. - [TESTING.md](TESTING.md) — how to run tests. diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 000000000..ec9fdabc2 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,27 @@ +# Migration Guide + +## OVERRIDES → METHOD_OVERRIDES (PR #642) + +The `OVERRIDES` relationship type has been renamed to `METHOD_OVERRIDES` for +consistency with the new `METHOD_IMPLEMENTS` edge type. + +### Do I need to migrate? + +**No.** Backward compatibility is handled automatically at runtime: + +- `local-backend.ts` dual-reads both `OVERRIDES` and `METHOD_OVERRIDES` in all + impact-analysis and context queries. Existing stored graphs with `OVERRIDES` + edges continue to return correct results without any manual intervention. +- The `REL_TYPES` array in `schema-constants.ts` includes both names so Cypher + queries that reference either will work. + +### What happens on re-index? + +Running `npx gitnexus analyze` on a repository produces `METHOD_OVERRIDES` +edges going forward. The old `OVERRIDES` edges are replaced as part of the +normal full re-index. + +### When will the legacy alias be removed? + +The `OVERRIDES` compat alias will remain until a future major version. Removal +will be announced in this file and in the changelog before it happens. diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index ff660e814..49762d145 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -97,7 +97,8 @@ export type RelationshipType = | 'CONTAINS' | 'CALLS' | 'INHERITS' - | 'OVERRIDES' + | 'METHOD_OVERRIDES' + | 'METHOD_IMPLEMENTS' | 'IMPORTS' | 'USES' | 'DEFINES' diff --git a/gitnexus-shared/src/lbug/schema-constants.ts b/gitnexus-shared/src/lbug/schema-constants.ts index e30948492..0eca57286 100644 --- a/gitnexus-shared/src/lbug/schema-constants.ts +++ b/gitnexus-shared/src/lbug/schema-constants.ts @@ -55,7 +55,9 @@ export const REL_TYPES = [ 'HAS_METHOD', 'HAS_PROPERTY', 'ACCESSES', - 'OVERRIDES', + 'METHOD_OVERRIDES', + 'OVERRIDES', // Legacy compat alias — kept until all stored indexes are migrated + 'METHOD_IMPLEMENTS', 'MEMBER_OF', 'STEP_IN_PROCESS', 'HANDLES_ROUTE', diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index aa1994bf4..ffa607ed8 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -12,9 +12,10 @@ import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { yieldToEventLoop } from './utils/event-loop.js'; import { FUNCTION_NODE_TYPES, - extractFunctionName, findEnclosingClassId, findEnclosingClassInfo, + genericFuncName, + inferFunctionLabel, } from './utils/ast-helpers.js'; import { countCallArguments, @@ -234,7 +235,9 @@ const findEnclosingFunction = ( while (current) { if (FUNCTION_NODE_TYPES.has(current.type)) { - const { funcName, label } = extractFunctionName(current); + const efnResult = provider.methodExtractor?.extractFunctionName?.(current); + const funcName = efnResult?.funcName ?? genericFuncName(current); + const label = efnResult?.label ?? inferFunctionLabel(current.type); if (funcName) { const resolved = ctx.resolve(funcName, filePath); @@ -264,7 +267,20 @@ const findEnclosingFunction = ( } const classInfo = findEnclosingClassInfo(current, filePath); const qualifiedName = classInfo ? `${classInfo.className}.${funcName}` : funcName; - return generateId(finalLabel, `${filePath}:${qualifiedName}`); + // Include # suffix to match definition-phase Method/Constructor IDs. + // Use provider.methodExtractor.extractFromNode — same extractor as definition phase. + let arity: number | undefined; + if (finalLabel === 'Method' || finalLabel === 'Constructor') { + const language = getLanguageFromFilename(filePath); + const info = language + ? provider.methodExtractor?.extractFromNode?.(current, { filePath, language }) + : undefined; + if (info) { + arity = info.parameters.some((p) => p.isVariadic) ? undefined : info.parameters.length; + } + } + const arityTag = arity !== undefined ? `#${arity}` : ''; + return generateId(finalLabel, `${filePath}:${qualifiedName}${arityTag}`); } } @@ -299,7 +315,20 @@ const findEnclosingFunction = ( const qualifiedName = classInfo ? `${classInfo.className}.${customResult.funcName}` : customResult.funcName; - return generateId(finalLabel, `${filePath}:${qualifiedName}`); + // Include # suffix to match definition-phase Method/Constructor IDs. + const sigNode = current.previousSibling ?? current; + let arity2: number | undefined; + if (finalLabel === 'Method' || finalLabel === 'Constructor') { + const language = getLanguageFromFilename(filePath); + const info = language + ? provider.methodExtractor?.extractFromNode?.(sigNode, { filePath, language }) + : undefined; + if (info) { + arity2 = info.parameters.some((p) => p.isVariadic) ? undefined : info.parameters.length; + } + } + const arityTag2 = arity2 !== undefined ? `#${arity2}` : ''; + return generateId(finalLabel, `${filePath}:${qualifiedName}${arityTag2}`); } } @@ -600,6 +629,7 @@ export const processCalls = async ( importedReturnTypes, importedRawReturnTypes, enclosingFunctionFinder: provider?.enclosingFunctionFinder, + extractFunctionName: provider?.methodExtractor?.extractFunctionName, }); if (typeEnv && exportedTypeMap) { const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph); @@ -840,7 +870,8 @@ export const processCalls = async ( let p = callNode.parent; while (p) { if (FUNCTION_NODE_TYPES.has(p.type)) { - const { funcName } = extractFunctionName(p); + const funcName = + provider.methodExtractor?.extractFunctionName?.(p)?.funcName ?? genericFuncName(p); if (funcName) { scope = `${funcName}@${p.startIndex}`; break; @@ -1384,12 +1415,16 @@ const extractFuncNameFromScope = (scope: string): string => scope.slice(0, scope /** Extract the bare function name from a sourceId. * Handles both unqualified ("Function:filepath:funcName" → "funcName") - * and qualified ("Function:filepath:ClassName.funcName" → "funcName"). */ + * and qualified ("Function:filepath:ClassName.funcName" → "funcName"). + * Strips any trailing # suffix from Method/Constructor IDs. */ const extractFuncNameFromSourceId = (sourceId: string): string => { const lastColon = sourceId.lastIndexOf(':'); const segment = lastColon >= 0 ? sourceId.slice(lastColon + 1) : ''; const dotIdx = segment.lastIndexOf('.'); - return dotIdx >= 0 ? segment.slice(dotIdx + 1) : segment; + const raw = dotIdx >= 0 ? segment.slice(dotIdx + 1) : segment; + // Strip # suffix (e.g. "save#2" → "save") + const hashIdx = raw.indexOf('#'); + return hashIdx >= 0 ? raw.slice(0, hashIdx) : raw; }; /** diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 87bd3a7bf..8edd1c2b8 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -15,7 +15,19 @@ import { cCppExportChecker } from '../export-detection.js'; import { resolveCImport, resolveCppImport } from '../import-resolvers/standard.js'; import { C_QUERIES, CPP_QUERIES } from '../tree-sitter-queries.js'; -import { isCppInsideClassOrStruct } from '../utils/ast-helpers.js'; +/** + * Node types for standard function declarations that need C/C++ declarator handling. + * Used by cCppExtractFunctionName to determine how to extract the function name. + */ +const FUNCTION_DECLARATION_TYPES = new Set([ + 'function_declaration', + 'function_definition', + 'async_function_declaration', + 'generator_function_declaration', + 'function_item', +]); +import type { SyntaxNode } from '../utils/ast-helpers.js'; +import type { NodeLabel } from 'gitnexus-shared'; import type { LanguageProvider } from '../language-provider.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { @@ -132,6 +144,154 @@ const C_BUILT_INS: ReadonlySet = new Set([ 'put', ]); +/** + * C/C++ function name extraction — unwraps pointer_declarator / reference_declarator / + * function_declarator / qualified_identifier chains to find the actual function name. + * Handles field_identifier (method inside class body) and parenthesized_declarator. + */ +const cCppExtractFunctionName = ( + node: SyntaxNode, +): { funcName: string | null; label: NodeLabel } | null => { + if (!FUNCTION_DECLARATION_TYPES.has(node.type)) return null; + + let funcName: string | null = null; + let label: NodeLabel = 'Function'; + + // 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 node types in FUNCTION_DECLARATION_TYPES (e.g. function_item for Rust in C++ tree) + 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 ?? null; + } + + return { funcName, label }; +}; + +/** Check if a C/C++ function_definition is inside a class or struct body. + * Used by cppLabelOverride to skip duplicate function captures + * that are already covered by definition.method queries. */ +function isCppInsideClassOrStruct(functionNode: SyntaxNode): boolean { + let ancestor: SyntaxNode | null = functionNode?.parent ?? null; + while (ancestor) { + if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') return true; + ancestor = ancestor.parent; + } + return false; +} + /** Label override shared by C and C++: skip function_definition captures inside class/struct * bodies (they're duplicates of definition.method captures). */ const cppLabelOverride: NonNullable = ( @@ -151,7 +311,10 @@ export const cProvider = defineLanguage({ importResolver: resolveCImport, importSemantics: 'wildcard', fieldExtractor: createFieldExtractor(cFieldConfig), - methodExtractor: createMethodExtractor(cMethodConfig), + methodExtractor: createMethodExtractor({ + ...cMethodConfig, + extractFunctionName: cCppExtractFunctionName, + }), labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, }); @@ -166,7 +329,10 @@ export const cppProvider = defineLanguage({ importSemantics: 'wildcard', mroStrategy: 'leftmost-base', fieldExtractor: createFieldExtractor(cppFieldConfig), - methodExtractor: createMethodExtractor(cppMethodConfig), + methodExtractor: createMethodExtractor({ + ...cppMethodConfig, + extractFunctionName: cCppExtractFunctionName, + }), labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index 808f4f157..cdd53c8d8 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -12,7 +12,7 @@ import type { SyntaxNode } from '../utils/ast-helpers.js'; import type { NodeLabel } from 'gitnexus-shared'; -import { FUNCTION_NODE_TYPES, extractFunctionName } from '../utils/ast-helpers.js'; +import { FUNCTION_NODE_TYPES } from '../utils/ast-helpers.js'; import { SupportedLanguages } from 'gitnexus-shared'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as dartConfig } from '../type-extractors/dart.js'; @@ -30,8 +30,8 @@ import { dartMethodConfig } from '../method-extractors/configs/dart.js'; * function_body are siblings under program or class_body, unlike most languages * where the function declaration wraps both. * - * Delegates name extraction to the shared `extractFunctionName` which already - * handles Dart's function_signature and method_signature node types. + * Extracts the function name inline — Dart uses function_signature and + * method_signature (which wraps function_signature) as its FUNCTION_NODE_TYPES. */ const dartEnclosingFunctionFinder = ( node: SyntaxNode, @@ -39,7 +39,21 @@ const dartEnclosingFunctionFinder = ( if (node.type !== 'function_body') return null; const prev = node.previousSibling; if (!prev || !FUNCTION_NODE_TYPES.has(prev.type)) return null; - const { funcName, label } = extractFunctionName(prev); + + // method_signature wraps function_signature — unwrap to reach the name + let target = prev; + let label: NodeLabel = 'Function'; + if (prev.type === 'method_signature') { + label = 'Method'; + for (let i = 0; i < prev.childCount; i++) { + const c = prev.child(i); + if (c?.type === 'function_signature') { + target = c; + break; + } + } + } + const funcName = target.childForFieldName?.('name')?.text ?? null; return funcName ? { funcName, label } : null; }; diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index 6e93912f0..639713c1a 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -15,12 +15,26 @@ import { resolveKotlinImport } from '../import-resolvers/jvm.js'; import { extractKotlinNamedBindings } from '../named-bindings/kotlin.js'; import { appendKotlinWildcard } from '../import-resolvers/jvm.js'; import { KOTLIN_QUERIES } from '../tree-sitter-queries.js'; -import { isKotlinClassMethod } from '../utils/ast-helpers.js'; +import type { SyntaxNode } 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'; +/** 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. */ +function isKotlinClassMethod( + captureNode: { parent?: SyntaxNode | null } | null | undefined, +): boolean { + let ancestor = captureNode?.parent; + while (ancestor) { + if (ancestor.type === 'class_body') return true; + ancestor = ancestor.parent; + } + return false; +} + const BUILT_INS: ReadonlySet = new Set([ 'println', 'print', diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index cff82fc1d..8b488fafb 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -8,7 +8,9 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import type { NodeLabel } from 'gitnexus-shared'; import { defineLanguage } from '../language-provider.js'; +import type { SyntaxNode } from '../utils/ast-helpers.js'; import { typeConfig as rubyConfig } from '../type-extractors/ruby.js'; import { routeRubyCall } from '../call-routing.js'; import { rubyExportChecker } from '../export-detection.js'; @@ -19,6 +21,25 @@ import { rubyConfig as rubyFieldConfig } from '../field-extractors/configs/ruby. import { createMethodExtractor } from '../method-extractors/generic.js'; import { rubyMethodConfig } from '../method-extractors/configs/ruby.js'; +/** Ruby method/singleton_method: extract name from 'name' field, label as Method. */ +const rubyExtractFunctionName = ( + node: SyntaxNode, +): { funcName: string | null; label: NodeLabel } | null => { + if (node.type !== 'method' && node.type !== 'singleton_method') return null; + + 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; + } + } + } + return { funcName: nameNode?.text ?? null, label: 'Method' }; +}; + const BUILT_INS: ReadonlySet = new Set([ 'puts', 'p', @@ -87,6 +108,9 @@ export const rubyProvider = defineLanguage({ callRouter: routeRubyCall, importSemantics: 'wildcard', fieldExtractor: createFieldExtractor(rubyFieldConfig), - methodExtractor: createMethodExtractor(rubyMethodConfig), + methodExtractor: createMethodExtractor({ + ...rubyMethodConfig, + extractFunctionName: rubyExtractFunctionName, + }), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index 8dc4ea30b..db8e2ad5b 100644 --- a/gitnexus/src/core/ingestion/languages/rust.ts +++ b/gitnexus/src/core/ingestion/languages/rust.ts @@ -11,7 +11,9 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import type { NodeLabel } from 'gitnexus-shared'; import { defineLanguage } from '../language-provider.js'; +import type { SyntaxNode } from '../utils/ast-helpers.js'; import { typeConfig as rustConfig } from '../type-extractors/rust.js'; import { rustExportChecker } from '../export-detection.js'; import { resolveRustImport } from '../import-resolvers/rust.js'; @@ -22,6 +24,35 @@ import { rustConfig as rustFieldConfig } from '../field-extractors/configs/rust. import { createMethodExtractor } from '../method-extractors/generic.js'; import { rustMethodConfig } from '../method-extractors/configs/rust.js'; +/** Rust impl_item: find the function_item child and extract its name as a Method. */ +const rustExtractFunctionName = ( + node: SyntaxNode, +): { funcName: string | null; label: NodeLabel } | null => { + if (node.type !== 'impl_item') return null; + + 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) return null; + + 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; + } + } + } + return { funcName: nameNode?.text ?? null, label: 'Method' }; +}; + const BUILT_INS: ReadonlySet = new Set([ 'unwrap', 'expect', @@ -89,6 +120,9 @@ export const rustProvider = defineLanguage({ namedBindingExtractor: extractRustNamedBindings, mroStrategy: 'qualified-syntax', fieldExtractor: createFieldExtractor(rustFieldConfig), - methodExtractor: createMethodExtractor(rustMethodConfig), + methodExtractor: createMethodExtractor({ + ...rustMethodConfig, + extractFunctionName: rustExtractFunctionName, + }), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index d9fb77e8f..f6753c397 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -11,12 +11,14 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import type { NodeLabel } from 'gitnexus-shared'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as swiftConfig } from '../type-extractors/swift.js'; import { swiftExportChecker } from '../export-detection.js'; import { resolveSwiftImport } from '../import-resolvers/swift.js'; import { SWIFT_QUERIES } from '../tree-sitter-queries.js'; import type { SwiftPackageConfig } from '../language-config.js'; +import type { SyntaxNode } from '../utils/ast-helpers.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { swiftConfig as swiftFieldConfig } from '../field-extractors/configs/swift.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; @@ -109,6 +111,15 @@ function wireSwiftImplicitImports( } } +/** Swift init/deinit declarations have special names and Constructor label. */ +const swiftExtractFunctionName = ( + node: SyntaxNode, +): { funcName: string | null; label: NodeLabel } | null => { + if (node.type === 'init_declaration') return { funcName: 'init', label: 'Constructor' }; + if (node.type === 'deinit_declaration') return { funcName: 'deinit', label: 'Constructor' }; + return null; // fall through to generic +}; + const BUILT_INS: ReadonlySet = new Set([ 'print', 'debugPrint', @@ -229,7 +240,10 @@ export const swiftProvider = defineLanguage({ importSemantics: 'wildcard', heritageDefaultEdge: 'IMPLEMENTS', fieldExtractor: createFieldExtractor(swiftFieldConfig), - methodExtractor: createMethodExtractor(swiftMethodConfig), + methodExtractor: createMethodExtractor({ + ...swiftMethodConfig, + extractFunctionName: swiftExtractFunctionName, + }), implicitImportWirer: wireSwiftImplicitImports, builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 80cf1ad8d..7563704e3 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -8,7 +8,9 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import type { NodeLabel } from 'gitnexus-shared'; import { defineLanguage } from '../language-provider.js'; +import type { SyntaxNode } from '../utils/ast-helpers.js'; import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js'; import { tsExportChecker } from '../export-detection.js'; import { resolveTypescriptImport, resolveJavascriptImport } from '../import-resolvers/standard.js'; @@ -23,6 +25,31 @@ import { javascriptMethodConfig, } from '../method-extractors/configs/typescript-javascript.js'; +/** + * TypeScript/JavaScript: arrow_function and function_expression get their name + * from the parent variable_declarator (e.g. `const foo = () => {}`). + */ +const tsExtractFunctionName = ( + node: SyntaxNode, +): { funcName: string | null; label: NodeLabel } | null => { + if (node.type !== 'arrow_function' && node.type !== 'function_expression') return null; + + const parent = node.parent; + if (parent?.type !== 'variable_declarator') return null; + + 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; + } + } + } + return { funcName: nameNode?.text ?? null, label: 'Function' }; +}; + export const BUILT_INS: ReadonlySet = new Set([ 'console', 'log', @@ -129,7 +156,10 @@ export const typescriptProvider = defineLanguage({ importResolver: resolveTypescriptImport, namedBindingExtractor: extractTsNamedBindings, fieldExtractor: typescriptFieldExtractor, - methodExtractor: createMethodExtractor(typescriptMethodConfig), + methodExtractor: createMethodExtractor({ + ...typescriptMethodConfig, + extractFunctionName: tsExtractFunctionName, + }), builtInNames: BUILT_INS, }); @@ -142,6 +172,9 @@ export const javascriptProvider = defineLanguage({ importResolver: resolveJavascriptImport, namedBindingExtractor: extractTsNamedBindings, fieldExtractor: createFieldExtractor(javascriptConfig), - methodExtractor: createMethodExtractor(javascriptMethodConfig), + methodExtractor: createMethodExtractor({ + ...javascriptMethodConfig, + extractFunctionName: tsExtractFunctionName, + }), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/csharp.ts b/gitnexus/src/core/ingestion/method-extractors/configs/csharp.ts index 34d43f658..da7daca96 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/csharp.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/csharp.ts @@ -187,6 +187,7 @@ export const csharpMethodConfig: MethodExtractionConfig = { 'destructor_declaration', 'operator_declaration', 'conversion_operator_declaration', + 'local_function_statement', ], bodyNodeTypes: ['declaration_list'], diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/php.ts b/gitnexus/src/core/ingestion/method-extractors/configs/php.ts index aee8cd512..9efc45a85 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/php.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/php.ts @@ -14,6 +14,57 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js'; // PHP helpers // --------------------------------------------------------------------------- +/** Regex to extract PHPDoc @return annotations: `@return User` */ +const PHPDOC_RETURN_RE = /@return\s+(\S+)/; + +/** Node types to skip when walking backwards through siblings for PHPDoc. */ +const PHPDOC_SKIP_NODE_TYPES: ReadonlySet = new Set(['attribute_list', 'attribute']); + +/** + * Normalize a PHPDoc return type for the MethodExtractor. + * Strips nullable prefix, null/false/void unions, namespace prefixes, and + * rejects uninformative types (mixed, void, self, static, object, array). + */ +function normalizePhpReturnType(raw: string): string | undefined { + let type = raw.startsWith('?') ? raw.slice(1) : raw; + const parts = type + .split('|') + .filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed'); + if (parts.length !== 1) return undefined; + type = parts[0]; + const segments = type.split('\\'); + type = segments[segments.length - 1]; + if ( + type === 'mixed' || + type === 'void' || + type === 'self' || + type === 'static' || + type === 'object' || + type === 'array' + ) + return undefined; + if (/^\w+(\[\])?$/.test(type) || /^\w+\s*(['public', 'private', 'protected']); /** @@ -52,6 +103,9 @@ function hasModifierNode(node: SyntaxNode, modifierType: string): boolean { * It appears as a type node (primitive_type, named_type, union_type, * optional_type, nullable_type, intersection_type) after the formal_parameters * and a `:` token separator. + * + * When the AST return type is missing or uninformative (`array` / `iterable`), + * falls back to parsing PHPDoc `@return Type` from preceding doc comments. */ function extractPhpReturnType(node: SyntaxNode): string | undefined { const TYPE_NODE_TYPES = new Set([ @@ -63,6 +117,7 @@ function extractPhpReturnType(node: SyntaxNode): string | undefined { 'intersection_type', ]); + let astType: string | undefined; let seenParams = false; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); @@ -73,14 +128,22 @@ function extractPhpReturnType(node: SyntaxNode): string | undefined { } // After the parameters node, look for the colon and then the type if (seenParams && child.isNamed && TYPE_NODE_TYPES.has(child.type)) { - return child.text?.trim(); + astType = child.text?.trim(); + break; } // Stop at body or semicolon if (child.type === 'compound_statement' || (!child.isNamed && child.text === ';')) { break; } } - return undefined; + + // If AST type is missing or uninformative, try PHPDoc @return fallback + if (!astType || astType === 'array' || astType === 'iterable') { + const docType = extractPhpDocReturnType(node); + if (docType) return docType; + } + + return astType; } /** @@ -208,7 +271,7 @@ export const phpMethodConfig: MethodExtractionConfig = { 'trait_declaration', 'enum_declaration', ], - methodNodeTypes: ['method_declaration'], + methodNodeTypes: ['method_declaration', 'function_definition'], bodyNodeTypes: ['declaration_list'], extractName(node) { diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/ruby.ts b/gitnexus/src/core/ingestion/method-extractors/configs/ruby.ts index f4ce41db5..097dea2f3 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/ruby.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/ruby.ts @@ -15,6 +15,50 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js'; const VISIBILITY_MODIFIERS = new Set(['private', 'protected', 'public']); +/** Regex to extract YARD `@return [Type]` annotations from comments. */ +const YARD_RETURN_RE = /@return\s+\[([^\]]+)\]/; + +/** + * Extract the simple type name from a YARD type string. + * Handles qualified types ("Models::User" -> "User"), generics ("Array" + * -> "Array"), nullable ("String, nil" -> "String"), and rejects ambiguous + * unions ("String, Integer" -> undefined). + */ +function extractYardTypeName(yardType: string): string | undefined { + const trimmed = yardType.trim(); + + // Bracket-balanced split on commas to handle generics like Hash + const parts: string[] = []; + let depth = 0, + start = 0; + for (let i = 0; i < trimmed.length; i++) { + if (trimmed[i] === '<') depth++; + else if (trimmed[i] === '>') depth--; + else if (trimmed[i] === ',' && depth === 0) { + parts.push(trimmed.slice(start, i).trim()); + start = i + 1; + } + } + parts.push(trimmed.slice(start).trim()); + const filtered = parts.filter((p) => p !== '' && p !== 'nil'); + if (filtered.length !== 1) return undefined; // ambiguous union + + const typePart = filtered[0]; + + // Qualified: "Models::User" -> "User" + const segments = typePart.split('::'); + const last = segments[segments.length - 1]; + + // Generic: "Array" -> "Array" + const genericMatch = last.match(/^(\w+)\s*[<{(]/); + if (genericMatch) return genericMatch[1]; + + // Simple identifier + if (/^\w+$/.test(last)) return last; + + return undefined; +} + /** * Extract visibility for a Ruby method by walking backwards through the * parent body_statement's named children from the method node's position. @@ -166,8 +210,30 @@ export const rubyMethodConfig: MethodExtractionConfig = { return nameNode?.text; }, - extractReturnType(_node) { - // Ruby has no type annotations — return type is always null + extractReturnType(node) { + // Walk backwards through preceding siblings looking for YARD @return [Type]. + // Try direct siblings first, then fall back to parent (body_statement) siblings + // for class methods where the comment may be a sibling of the body_statement. + const search = (startNode: SyntaxNode): string | undefined => { + let sibling = startNode.previousSibling; + while (sibling) { + if (sibling.type === 'comment') { + const match = YARD_RETURN_RE.exec(sibling.text); + if (match) return extractYardTypeName(match[1]); + } else if (sibling.isNamed) { + break; + } + sibling = sibling.previousSibling; + } + return undefined; + }; + + const result = search(node); + if (result) return result; + + if (node.parent?.type === 'body_statement') { + return search(node.parent); + } return undefined; }, diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/typescript-javascript.ts b/gitnexus/src/core/ingestion/method-extractors/configs/typescript-javascript.ts index 08bed31ea..1a18c87e0 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/typescript-javascript.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/typescript-javascript.ts @@ -125,8 +125,46 @@ function extractTsJsParameters(node: SyntaxNode): ParameterInfo[] { return params; } +/** Regex to extract @returns or @return from JSDoc comments: `@returns {Type}` */ +const JSDOC_RETURN_RE = /@returns?\s*\{([^}]+)\}/; + +/** + * Minimal sanitization for JSDoc return types — preserves generic wrappers + * (e.g. `Promise`) so that extractReturnTypeName in call-processor + * can apply WRAPPER_GENERICS unwrapping. Only strips JSDoc-specific syntax markers. + */ +function sanitizeJsDocReturnType(raw: string): string | undefined { + let type = raw.trim(); + // Strip JSDoc nullable/non-nullable prefixes: ?User → User, !User → User + if (type.startsWith('?') || type.startsWith('!')) type = type.slice(1); + // Strip module: prefix — module:models.User → models.User + if (type.startsWith('module:')) type = type.slice(7); + // Reject unions (ambiguous) + if (type.includes('|')) return undefined; + if (!type) return undefined; + return type; +} + +/** + * Walk backwards through preceding siblings looking for a JSDoc comment containing + * `@returns {Type}` or `@return {Type}`. Stops at the first non-comment named node + * (excluding decorators, which precede methods in TS/JS). + */ +function extractJsDocReturnType(node: SyntaxNode): string | undefined { + let sibling = node.previousSibling; + while (sibling) { + if (sibling.type === 'comment') { + const match = JSDOC_RETURN_RE.exec(sibling.text); + if (match) return sanitizeJsDocReturnType(match[1]); + } else if (sibling.isNamed && sibling.type !== 'decorator') break; + sibling = sibling.previousSibling; + } + return undefined; +} + /** * Extract return type from return_type field, unwrapping type_annotation. + * Falls back to JSDoc `@returns {Type}` when the AST has no return type annotation. * * tree-sitter-typescript uses `return_type` as the field name (not `type` like JVM). * The return_type field points to a type_annotation node that must be unwrapped. @@ -140,7 +178,8 @@ function extractTsJsReturnType(node: SyntaxNode): string | undefined { } return returnType.text?.trim(); } - return undefined; + // AST has no return type annotation — try JSDoc fallback + return extractJsDocReturnType(node); } /** @@ -227,7 +266,14 @@ const shared: Omit = { // are not discovered because class_expression is not in typeDeclarationNodes. // - declare module / declare global augmentations — methods inside ambient_module_declaration // wrappers are not surfaced because the top-level walker doesn't descend into them. - methodNodeTypes: ['method_definition', 'method_signature', 'abstract_method_signature'], + methodNodeTypes: [ + 'method_definition', + 'method_signature', + 'abstract_method_signature', + 'function_declaration', + 'generator_function_declaration', + 'function_signature', + ], bodyNodeTypes: ['class_body', 'interface_body'], extractName(node) { diff --git a/gitnexus/src/core/ingestion/method-extractors/generic.ts b/gitnexus/src/core/ingestion/method-extractors/generic.ts index d4df84feb..054aa3f58 100644 --- a/gitnexus/src/core/ingestion/method-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/method-extractors/generic.ts @@ -86,6 +86,8 @@ export function createMethodExtractor(config: MethodExtractionConfig): MethodExt if (!methodNodeSet.has(node.type)) return null; return buildMethod(node, node, context, config); }, + + ...(config.extractFunctionName ? { extractFunctionName: config.extractFunctionName } : {}), }; } diff --git a/gitnexus/src/core/ingestion/method-types.ts b/gitnexus/src/core/ingestion/method-types.ts index 30b34474a..11a613804 100644 --- a/gitnexus/src/core/ingestion/method-types.ts +++ b/gitnexus/src/core/ingestion/method-types.ts @@ -48,6 +48,14 @@ export interface MethodExtractor { isTypeDeclaration(node: SyntaxNode): boolean; /** Extract method info from a standalone method node (e.g. Go top-level method_declaration). */ extractFromNode?(node: SyntaxNode, context: MethodExtractorContext): MethodInfo | null; + /** Extract function name + label from an AST node during parent-walk. + * Languages with non-standard AST structures (e.g. C/C++ declarator + * unwrapping, Swift init/deinit, Rust impl_item) provide this hook + * to replace the generic name-field lookup. + * Return null to fall through to the generic extractor. */ + extractFunctionName?( + node: SyntaxNode, + ): { funcName: string | null; label: import('gitnexus-shared').NodeLabel } | null; } export interface MethodExtractionConfig { @@ -75,4 +83,9 @@ export interface MethodExtractionConfig { ownerNode: SyntaxNode, context: MethodExtractorContext, ) => MethodInfo | null; + /** Extract function name + label from an AST node during parent-walk. + * Passed through to the MethodExtractor by createMethodExtractor. */ + extractFunctionName?: ( + node: SyntaxNode, + ) => { funcName: string | null; label: import('gitnexus-shared').NodeLabel } | null; } diff --git a/gitnexus/src/core/ingestion/mro-processor.ts b/gitnexus/src/core/ingestion/mro-processor.ts index 6d96bb2d9..6d8fe5f74 100644 --- a/gitnexus/src/core/ingestion/mro-processor.ts +++ b/gitnexus/src/core/ingestion/mro-processor.ts @@ -3,7 +3,7 @@ * * Walks the inheritance DAG (EXTENDS/IMPLEMENTS edges), collects methods from * each ancestor via HAS_METHOD edges, detects method-name collisions across - * parents, and applies language-specific resolution rules to emit OVERRIDES edges. + * parents, and applies language-specific resolution rules to emit METHOD_OVERRIDES edges. * * Language-specific rules: * - C++: leftmost base class in declaration order wins @@ -13,10 +13,10 @@ * - Rust: no auto-resolution — requires qualified syntax, resolvedTo = null * - Default: single inheritance — first definition wins * - * OVERRIDES edge direction: Class → Method (not Method → Method). + * METHOD_OVERRIDES edge direction: Class → Method (not Method → Method). * The source is the child class that inherits conflicting methods, * the target is the winning ancestor method node. - * Cypher: MATCH (c:Class)-[r:CodeRelation {type: 'OVERRIDES'}]->(m:Method) + * Cypher: MATCH (c:Class)-[r:CodeRelation {type: 'METHOD_OVERRIDES'}]->(m:Method) */ import { KnowledgeGraph } from '../graph/types.js'; @@ -47,6 +47,7 @@ export interface MROResult { entries: MROEntry[]; overrideEdges: number; ambiguityCount: number; + methodImplementsEdges: number; } // --------------------------------------------------------------------------- @@ -289,6 +290,10 @@ export function computeMRO(graph: KnowledgeGraph): MROResult { let overrideEdges = 0; let ambiguityCount = 0; + // Pre-computed maps to avoid redundant BFS in emitMethodImplementsEdges + const ancestorsMap = new Map(); + const edgeTypesMap = new Map>(); + // Process every class that has at least one parent for (const [classId, directParents] of parentMap) { if (directParents.length === 0) continue; @@ -302,12 +307,16 @@ export function computeMRO(graph: KnowledgeGraph): MROResult { // Compute linearized MRO depending on language strategy const provider = getProvider(language); + const ancestors = gatherAncestors(classId, parentMap); + ancestorsMap.set(classId, ancestors); + edgeTypesMap.set(classId, buildTransitiveEdgeTypes(classId, parentMap, parentEdgeType)); + let mroOrder: string[]; if (provider.mroStrategy === 'c3') { const c3Result = c3Linearize(classId, parentMap, c3Cache); - mroOrder = c3Result ?? gatherAncestors(classId, parentMap); + mroOrder = c3Result ?? ancestors; } else { - mroOrder = gatherAncestors(classId, parentMap); + mroOrder = ancestors; } // Get the parent names for the MRO entry @@ -348,11 +357,9 @@ export function computeMRO(graph: KnowledgeGraph): MROResult { // Detect collisions: methods defined in 2+ different ancestors const ambiguities: MethodAmbiguity[] = []; - // Compute transitive edge types once per class (only needed for implements-split languages) + // Use pre-computed transitive edge types (only needed for implements-split languages) const needsEdgeTypes = provider.mroStrategy === 'implements-split'; - const classEdgeTypes = needsEdgeTypes - ? buildTransitiveEdgeTypes(classId, parentMap, parentEdgeType) - : undefined; + const classEdgeTypes = needsEdgeTypes ? edgeTypesMap.get(classId) : undefined; for (const [methodName, defs] of methodsByName) { if (defs.length < 2) continue; @@ -401,13 +408,13 @@ export function computeMRO(graph: KnowledgeGraph): MROResult { ambiguityCount++; } - // Emit OVERRIDES edge if resolution found + // Emit METHOD_OVERRIDES edge if resolution found if (resolution.resolvedTo !== null) { graph.addRelationship({ - id: generateId('OVERRIDES', `${classId}->${resolution.resolvedTo}`), + id: generateId('METHOD_OVERRIDES', `${classId}->${resolution.resolvedTo}`), sourceId: classId, targetId: resolution.resolvedTo, - type: 'OVERRIDES', + type: 'METHOD_OVERRIDES', confidence: resolution.confidence, reason: resolution.reason, }); @@ -424,7 +431,389 @@ export function computeMRO(graph: KnowledgeGraph): MROResult { }); } - return { entries, overrideEdges, ambiguityCount }; + const methodImplementsEdges = emitMethodImplementsEdges( + graph, + parentMap, + methodMap, + parentEdgeType, + ancestorsMap, + edgeTypesMap, + ); + + return { entries, overrideEdges, ambiguityCount, methodImplementsEdges }; +} + +// --------------------------------------------------------------------------- +// METHOD_IMPLEMENTS edge emission +// --------------------------------------------------------------------------- + +/** + * Check if two parameter type arrays match. + * When either side has no type info, fall back to parameterCount comparison + * (arity-compatible matching). If both have parameterCount and they differ, + * return no match. If counts match, return confident match. If either count + * is undefined, return lenient (non-confident) match. + * + * Returns `{ match, confident }`: + * - Exact type match → `{ match: true, confident: true }` + * - Arity match (both have parameterCount, counts equal) → `{ match: true, confident: true }` + * - Lenient (either side lacks types AND lacks parameterCount) → `{ match: true, confident: false }` + * - No match → `{ match: false, confident: false }` + */ +function parameterTypesMatch( + a: string[], + b: string[], + aParamCount?: number, + bParamCount?: number, +): { match: boolean; confident: boolean } { + // If one side is variadic and the other isn't, types may match superficially + // but the methods aren't guaranteed to be interchangeable + if ((aParamCount === undefined) !== (bParamCount === undefined)) { + return { match: true, confident: false }; + } + + if (a.length === 0 || b.length === 0) { + // Fall back to arity check when type info is missing + if (aParamCount !== undefined && bParamCount !== undefined) { + return { match: aParamCount === bParamCount, confident: aParamCount === bParamCount }; + } + return { match: true, confident: false }; // lenient when either count is unknown + } + if (a.length !== b.length) return { match: false, confident: false }; + const exact = a.every((t, i) => t === b[i]); + return { match: exact, confident: exact }; +} + +/** + * For each concrete class that implements/extends an interface or trait, + * find methods in the class that implement methods defined in the interface + * and emit METHOD_IMPLEMENTS edges: ConcreteMethod → InterfaceMethod. + * + * Method node IDs include a `#` arity suffix, so overloaded + * methods with different parameter counts are distinct nodes in the graph. + * + * **Remaining limitation — same-arity overloads:** When two overloads share + * the same parameter count but differ only in types (e.g. `save(int)` vs + * `save(String)`), they still collapse to one node ID. This is rare in + * practice; a future enhancement may add type-hash disambiguation for + * languages with reliable type extraction (see issue #574). + */ +function emitMethodImplementsEdges( + graph: KnowledgeGraph, + parentMap: Map, + methodMap: Map, + parentEdgeType: Map>, + ancestorsMap: Map, + edgeTypesMap: Map>, +): number { + let edgeCount = 0; + + for (const [classId, parentIds] of parentMap) { + const classNode = graph.getNode(classId); + if (!classNode) continue; + + // Interfaces and traits declare contracts — they don't implement them + if (classNode.label === 'Interface' || classNode.label === 'Trait') continue; + + // Get this class's own methods + const ownMethodIds = methodMap.get(classId) ?? []; + + // Build a lookup: methodName → Array<{methodId, parameterTypes, parameterCount}> for own methods + const ownMethodsByName = new Map< + string, + Array<{ methodId: string; parameterTypes: string[]; parameterCount?: number }> + >(); + for (const methodId of ownMethodIds) { + const methodNode = graph.getNode(methodId); + if (!methodNode || methodNode.label === 'Property') continue; + // Abstract methods don't satisfy interface contracts + if (methodNode.properties.isAbstract === true) continue; + const name = methodNode.properties.name as string; + const parameterTypes = (methodNode.properties.parameterTypes as string[] | undefined) ?? []; + const parameterCount = methodNode.properties.parameterCount as number | undefined; + let bucket = ownMethodsByName.get(name); + if (!bucket) { + bucket = []; + ownMethodsByName.set(name, bucket); + } + bucket.push({ methodId, parameterTypes, parameterCount }); + } + + // Use pre-computed ancestors and edge types; fall back to computing if missing (safety) + const allAncestors = ancestorsMap.get(classId) ?? gatherAncestors(classId, parentMap); + const ancestorEdgeTypes = + edgeTypesMap.get(classId) ?? buildTransitiveEdgeTypes(classId, parentMap, parentEdgeType); + + // Dedup set: avoid duplicate edges from diamond paths + const emitted = new Set(); + + // For each ancestor, check if it's an interface/trait or classified as IMPLEMENTS + for (const ancestorId of allAncestors) { + const ancestorNode = graph.getNode(ancestorId); + if (!ancestorNode) continue; + + const isInterfaceLike = ancestorNode.label === 'Interface' || ancestorNode.label === 'Trait'; + const classifiedEdgeType = ancestorEdgeTypes.get(ancestorId); + if (!isInterfaceLike && classifiedEdgeType !== 'IMPLEMENTS') continue; + + // Get ancestor's methods + const ancestorMethodIds = methodMap.get(ancestorId) ?? []; + + for (const ancestorMethodId of ancestorMethodIds) { + const ancestorMethodNode = graph.getNode(ancestorMethodId); + if (!ancestorMethodNode || ancestorMethodNode.label === 'Property') continue; + + const ancestorName = ancestorMethodNode.properties.name as string; + const ancestorParamTypes = + (ancestorMethodNode.properties.parameterTypes as string[] | undefined) ?? []; + const ancestorParamCount = ancestorMethodNode.properties.parameterCount as + | number + | undefined; + + // Find matching method in own class by name + parameterTypes/arity + const candidates = ownMethodsByName.get(ancestorName); + + // Unit 3: If no own method matches, walk the EXTENDS chain to find inherited concrete method + if (!candidates || candidates.length === 0) { + const inherited = findInheritedMethod( + classId, + ancestorName, + ancestorParamTypes, + ancestorParamCount, + graph, + parentMap, + methodMap, + parentEdgeType, + ancestorMethodId, + ); + if (inherited) { + const edgeKey = `${inherited.methodId}->${ancestorMethodId}`; + if (!emitted.has(edgeKey)) { + emitted.add(edgeKey); + graph.addRelationship({ + id: generateId('METHOD_IMPLEMENTS', edgeKey), + sourceId: inherited.methodId, + targetId: ancestorMethodId, + type: 'METHOD_IMPLEMENTS', + confidence: inherited.confident ? 1.0 : 0.7, + reason: '', + }); + edgeCount++; + } + } + continue; + } + + // Unit 4: Filter candidates by type/arity match, then check for ambiguity + const matching: Array<{ + methodId: string; + parameterTypes: string[]; + parameterCount?: number; + confident: boolean; + }> = []; + for (const c of candidates) { + const result = parameterTypesMatch( + c.parameterTypes, + ancestorParamTypes, + c.parameterCount, + ancestorParamCount, + ); + if (result.match) { + matching.push({ ...c, confident: result.confident }); + } + } + + if (matching.length === 0) continue; + + // If multiple candidates match at name+arity level, emit no edge (ambiguous) + if (matching.length > 1) continue; + + const winner = matching[0]; + const edgeKey = `${winner.methodId}->${ancestorMethodId}`; + if (emitted.has(edgeKey)) continue; + emitted.add(edgeKey); + + graph.addRelationship({ + id: generateId('METHOD_IMPLEMENTS', edgeKey), + sourceId: winner.methodId, + targetId: ancestorMethodId, + type: 'METHOD_IMPLEMENTS', + confidence: winner.confident ? 1.0 : 0.7, + reason: '', + }); + edgeCount++; + } + } + } + + return edgeCount; +} + +/** + * Walk the class's EXTENDS chain to find the nearest concrete method matching + * the given name and parameter signature. If the EXTENDS chain yields no match, + * fall back to IMPLEMENTS parents and check for non-abstract default methods + * (e.g. Java default interface methods, Kotlin interface defaults). + * Returns the first matching method found in BFS order, or null. + */ +function findInheritedMethod( + classId: string, + methodName: string, + targetParamTypes: string[], + targetParamCount: number | undefined, + graph: KnowledgeGraph, + parentMap: Map, + methodMap: Map, + parentEdgeType: Map>, + /** Method ID to exclude from results (prevents self-edges when the ancestor + * method being matched lives on an IMPLEMENTS parent). */ + excludeMethodId?: string, +): { methodId: string; parameterTypes: string[]; confident: boolean } | null { + const visited = new Set(); + const queue: string[] = []; + + // Seed with direct EXTENDS parents only + const directParents = parentMap.get(classId) ?? []; + const directEdges = parentEdgeType.get(classId); + for (const pid of directParents) { + const et = directEdges?.get(pid); + if (et === 'EXTENDS') { + // Also check that the parent is not an Interface/Trait + const parentNode = graph.getNode(pid); + if (parentNode && parentNode.label !== 'Interface' && parentNode.label !== 'Trait') { + queue.push(pid); + } + } + } + + // Level-order BFS: process all ancestors at the current depth before + // advancing. Once any match is found at depth D, finish that depth and stop. + // Diamond dedup: same methodId via two paths at the same depth = 1 match. + let currentLevel = [...queue]; + + while (currentLevel.length > 0) { + const matches = new Map< + string, + { methodId: string; parameterTypes: string[]; confident: boolean } + >(); + const nextLevel: string[] = []; + + for (const ancestorId of currentLevel) { + if (visited.has(ancestorId)) continue; + visited.add(ancestorId); + + // Check this ancestor's methods + const methods = methodMap.get(ancestorId) ?? []; + for (const mid of methods) { + const mNode = graph.getNode(mid); + if (!mNode || mNode.label === 'Property') continue; + // Abstract inherited methods don't count as concrete implementations + if (mNode.properties.isAbstract === true) continue; + if (mNode.properties.name !== methodName) continue; + + const mParamTypes = (mNode.properties.parameterTypes as string[] | undefined) ?? []; + const mParamCount = mNode.properties.parameterCount as number | undefined; + const ptResult = parameterTypesMatch( + mParamTypes, + targetParamTypes, + mParamCount, + targetParamCount, + ); + if (ptResult.match) { + matches.set(mid, { + methodId: mid, + parameterTypes: mParamTypes, + confident: ptResult.confident, + }); + } + } + + // Collect EXTENDS parents for the next depth level + const grandparents = parentMap.get(ancestorId) ?? []; + const ancestorEdges = parentEdgeType.get(ancestorId); + for (const gp of grandparents) { + if (visited.has(gp)) continue; + const gpEdge = ancestorEdges?.get(gp); + if (gpEdge === 'EXTENDS') { + const gpNode = graph.getNode(gp); + if (gpNode && gpNode.label !== 'Interface' && gpNode.label !== 'Trait') { + nextLevel.push(gp); + } + } + } + } + + // If any matches found at this depth, decide and stop + if (matches.size === 1) return matches.values().next().value!; + if (matches.size > 1) return null; // ambiguous at same depth + + currentLevel = nextLevel; + } + + // ── Second pass: walk IMPLEMENTS parents AND their interface ancestry ── + // Only reached when the EXTENDS chain yielded no match. + // BFS through interface/trait hierarchy to find default (non-abstract) methods. + const implBfsQueue: string[] = []; + for (const pid of directParents) { + const et = directEdges?.get(pid); + if (et === 'IMPLEMENTS') { + implBfsQueue.push(pid); + } + } + + // Collect all matches from the IMPLEMENTS BFS — return null if ambiguous (>1 match) + const implMatches: Array<{ + methodId: string; + parameterTypes: string[]; + confident: boolean; + }> = []; + const implVisited = new Set(); + while (implBfsQueue.length > 0) { + const ifaceId = implBfsQueue.shift()!; + if (implVisited.has(ifaceId)) continue; + implVisited.add(ifaceId); + + // Only process Interface/Trait nodes — Dart `implements Class` does not + // inherit method bodies, so Class/Struct/Enum parents must be skipped. + const ifaceNode = graph.getNode(ifaceId); + if (!ifaceNode || (ifaceNode.label !== 'Interface' && ifaceNode.label !== 'Trait')) continue; + + // Check this interface/trait's methods for a non-abstract default + const methods = methodMap.get(ifaceId) ?? []; + for (const mid of methods) { + if (mid === excludeMethodId) continue; // prevent self-edges + const mNode = graph.getNode(mid); + if (!mNode || mNode.label === 'Property') continue; + if (mNode.properties.isAbstract === true) continue; + if (mNode.properties.name !== methodName) continue; + + const mParamTypes = (mNode.properties.parameterTypes as string[] | undefined) ?? []; + const mParamCount = mNode.properties.parameterCount as number | undefined; + const ptResult = parameterTypesMatch( + mParamTypes, + targetParamTypes, + mParamCount, + targetParamCount, + ); + if (ptResult.match) { + implMatches.push({ + methodId: mid, + parameterTypes: mParamTypes, + confident: ptResult.confident, + }); + } + } + + // Walk this interface's parents (interface-extends-interface chains) + const ifaceParents = parentMap.get(ifaceId) ?? []; + for (const gp of ifaceParents) { + if (!implVisited.has(gp)) implBfsQueue.push(gp); + } + } + + // Ambiguous: multiple interfaces provide the same default method + if (implMatches.length === 1) return implMatches[0]; + return null; // 0 matches or ambiguous (>1) } /** diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 42e2e4c12..8511ce06b 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -12,7 +12,6 @@ import { yieldToEventLoop } from './utils/event-loop.js'; import { getDefinitionNodeFromCaptures, findEnclosingClassInfo, - extractMethodSignature, getLabelFromCaptures, CLASS_CONTAINER_TYPES, type SyntaxNode, @@ -22,6 +21,7 @@ import { detectFrameworkFromAST } from './framework-detection.js'; import { buildTypeEnv } from './type-env.js'; import type { FieldInfo, FieldExtractorContext } from './field-types.js'; import type { MethodInfo } from './method-types.js'; +import { buildMethodProps, arityForIdFromInfo } from './utils/method-props.js'; import type { LanguageProvider } from './language-provider.js'; import { WorkerPool } from './workers/worker-pool.js'; import type { @@ -232,35 +232,6 @@ function seqFindEnclosingClassNode(node: SyntaxNode): SyntaxNode | null { return null; } -/** Convert MethodInfo from methodExtractor into flat properties for a graph node. */ -function buildMethodProps(info: MethodInfo): Record { - const types: string[] = []; - let optionalCount = 0; - let hasVariadic = false; - for (const p of info.parameters) { - if (p.type !== null) types.push(p.type); - if (p.isOptional) optionalCount++; - if (p.isVariadic) hasVariadic = true; - } - return { - parameterCount: hasVariadic ? undefined : info.parameters.length, - ...(!hasVariadic && optionalCount > 0 - ? { requiredParameterCount: info.parameters.length - optionalCount } - : {}), - ...(types.length > 0 ? { parameterTypes: types } : {}), - returnType: info.returnType ?? undefined, - visibility: info.visibility, - isStatic: info.isStatic, - isAbstract: info.isAbstract, - isFinal: info.isFinal, - ...(info.isVirtual ? { isVirtual: info.isVirtual } : {}), - ...(info.isOverride ? { isOverride: info.isOverride } : {}), - ...(info.isAsync ? { isAsync: info.isAsync } : {}), - ...(info.isPartial ? { isPartial: info.isPartial } : {}), - ...(info.annotations.length > 0 ? { annotations: info.annotations } : {}), - }; -} - /** Minimal no-op SymbolTable stub for FieldExtractorContext (sequential path has a real * SymbolTable, but it's incomplete at this stage — use the stub for safety). */ const NOOP_SYMBOL_TABLE_SEQ = { @@ -372,7 +343,10 @@ const processParsingSequential = async ( // Build per-file type environment for FieldExtractor context (lightweight — skipped if no fieldExtractor) const typeEnv = provider.fieldExtractor - ? buildTypeEnv(tree, language, { enclosingFunctionFinder: provider.enclosingFunctionFinder }) + ? buildTypeEnv(tree, language, { + enclosingFunctionFinder: provider.enclosingFunctionFinder, + extractFunctionName: provider.methodExtractor?.extractFunctionName, + }) : null; matches.forEach((match) => { @@ -414,18 +388,15 @@ const processParsingSequential = async ( const qualifiedName = enclosingClassInfo ? `${enclosingClassInfo.className}.${nodeName}` : nodeName; - const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}`); - const frameworkHint = definitionNode - ? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300)) - : null; - // Extract method metadata for Function/Method/Constructor nodes. - // Try the per-language methodExtractor first (provides isAbstract, isStatic, - // visibility, annotations, etc.). Fall back to extractMethodSignature for - // basic parameterCount/parameterTypes/returnType when no methodExtractor exists. + // Extract method metadata for Function/Method/Constructor nodes BEFORE generating + // the node ID — parameterCount is needed to disambiguate overloaded methods. + // Use the per-language MethodExtractor for method metadata (isAbstract, isStatic, + // visibility, annotations, parameterCount, parameterTypes, returnType, etc.). const isMethodLike = nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor'; let methodProps: Record = {}; + let arityForId: number | undefined; // raw param count for ID, even for variadic if (isMethodLike && definitionNode) { let enriched = false; @@ -452,6 +423,7 @@ const processParsingSequential = async ( const info = result.methods.find((m) => m.name === nodeName && m.line === defLine); if (info) { enriched = true; + arityForId = arityForIdFromInfo(info); methodProps = buildMethodProps(info); } } @@ -465,36 +437,22 @@ const processParsingSequential = async ( }); if (info) { enriched = true; + arityForId = arityForIdFromInfo(info); methodProps = buildMethodProps(info); } } } - - // Fallback to generic extractMethodSignature - if (!enriched) { - const sig = extractMethodSignature(definitionNode); - methodProps = { - parameterCount: sig.parameterCount, - ...(sig.requiredParameterCount !== undefined - ? { requiredParameterCount: sig.requiredParameterCount } - : {}), - ...(sig.parameterTypes ? { 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[]` - const rt = methodProps.returnType as string | undefined; - if (!rt || rt === 'array' || rt === 'iterable') { - const tc = provider.typeConfig; - if (tc?.extractReturnType) { - const docReturn = tc.extractReturnType(definitionNode); - if (docReturn) methodProps.returnType = docReturn; - } - } } + // Append # to Method/Constructor IDs to disambiguate overloads. + // Functions are not suffixed — they don't overload by name in the same scope. + const needsAritySuffix = nodeLabel === 'Method' || nodeLabel === 'Constructor'; + const arityTag = needsAritySuffix && arityForId !== undefined ? `#${arityForId}` : ''; + const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}${arityTag}`); + const frameworkHint = definitionNode + ? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300)) + : null; + const node: GraphNode = { id: nodeId, label: nodeLabel as NodeLabel, diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 5f7d21fe1..d49bdbf70 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -1103,7 +1103,7 @@ async function runChunkedParseAndResolve( * Post-parse graph analysis: MRO, community detection, process extraction. * * @reads graph (all nodes and relationships from parse + resolve phases) - * @writes graph (Community nodes, Process nodes, MEMBER_OF edges, STEP_IN_PROCESS edges, OVERRIDES edges) + * @writes graph (Community nodes, Process nodes, MEMBER_OF edges, STEP_IN_PROCESS edges, METHOD_OVERRIDES edges) */ async function runGraphAnalysisPhases( graph: ReturnType, @@ -1126,7 +1126,7 @@ async function runGraphAnalysisPhases( const mroResult = computeMRO(graph); if (isDev && mroResult.entries.length > 0) { console.log( - `🔀 MRO: ${mroResult.entries.length} classes analyzed, ${mroResult.ambiguityCount} ambiguities found, ${mroResult.overrideEdges} OVERRIDES edges`, + `🔀 MRO: ${mroResult.entries.length} classes analyzed, ${mroResult.ambiguityCount} ambiguities, ${mroResult.overrideEdges} METHOD_OVERRIDES, ${mroResult.methodImplementsEdges} METHOD_IMPLEMENTS`, ); } diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 02ec8c3a6..6bd469bee 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -1,8 +1,8 @@ import { type SyntaxNode, FUNCTION_NODE_TYPES, - extractFunctionName, CLASS_CONTAINER_TYPES, + genericFuncName, } from './utils/ast-helpers.js'; import { CALL_EXPRESSION_TYPES } from './utils/call-analysis.js'; import { SupportedLanguages } from 'gitnexus-shared'; @@ -132,6 +132,7 @@ const lookupInEnv = ( callNode: SyntaxNode, patternOverrides?: PatternOverrides, enclosingFunctionFinder?: (n: SyntaxNode) => { funcName: string; label: NodeLabel } | null, + extractFunctionNameHook?: (n: SyntaxNode) => { funcName: string | null; label: NodeLabel } | null, ): string | undefined => { // Self/this receiver: resolve to enclosing class name via AST walk if (varName === 'self' || varName === 'this' || varName === '$this') { @@ -145,7 +146,11 @@ const lookupInEnv = ( } // Determine the enclosing function scope for the call - const scopeKey = findEnclosingScopeKey(callNode, enclosingFunctionFinder); + const scopeKey = findEnclosingScopeKey( + callNode, + enclosingFunctionFinder, + extractFunctionNameHook, + ); // Check position-indexed pattern overrides first (e.g., Kotlin when/is smart casts). // These take priority over flat scopeEnv because they represent per-branch narrowing. @@ -361,11 +366,12 @@ const extractParentClassFromNode = (classNode: SyntaxNode): string | undefined = const findEnclosingScopeKey = ( node: SyntaxNode, enclosingFunctionFinder?: (n: SyntaxNode) => { funcName: string; label: NodeLabel } | null, + extractFunctionNameHook?: (n: SyntaxNode) => { funcName: string | null; label: NodeLabel } | null, ): string | undefined => { let current = node.parent; while (current) { if (FUNCTION_NODE_TYPES.has(current.type)) { - const { funcName } = extractFunctionName(current); + const funcName = extractFunctionNameHook?.(current)?.funcName ?? genericFuncName(current); if (funcName) return `${funcName}@${current.startIndex}`; } // Language-specific hook (e.g., Dart function_body → sibling function_signature) @@ -621,7 +627,15 @@ const resolveMethodReturnType = ( parentMap?: ReadonlyMap, ): string | undefined => { if (!symbolTable) return undefined; - const receiverType = scopeEnv.get(receiver); + let receiverType = scopeEnv.get(receiver); + // When substituteThisReceiver replaced $this/self with the enclosing class name, + // the receiver IS the type — look it up directly as a class name. + if (!receiverType) { + const lookup = + getClassDefs ?? + ((name: string) => symbolTable.lookupFuzzy(name).filter((d) => CLASS_LIKE_TYPES.has(d.type))); + if (lookup(receiver).length > 0) receiverType = receiver; + } if (!receiverType) return undefined; const lookup = getClassDefs ?? @@ -765,6 +779,11 @@ export interface BuildTypeEnvOptions { enclosingFunctionFinder?: ( ancestorNode: SyntaxNode, ) => { funcName: string; label: NodeLabel } | null; + /** Language-specific function name extraction from an AST node. + * Replaces the generic name-field lookup for languages with non-standard + * AST structures (C/C++ declarator unwrapping, Swift init/deinit, etc.). + * When null is returned or not provided, falls back to node.childForFieldName('name')?.text. */ + extractFunctionName?: (node: SyntaxNode) => { funcName: string | null; label: NodeLabel } | null; } /** Seed cross-file type bindings into the file scope. @@ -794,6 +813,7 @@ export const buildTypeEnv = ( const symbolTable = options?.symbolTable; const parentMap = options?.parentMap; + const extractFuncNameHook = options?.extractFunctionName; const env: TypeEnv = new Map(); const patternOverrides: PatternOverrides = new Map(); // Phase P: maps `scope\0varName` → constructor type when a declaration has BOTH @@ -1057,7 +1077,7 @@ export const buildTypeEnv = ( // Detect scope boundaries (function/method definitions) let scope = currentScope; if (FUNCTION_NODE_TYPES.has(node.type)) { - const { funcName } = extractFunctionName(node); + const funcName = extractFuncNameHook?.(node)?.funcName ?? genericFuncName(node); if (funcName) scope = `${funcName}@${node.startIndex}`; } @@ -1206,7 +1226,14 @@ export const buildTypeEnv = ( return { lookup: (varName, callNode) => - lookupInEnv(env, varName, callNode, patternOverrides, options?.enclosingFunctionFinder), + lookupInEnv( + env, + varName, + callNode, + patternOverrides, + options?.enclosingFunctionFinder, + extractFuncNameHook, + ), constructorBindings: bindings, fileScope: () => env.get(FILE_SCOPE) ?? EMPTY_FILE_SCOPE, allScopes: () => env as ReadonlyMap>, diff --git a/gitnexus/src/core/ingestion/type-extractors/php.ts b/gitnexus/src/core/ingestion/type-extractors/php.ts index c33c9b9b2..ca517ee90 100644 --- a/gitnexus/src/core/ingestion/type-extractors/php.ts +++ b/gitnexus/src/core/ingestion/type-extractors/php.ts @@ -6,7 +6,6 @@ import type { InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, - ReturnTypeExtractor, PendingAssignmentExtractor, ForLoopExtractor, } from './types.js'; @@ -337,60 +336,6 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => { return undefined; }; -/** Regex to extract PHPDoc @return annotations: `@return User` */ -const PHPDOC_RETURN_RE = /@return\s+(\S+)/; - -/** - * Normalize a PHPDoc return type for storage in the SymbolTable. - * Unlike normalizePhpType (which strips User[] → User for scopeEnv), this preserves - * array notation so lookupRawReturnType can extract element types for for-loop resolution. - * \App\Models\User[] → User[] - * ?User → User - * Collection → Collection (preserved for extractElementTypeFromString) - */ -const normalizePhpReturnType = (raw: string): string | undefined => { - // Strip nullable prefix: ?User[] → User[] - let type = raw.startsWith('?') ? raw.slice(1) : raw; - // Strip union with null/false/void: User[]|null → User[] - const parts = type - .split('|') - .filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed'); - if (parts.length !== 1) return undefined; - type = parts[0]; - // Strip namespace: \App\Models\User[] → User[] - const segments = type.split('\\'); - type = segments[segments.length - 1]; - // Skip uninformative types - if ( - type === 'mixed' || - type === 'void' || - type === 'self' || - type === 'static' || - type === 'object' || - type === 'array' - ) - return undefined; - if (/^\w+(\[\])?$/.test(type) || /^\w+\s* { - let sibling = node.previousSibling; - while (sibling) { - if (sibling.type === 'comment') { - const match = PHPDOC_RETURN_RE.exec(sibling.text); - if (match) return normalizePhpReturnType(match[1]); - } else if (sibling.isNamed && !SKIP_NODE_TYPES.has(sibling.type)) break; - sibling = sibling.previousSibling; - } - return undefined; -}; - /** PHP: $alias = $user → assignment_expression with variable_name left/right. * PHP TypeEnv stores variables WITH $ prefix ($user → User), so we keep $ in lhs/rhs. */ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => { @@ -605,7 +550,6 @@ export const typeConfig: LanguageTypeConfig = { extractParameter, extractInitializer, scanConstructorBinding, - extractReturnType, extractForLoopBinding, extractPendingAssignment, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/ruby.ts b/gitnexus/src/core/ingestion/type-extractors/ruby.ts index 4cef12d53..b6fe6ecdc 100644 --- a/gitnexus/src/core/ingestion/type-extractors/ruby.ts +++ b/gitnexus/src/core/ingestion/type-extractors/ruby.ts @@ -4,7 +4,6 @@ import type { TypeBindingExtractor, InitializerExtractor, ConstructorBindingScanner, - ReturnTypeExtractor, PendingAssignmentExtractor, ForLoopExtractor, } from './types.js'; @@ -43,9 +42,6 @@ const YARD_PARAM_RE = /@param\s+(\w+)\s+\[([^\]]+)\]/g; /** Alternate YARD order: `@param [Type] name` */ const YARD_PARAM_ALT_RE = /@param\s+\[([^\]]+)\]\s+(\w+)/g; -/** Regex to extract @return annotations: `@return [Type]` */ -const YARD_RETURN_RE = /@return\s+\[([^\]]+)\]/; - /** * Extract the simple type name from a YARD type string. * Handles: @@ -229,35 +225,6 @@ const extractInitializer: InitializerExtractor = (node, env, classNames): void = } }; -/** - * Extract return type from YARD `@return [Type]` annotation preceding a method. - * Reuses the same comment-walking strategy as collectYardParams: try direct - * siblings first, fall back to parent (body_statement) siblings for class methods. - */ -const extractReturnType: ReturnTypeExtractor = (node) => { - const search = (startNode: SyntaxNode): string | undefined => { - let sibling = startNode.previousSibling; - while (sibling) { - if (sibling.type === 'comment') { - const match = YARD_RETURN_RE.exec(sibling.text); - if (match) return extractYardTypeName(match[1]); - } else if (sibling.isNamed) { - break; - } - sibling = sibling.previousSibling; - } - return undefined; - }; - - const result = search(node); - if (result) return result; - - if (node.parent?.type === 'body_statement') { - return search(node.parent); - } - return undefined; -}; - /** * Ruby constructor binding scanner: captures both `user = User.new` and * plain call assignments like `user = get_user()`. @@ -452,7 +419,6 @@ export const typeConfig: LanguageTypeConfig = { extractParameter, extractInitializer, scanConstructorBinding, - extractReturnType, extractForLoopBinding, extractPendingAssignment, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/types.ts b/gitnexus/src/core/ingestion/type-extractors/types.ts index d232ec448..85da9d21a 100644 --- a/gitnexus/src/core/ingestion/type-extractors/types.ts +++ b/gitnexus/src/core/ingestion/type-extractors/types.ts @@ -30,11 +30,6 @@ export type ConstructorBindingScanner = ( node: SyntaxNode, ) => { varName: string; calleeName: string; receiverClassName?: string } | undefined; -/** Extracts a return type string from a method/function definition node. - * Used for languages where return types are expressed in comments (e.g. YARD @return [Type]) - * rather than in AST fields. Returns undefined if no return type can be determined. */ -export type ReturnTypeExtractor = (node: SyntaxNode) => string | undefined; - /** Infer the type name of a literal AST node for overload disambiguation. * Returns the canonical type name (e.g. 'int', 'String', 'boolean') or undefined * for non-literal nodes. Only used when resolveCallTarget has multiple candidates @@ -170,9 +165,6 @@ export interface LanguageTypeConfig { * Called on every AST node during buildTypeEnv walk; returns undefined for non-matches. * The callee binding is unverified — the caller must confirm against the SymbolTable. */ scanConstructorBinding?: ConstructorBindingScanner; - /** Extract return type from comment-based annotations (e.g. YARD @return [Type]). - * Called as fallback when extractMethodSignature finds no AST-based return type. */ - extractReturnType?: ReturnTypeExtractor; /** Extract loop variable → type binding from a for-each AST node. */ extractForLoopBinding?: ForLoopExtractor; /** Extract pending assignment for Tier 2 propagation. diff --git a/gitnexus/src/core/ingestion/type-extractors/typescript.ts b/gitnexus/src/core/ingestion/type-extractors/typescript.ts index a2d17c5fc..fcc58cff4 100644 --- a/gitnexus/src/core/ingestion/type-extractors/typescript.ts +++ b/gitnexus/src/core/ingestion/type-extractors/typescript.ts @@ -6,7 +6,6 @@ import type { InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, - ReturnTypeExtractor, PendingAssignmentExtractor, PendingAssignment, ForLoopExtractor, @@ -198,44 +197,6 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => { return { varName: nameNode.text, calleeName }; }; -/** Regex to extract @returns or @return from JSDoc comments: `@returns {Type}` */ -const JSDOC_RETURN_RE = /@returns?\s*\{([^}]+)\}/; - -/** - * Minimal sanitization for JSDoc return types — preserves generic wrappers - * (e.g. `Promise`) so that extractReturnTypeName in call-processor - * can apply WRAPPER_GENERICS unwrapping. Unlike normalizeJsDocType (which - * strips generics), this only strips JSDoc-specific syntax markers. - */ -const sanitizeReturnType = (raw: string): string | undefined => { - let type = raw.trim(); - // Strip JSDoc nullable/non-nullable prefixes: ?User → User, !User → User - if (type.startsWith('?') || type.startsWith('!')) type = type.slice(1); - // Strip module: prefix — module:models.User → models.User - if (type.startsWith('module:')) type = type.slice(7); - // Reject unions (ambiguous) - if (type.includes('|')) return undefined; - if (!type) return undefined; - return type; -}; - -/** - * Extract return type from JSDoc `@returns {Type}` or `@return {Type}` annotation - * preceding a function/method definition. Walks backwards through preceding siblings - * looking for comment nodes containing the annotation. - */ -const extractReturnType: ReturnTypeExtractor = (node) => { - let sibling = node.previousSibling; - while (sibling) { - if (sibling.type === 'comment') { - const match = JSDOC_RETURN_RE.exec(sibling.text); - if (match) return sanitizeReturnType(match[1]); - } else if (sibling.isNamed && sibling.type !== 'decorator') break; - sibling = sibling.previousSibling; - } - return undefined; -}; - const FOR_LOOP_NODE_TYPES: ReadonlySet = new Set(['for_in_statement']); /** TS function/method node types that carry a parameters list. */ @@ -742,7 +703,6 @@ export const typeConfig: LanguageTypeConfig = { extractParameter, extractInitializer, scanConstructorBinding, - extractReturnType, extractForLoopBinding, extractPendingAssignment, extractPatternBinding, diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 0bce8d926..825600ddd 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -2,7 +2,6 @@ import type Parser from 'tree-sitter'; import type { NodeLabel } from 'gitnexus-shared'; import type { LanguageProvider } from '../language-provider.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; @@ -48,7 +47,13 @@ export const getDefinitionNodeFromCaptures = ( /** * Node types that represent function/method definitions across languages. - * Used to find the enclosing function for a call site. + * Used by parent-walk in call-processor, parse-worker, and type-env to detect + * enclosing function scope boundaries. + * + * INVARIANT: This set MUST be a superset of every language's + * MethodExtractionConfig.methodNodeTypes. When adding a new node type to a + * MethodExtractor config, add it here too — otherwise enclosing-function + * resolution will silently miss that node type during parent-walks. */ export const FUNCTION_NODE_TYPES = new Set([ // TypeScript/JavaScript @@ -91,18 +96,6 @@ export const FUNCTION_NODE_TYPES = new Set([ 'method_signature', ]); -/** - * 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). * @@ -165,20 +158,6 @@ export const CONTAINER_TYPE_TO_LABEL: Record = { 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?: SyntaxNode | null } | null | undefined, -): boolean { - let ancestor = captureNode?.parent; - while (ancestor) { - if (ancestor.type === 'class_body') return true; - ancestor = ancestor.parent; - } - return false; -} - /** * Determine the graph node label from a tree-sitter capture map. * Handles language-specific reclassification via the provider's labelOverride hook @@ -337,7 +316,17 @@ export const findEnclosingClassInfo = ( c.type === 'constant', ); if (nameNode) { - const label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class'; + let label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class'; + // Kotlin: class_declaration with an anonymous "interface" keyword child + // is actually an interface, not a class. Refine the label to match the + // node ID generated from the tree-sitter query capture (@definition.interface). + if ( + current.type === 'class_declaration' && + label === 'Class' && + current.children?.some((c: SyntaxNode) => c.type === 'interface') + ) { + label = 'Interface'; + } return { classId: generateId(label, `${filePath}:${nameNode.text}`), className: nameNode.text, @@ -375,553 +364,49 @@ export const findSiblingChild = ( 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: NodeLabel } => { - let funcName: string | null = null; - let label: NodeLabel = 'Function'; - - // Swift init/deinit - if (node.type === 'init_declaration' || node.type === 'deinit_declaration') { - return { - funcName: node.type === 'init_declaration' ? 'init' : 'deinit', - label: 'Constructor', - }; +/** Generic name extraction from a function-like AST node. + * Tries `node.childForFieldName('name')?.text`, then scans children for + * `identifier` / `property_identifier` / `simple_identifier`. */ +export const genericFuncName = (node: SyntaxNode): string | null => { + const nameField = node.childForFieldName?.('name'); + if (nameField) return nameField.text; + 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' + ) + return c.text; } - - 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; - } - } 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'; - } else if (node.type === 'function_signature') { - // Dart: top-level function signatures - 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 ?? null; - } else if (node.type === 'method_signature') { - // Dart: method_signature wraps function_signature - let funcSig: SyntaxNode | null = null; - for (let i = 0; i < node.childCount; i++) { - const c = node.child(i); - if (c?.type === 'function_signature') { - funcSig = c; - break; - } - } - if (funcSig) { - let nameNode = funcSig.childForFieldName?.('name'); - if (!nameNode) { - for (let i = 0; i < funcSig.childCount; i++) { - const c = funcSig.child(i); - if (c?.type === 'identifier') { - nameNode = c; - break; - } - } - } - funcName = nameNode?.text ?? null; - } - label = 'Method'; - } - - return { funcName, label }; + 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; -} +/** AST node types that represent a method definition (for `inferFunctionLabel`). */ +export const METHOD_LABEL_NODE_TYPES = new Set([ + 'method_definition', + 'method_declaration', + 'method', + 'singleton_method', +]); -/** Argument list node types shared between extractMethodSignature and countCallArguments. */ +/** AST node types that represent a constructor definition (for `inferFunctionLabel`). */ +export const CONSTRUCTOR_LABEL_NODE_TYPES = new Set([ + 'constructor_declaration', + 'compact_constructor_declaration', +]); + +/** Infer node label from AST node type for function-like nodes without a provider hook. */ +export const inferFunctionLabel = (nodeType: string): NodeLabel => + METHOD_LABEL_NODE_TYPES.has(nodeType) + ? 'Method' + : CONSTRUCTOR_LABEL_NODE_TYPES.has(nodeType) + ? 'Constructor' + : 'Function'; + +/** Argument list node types shared between countCallArguments and call-resolution helpers. */ 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', - 'formal_parameter_list', // Dart - ]); - - // 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; - } - // TypeScript: `this` parameter is a compile-time type constraint, not a real param - // e.g., handle(this: void, event: Event) — only count 'event' - if (param.type === 'required_parameter') { - const patternNode = param.childForFieldName('pattern'); - if (patternNode?.type === 'this') 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; - } - } - } - } - - // Swift fallback: tree-sitter-swift places `parameter` nodes as direct children of - // function_declaration without a wrapping parameters/function_parameters list node. - // When no parameter list was found, count direct `parameter` children on the node. - if (!parameterList && parameterCount === 0) { - for (const child of node.namedChildren) { - if (child.type === 'parameter') { - if (!hasDefaultValue(child)) requiredCount++; - parameterCount++; - } - } - } - - // 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, - }; -}; - // ============================================================================ // Generic AST traversal helpers (shared by parse-worker + php-helpers) // ============================================================================ @@ -945,18 +430,6 @@ export function extractStringContent(node: SyntaxNode | null | undefined): strin return null; } -/** Check if a C/C++ function_definition is inside a class or struct body. - * Used by the C/C++ labelOverride to skip duplicate function captures - * that are already covered by definition.method queries. */ -export function isCppInsideClassOrStruct(functionNode: SyntaxNode): boolean { - let ancestor: SyntaxNode | null = functionNode?.parent ?? null; - while (ancestor) { - if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') return true; - ancestor = ancestor.parent; - } - return false; -} - /** Find the first direct named child of a tree-sitter node matching the given type. */ export function findChild(node: SyntaxNode, type: string): SyntaxNode | null { for (let i = 0; i < node.namedChildCount; i++) { diff --git a/gitnexus/src/core/ingestion/utils/method-props.ts b/gitnexus/src/core/ingestion/utils/method-props.ts new file mode 100644 index 000000000..7eb98004d --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/method-props.ts @@ -0,0 +1,38 @@ +import type { MethodInfo } from '../method-types.js'; + +/** + * Compute arity for ID-generation purposes. + * Returns `undefined` when any parameter is variadic (arity is indeterminate). + */ +export function arityForIdFromInfo(info: MethodInfo): number | undefined { + return info.parameters.some((p) => p.isVariadic) ? undefined : info.parameters.length; +} + +/** Convert MethodInfo from methodExtractor into flat properties for a graph node. */ +export function buildMethodProps(info: MethodInfo): Record { + const types: string[] = []; + let optionalCount = 0; + let hasVariadic = false; + for (const p of info.parameters) { + if (p.type !== null) types.push(p.type); + if (p.isOptional) optionalCount++; + if (p.isVariadic) hasVariadic = true; + } + return { + parameterCount: hasVariadic ? undefined : info.parameters.length, + ...(!hasVariadic && optionalCount > 0 + ? { requiredParameterCount: info.parameters.length - optionalCount } + : {}), + ...(types.length > 0 ? { parameterTypes: types } : {}), + returnType: info.returnType ?? undefined, + visibility: info.visibility, + isStatic: info.isStatic, + isAbstract: info.isAbstract, + isFinal: info.isFinal, + ...(info.isVirtual ? { isVirtual: info.isVirtual } : {}), + ...(info.isOverride ? { isOverride: info.isOverride } : {}), + ...(info.isAsync ? { isAsync: info.isAsync } : {}), + ...(info.isPartial ? { isPartial: info.isPartial } : {}), + ...(info.annotations.length > 0 ? { annotations: info.annotations } : {}), + }; +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index f34f0ef7d..8698c4201 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -41,14 +41,15 @@ try { import { getLanguageFromFilename } from 'gitnexus-shared'; import { FUNCTION_NODE_TYPES, - extractFunctionName, getDefinitionNodeFromCaptures, findEnclosingClassInfo, type EnclosingClassInfo, getLabelFromCaptures, - extractMethodSignature, findDescendant, extractStringContent, + genericFuncName, + inferFunctionLabel, + CLASS_CONTAINER_TYPES, type SyntaxNode, } from '../utils/ast-helpers.js'; import { @@ -75,7 +76,8 @@ 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'; +import { buildMethodProps, arityForIdFromInfo } from '../utils/method-props.js'; +import type { LanguageProvider } from '../language-provider.js'; // ============================================================================ // Types for serializable results @@ -94,14 +96,8 @@ interface ParsedNode { astFrameworkMultiplier?: number; astFrameworkReason?: string; description?: string; - parameterCount?: number; - requiredParameterCount?: number; - returnType?: string; - // Field/property metadata (populated by FieldExtractor) - declaredType?: string; - visibility?: string; - isStatic?: boolean; - isReadonly?: boolean; + // Method/field metadata — extensible via buildMethodProps spread + [key: string]: unknown; }; } @@ -511,8 +507,6 @@ function getMethodInfo( // Enclosing function detection (for call extraction) — cached // ============================================================================ -import type { LanguageProvider } from '../language-provider.js'; - /** Walk up AST to find enclosing function, return its generateId or null for top-level. * Applies provider.labelOverride so the label matches the definition phase (single source of truth). */ const findEnclosingFunctionId = ( @@ -526,7 +520,9 @@ const findEnclosingFunctionId = ( let current = node.parent; while (current) { if (FUNCTION_NODE_TYPES.has(current.type)) { - const { funcName, label } = extractFunctionName(current); + const efnResult = provider.methodExtractor?.extractFunctionName?.(current); + const funcName = efnResult?.funcName ?? genericFuncName(current); + const label = efnResult?.label ?? inferFunctionLabel(current.type); if (funcName) { // Apply labelOverride so label matches definition phase (e.g., Kotlin Function→Method). // null means "skip as definition" — keep original label for scope identification. @@ -538,7 +534,28 @@ const findEnclosingFunctionId = ( // Qualify with enclosing class to match definition-phase node IDs const classInfo = cachedFindEnclosingClassInfo(current, filePath); const qualifiedName = classInfo ? `${classInfo.className}.${funcName}` : funcName; - const result = generateId(finalLabel, `${filePath}:${qualifiedName}`); + // Include # suffix to match definition-phase Method/Constructor IDs. + // Use the same MethodExtractor (getMethodInfo) as the definition phase. + let arity: number | undefined; + if (finalLabel === 'Method' || finalLabel === 'Constructor') { + const classNode = + findEnclosingClassNode(current) ?? findClassNodeByQualifiedName(current); + if (classNode) { + const methodMap = getMethodInfo(classNode, provider, { + filePath, + language: getLanguageFromFilename(filePath), + }); + const defLine = current.startPosition.row + 1; + const info = methodMap?.get(`${funcName}:${defLine}`); + if (info) { + arity = info.parameters.some((p) => p.isVariadic) + ? undefined + : info.parameters.length; + } + } + } + const arityTag = arity !== undefined ? `#${arity}` : ''; + const result = generateId(finalLabel, `${filePath}:${qualifiedName}${arityTag}`); functionIdCache.set(node, result); return result; } @@ -562,7 +579,28 @@ const findEnclosingFunctionId = ( const qualifiedName = classInfo ? `${classInfo.className}.${customResult.funcName}` : customResult.funcName; - const result = generateId(finalLabel, `${filePath}:${qualifiedName}`); + // Include # suffix to match definition-phase Method/Constructor IDs. + const sigNode = current.previousSibling ?? current; + let arity2: number | undefined; + if (finalLabel === 'Method' || finalLabel === 'Constructor') { + const classNode2 = + findEnclosingClassNode(sigNode) ?? findClassNodeByQualifiedName(sigNode); + if (classNode2) { + const methodMap2 = getMethodInfo(classNode2, provider, { + filePath, + language: getLanguageFromFilename(filePath), + }); + const defLine2 = sigNode.startPosition.row + 1; + const info2 = methodMap2?.get(`${customResult.funcName}:${defLine2}`); + if (info2) { + arity2 = info2.parameters.some((p) => p.isVariadic) + ? undefined + : info2.parameters.length; + } + } + } + const arityTag2 = arity2 !== undefined ? `#${arity2}` : ''; + const result = generateId(finalLabel, `${filePath}:${qualifiedName}${arityTag2}`); functionIdCache.set(node, result); return result; } @@ -1312,6 +1350,7 @@ const processFileGroup = ( const typeEnv = buildTypeEnv(tree, language, { parentMap, enclosingFunctionFinder: provider?.enclosingFunctionFinder, + extractFunctionName: provider?.methodExtractor?.extractFunctionName, }); const callRouter = provider.callRouter; @@ -1775,7 +1814,57 @@ const processFileGroup = ( const qualifiedName = enclosingClassInfo ? `${enclosingClassInfo.className}.${nodeName}` : nodeName; - const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}`); + + // Extract method metadata BEFORE generating node ID — parameterCount is needed + // to disambiguate overloaded methods via # suffix in the ID. + let declaredType: string | undefined; + let methodProps: Record = {}; + let arityForId: number | undefined; // raw param count for ID, even for variadic + if (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') { + // Use MethodExtractor for method metadata — provides parameterCount, parameterTypes, + // returnType, isAbstract/isFinal/annotations, visibility, and more. + let enrichedByMethodExtractor = false; + if (provider.methodExtractor && definitionNode) { + const classNode = + findEnclosingClassNode(definitionNode) ?? findClassNodeByQualifiedName(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; + arityForId = arityForIdFromInfo(info); + methodProps = buildMethodProps(info); + } + } + } + + // For top-level methods (e.g. Go method_declaration), try extractFromNode + if ( + !enrichedByMethodExtractor && + provider.methodExtractor?.extractFromNode && + definitionNode + ) { + const info = provider.methodExtractor.extractFromNode(definitionNode, { + filePath: file.path, + language, + }); + if (info) { + enrichedByMethodExtractor = true; + arityForId = arityForIdFromInfo(info); + methodProps = buildMethodProps(info); + } + } + } + + // Append # to Method/Constructor IDs to disambiguate overloads. + // Functions are not suffixed — they don't overload by name in the same scope. + const needsAritySuffix = nodeLabel === 'Method' || nodeLabel === 'Constructor'; + const arityTag = needsAritySuffix && arityForId !== undefined ? `#${arityForId}` : ''; + const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}${arityTag}`); const description = provider.descriptionExtractor?.(nodeLabel, nodeName, captureMap); @@ -1817,124 +1906,8 @@ const processFileGroup = ( } } - let parameterCount: number | undefined; - let requiredParameterCount: number | undefined; - let parameterTypes: string[] | undefined; - let returnType: string | undefined; - let declaredType: string | undefined; - let visibility: string | undefined; - let isStatic: boolean | undefined; - let isReadonly: boolean | undefined; - let isAbstract: boolean | undefined; - let isFinal: boolean | undefined; - let isVirtual: boolean | undefined; - let isOverride: boolean | undefined; - let isAsync: boolean | undefined; - let isPartial: boolean | undefined; - let annotations: string[] | undefined; - if (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') { - // 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) ?? findClassNodeByQualifiedName(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; - const hasVariadic = info.parameters.some((p) => p.isVariadic); - parameterCount = hasVariadic ? undefined : 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 = - !hasVariadic && optionalCount > 0 - ? info.parameters.length - optionalCount - : undefined; - returnType = info.returnType ?? undefined; - visibility = info.visibility; - isStatic = info.isStatic; - isAbstract = info.isAbstract; - isFinal = info.isFinal; - if (info.isVirtual) isVirtual = info.isVirtual; - if (info.isOverride) isOverride = info.isOverride; - if (info.isAsync) isAsync = info.isAsync; - if (info.isPartial) isPartial = info.isPartial; - if (info.annotations.length > 0) annotations = info.annotations; - } - } - } - - // For top-level methods (e.g. Go method_declaration), try extractFromNode - if ( - !enrichedByMethodExtractor && - provider.methodExtractor?.extractFromNode && - definitionNode - ) { - const info = provider.methodExtractor.extractFromNode(definitionNode, { - filePath: file.path, - language, - }); - if (info) { - enrichedByMethodExtractor = true; - const hasVariadic = info.parameters.some((p) => p.isVariadic); - parameterCount = hasVariadic ? undefined : 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 = - !hasVariadic && optionalCount > 0 - ? info.parameters.length - optionalCount - : undefined; - returnType = info.returnType ?? undefined; - visibility = info.visibility; - isStatic = info.isStatic; - isAbstract = info.isAbstract; - isFinal = info.isFinal; - if (info.isVirtual) isVirtual = info.isVirtual; - if (info.isOverride) isOverride = info.isOverride; - if (info.isAsync) isAsync = info.isAsync; - if (info.isPartial) isPartial = info.isPartial; - 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[]` - if ( - (!returnType || returnType === 'array' || returnType === 'iterable') && - definitionNode - ) { - const tc = provider.typeConfig; - if (tc?.extractReturnType) { - const docReturn = tc.extractReturnType(definitionNode); - if (docReturn) returnType = docReturn; - } - } - } else if (nodeLabel === 'Property' && definitionNode) { + // Property metadata extraction (not needed before nodeId — Properties don't overload) + if (nodeLabel === 'Property' && definitionNode) { // FieldExtractor is the single source of truth when available if (provider.fieldExtractor && typeEnv) { const classNode = findEnclosingClassNode(definitionNode); @@ -1948,9 +1921,9 @@ const processFileGroup = ( const info = fieldMap?.get(nodeName); if (info) { declaredType = info.type ?? undefined; - visibility = info.visibility; - isStatic = info.isStatic; - isReadonly = info.isReadonly; + methodProps.visibility = info.visibility; + methodProps.isStatic = info.isStatic; + methodProps.isReadonly = info.isReadonly; } } } @@ -1976,21 +1949,8 @@ const processFileGroup = ( } : {}), ...(description !== undefined ? { description } : {}), - ...(parameterCount !== undefined ? { parameterCount } : {}), - ...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}), - ...(parameterTypes !== undefined ? { parameterTypes } : {}), - ...(returnType !== undefined ? { returnType } : {}), + ...methodProps, ...(declaredType !== undefined ? { declaredType } : {}), - ...(visibility !== undefined ? { visibility } : {}), - ...(isStatic !== undefined ? { isStatic } : {}), - ...(isReadonly !== undefined ? { isReadonly } : {}), - ...(isAbstract !== undefined ? { isAbstract } : {}), - ...(isFinal !== undefined ? { isFinal } : {}), - ...(isVirtual !== undefined ? { isVirtual } : {}), - ...(isOverride !== undefined ? { isOverride } : {}), - ...(isAsync !== undefined ? { isAsync } : {}), - ...(isPartial !== undefined ? { isPartial } : {}), - ...(annotations !== undefined ? { annotations } : {}), }, }); @@ -2001,22 +1961,30 @@ const processFileGroup = ( name: nodeName, nodeId, type: nodeLabel, - ...(parameterCount !== undefined ? { parameterCount } : {}), - ...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}), - ...(parameterTypes !== undefined ? { parameterTypes } : {}), - ...(returnType !== undefined ? { returnType } : {}), + parameterCount: methodProps.parameterCount as number | undefined, + requiredParameterCount: methodProps.requiredParameterCount as number | undefined, + parameterTypes: methodProps.parameterTypes as string[] | undefined, + returnType: methodProps.returnType as string | undefined, ...(declaredType !== undefined ? { declaredType } : {}), ...(enclosingClassId ? { ownerId: enclosingClassId } : {}), - ...(visibility !== undefined ? { visibility } : {}), - ...(isStatic !== undefined ? { isStatic } : {}), - ...(isReadonly !== undefined ? { isReadonly } : {}), - ...(isAbstract !== undefined ? { isAbstract } : {}), - ...(isFinal !== undefined ? { isFinal } : {}), - ...(isVirtual !== undefined ? { isVirtual } : {}), - ...(isOverride !== undefined ? { isOverride } : {}), - ...(isAsync !== undefined ? { isAsync } : {}), - ...(isPartial !== undefined ? { isPartial } : {}), - ...(annotations !== undefined ? { annotations } : {}), + visibility: methodProps.visibility as string | undefined, + isStatic: methodProps.isStatic as boolean | undefined, + isReadonly: methodProps.isReadonly as boolean | undefined, + isAbstract: methodProps.isAbstract as boolean | undefined, + isFinal: methodProps.isFinal as boolean | undefined, + ...(methodProps.isVirtual !== undefined + ? { isVirtual: methodProps.isVirtual as boolean } + : {}), + ...(methodProps.isOverride !== undefined + ? { isOverride: methodProps.isOverride as boolean } + : {}), + ...(methodProps.isAsync !== undefined ? { isAsync: methodProps.isAsync as boolean } : {}), + ...(methodProps.isPartial !== undefined + ? { isPartial: methodProps.isPartial as boolean } + : {}), + ...(methodProps.annotations !== undefined + ? { annotations: methodProps.annotations as string[] } + : {}), }); const fileId = generateId('File', file.path); diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index fe55a9f30..91c3a5b69 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -96,7 +96,9 @@ export const VALID_RELATION_TYPES = new Set([ 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', - 'OVERRIDES', + 'METHOD_OVERRIDES', + 'OVERRIDES', // Legacy alias — dual-read for pre-rename indexes + 'METHOD_IMPLEMENTS', 'ACCESSES', 'HANDLES_ROUTE', 'FETCHES', @@ -117,7 +119,8 @@ export const VALID_RELATION_TYPES = new Set([ * CALLS / IMPORTS – direct, strongly-typed references → 0.9 * EXTENDS – class hierarchy, statically verifiable → 0.85 * IMPLEMENTS – interface contract, statically verifiable → 0.85 - * OVERRIDES – method override, statically verifiable → 0.85 + * METHOD_OVERRIDES – method override, statically verifiable → 0.85 + * METHOD_IMPLEMENTS – interface method implementation, statically verifiable → 0.85 * HAS_METHOD – structural containment → 0.95 * HAS_PROPERTY – structural containment → 0.95 * ACCESSES – field read/write, may be indirect → 0.8 @@ -129,7 +132,8 @@ export const IMPACT_RELATION_CONFIDENCE: Readonly> = { IMPORTS: 0.9, EXTENDS: 0.85, IMPLEMENTS: 0.85, - OVERRIDES: 0.85, + METHOD_OVERRIDES: 0.85, + METHOD_IMPLEMENTS: 0.85, HAS_METHOD: 0.95, HAS_PROPERTY: 0.95, ACCESSES: 0.8, @@ -1200,7 +1204,7 @@ export class LocalBackend { repo.id, ` MATCH (caller)-[r:CodeRelation]->(n {id: $symId}) - WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES'] + WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES'] RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind LIMIT 30 `, @@ -1290,7 +1294,7 @@ export class LocalBackend { repo.id, ` MATCH (n {id: $symId})-[r:CodeRelation]->(target) - WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES'] + WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES'] RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind LIMIT 30 `, @@ -1909,12 +1913,34 @@ export class LocalBackend { const { target, direction } = params; const maxDepth = params.maxDepth || 3; + // Map legacy relation type names before filtering (backward compat for OVERRIDES → METHOD_OVERRIDES) + const mappedRelTypes = params.relationTypes?.flatMap((t: string) => + t === 'OVERRIDES' ? ['OVERRIDES', 'METHOD_OVERRIDES'] : [t], + ); const rawRelTypes = - params.relationTypes && params.relationTypes.length > 0 - ? params.relationTypes.filter((t) => VALID_RELATION_TYPES.has(t)) - : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; + mappedRelTypes && mappedRelTypes.length > 0 + ? mappedRelTypes.filter((t: string) => VALID_RELATION_TYPES.has(t)) + : [ + 'CALLS', + 'IMPORTS', + 'EXTENDS', + 'IMPLEMENTS', + 'METHOD_OVERRIDES', + 'OVERRIDES', + 'METHOD_IMPLEMENTS', + ]; const relationTypes = - rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; + rawRelTypes.length > 0 + ? rawRelTypes + : [ + 'CALLS', + 'IMPORTS', + 'EXTENDS', + 'IMPLEMENTS', + 'METHOD_OVERRIDES', + 'OVERRIDES', + 'METHOD_IMPLEMENTS', + ]; const includeTests = params.includeTests ?? false; const minConfidence = params.minConfidence ?? 0; @@ -2457,12 +2483,34 @@ export class LocalBackend { const symType = typeof labelRaw === 'string' && labelRaw.trim().length > 0 ? labelRaw.trim() : ''; + // Map legacy relation type names (backward compat for OVERRIDES → METHOD_OVERRIDES) + const mappedRelTypes = opts.relationTypes?.flatMap((t: string) => + t === 'OVERRIDES' ? ['OVERRIDES', 'METHOD_OVERRIDES'] : [t], + ); const rawRelTypes = - opts.relationTypes && opts.relationTypes.length > 0 - ? opts.relationTypes.filter((t) => VALID_RELATION_TYPES.has(t)) - : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; + mappedRelTypes && mappedRelTypes.length > 0 + ? mappedRelTypes.filter((t: string) => VALID_RELATION_TYPES.has(t)) + : [ + 'CALLS', + 'IMPORTS', + 'EXTENDS', + 'IMPLEMENTS', + 'METHOD_OVERRIDES', + 'OVERRIDES', + 'METHOD_IMPLEMENTS', + ]; const relationTypes = - rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; + rawRelTypes.length > 0 + ? rawRelTypes + : [ + 'CALLS', + 'IMPORTS', + 'EXTENDS', + 'IMPLEMENTS', + 'METHOD_OVERRIDES', + 'OVERRIDES', + 'METHOD_IMPLEMENTS', + ]; try { return await this._runImpactBFS(repo, sym, symType, dir, { diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index 3fc2afdee..dce81bab4 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -353,7 +353,8 @@ relationships: - HAS_METHOD: Class/Struct/Interface owns a Method - HAS_PROPERTY: Class/Struct/Interface owns a Property (field) - ACCESSES: Function/Method reads or writes a Property (reason: 'read' or 'write') - - OVERRIDES: Method overrides another Method (MRO) + - METHOD_OVERRIDES: Method overrides another Method (MRO) + - METHOD_IMPLEMENTS: ConcreteMethod implements InterfaceMethod (matched by name + parameterTypes) - MEMBER_OF: Symbol belongs to community - STEP_IN_PROCESS: Symbol is step N in process diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index c74b19464..2c884d10b 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -99,7 +99,7 @@ SCHEMA: - Nodes: File, Folder, Function, Class, Interface, Method, CodeElement, Community, Process, Route, Tool - Multi-language nodes (use backticks): \`Struct\`, \`Enum\`, \`Trait\`, \`Impl\`, etc. - All edges via single CodeRelation table with 'type' property -- Edge types: CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, ACCESSES, OVERRIDES, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF +- Edge types: CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF - Edge properties: type (STRING), confidence (DOUBLE), reason (STRING), step (INT32) EXAMPLES: @@ -122,7 +122,7 @@ EXAMPLES: MATCH (f:Function)-[r:CodeRelation {type: 'ACCESSES', reason: 'write'}]->(p:Property) WHERE p.name = "address" RETURN f.name, f.filePath • Find method overrides (MRO resolution): - MATCH (winner:Method)-[r:CodeRelation {type: 'OVERRIDES'}]->(loser:Method) RETURN winner.name, winner.filePath, loser.filePath, r.reason + MATCH (winner:Method)-[r:CodeRelation {type: 'METHOD_OVERRIDES'}]->(loser:Method) RETURN winner.name, winner.filePath, loser.filePath, r.reason • Detect diamond inheritance: MATCH (d:Class)-[:CodeRelation {type: 'EXTENDS'}]->(b1), (d)-[:CodeRelation {type: 'EXTENDS'}]->(b2), (b1)-[:CodeRelation {type: 'EXTENDS'}]->(a), (b2)-[:CodeRelation {type: 'EXTENDS'}]->(a) WHERE b1 <> b2 RETURN d.name, b1.name, b2.name, a.name @@ -265,7 +265,7 @@ Depth groups: TIP: Default traversal uses CALLS/IMPORTS/EXTENDS/IMPLEMENTS. For class members, include HAS_METHOD and HAS_PROPERTY in relationTypes. For field access analysis, include ACCESSES in relationTypes. -EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, OVERRIDES, ACCESSES +EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES Confidence: 1.0 = certain, <0.8 = fuzzy match`, inputSchema: { type: 'object', @@ -284,7 +284,7 @@ Confidence: 1.0 = certain, <0.8 = fuzzy match`, type: 'array', items: { type: 'string' }, description: - 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, OVERRIDES, ACCESSES (default: usage-based, ACCESSES excluded by default)', + 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES (default: usage-based, ACCESSES excluded by default)', }, includeTests: { type: 'boolean', description: 'Include test files (default: false)' }, minConfidence: { type: 'number', description: 'Minimum confidence 0-1 (default: 0.7)' }, diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/App.cs new file mode 100644 index 000000000..da22577ec --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/App.cs @@ -0,0 +1,7 @@ +public class App { + public static void Main() { + IRepository repo = new SqlRepository(); + repo.Find(1); + repo.Save("test"); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/IRepository.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/IRepository.cs new file mode 100644 index 000000000..8fa557421 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/IRepository.cs @@ -0,0 +1,4 @@ +public interface IRepository { + string Find(int id); + bool Save(string entity); +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/SqlRepository.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/SqlRepository.cs new file mode 100644 index 000000000..4062ca725 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/SqlRepository.cs @@ -0,0 +1,9 @@ +public class SqlRepository : IRepository { + public string Find(int id) { + return "found"; + } + + public bool Save(string entity) { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-overload-dispatch/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-overload-dispatch/App.cs new file mode 100644 index 000000000..faaa9431d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-overload-dispatch/App.cs @@ -0,0 +1,8 @@ +public class App { + public void Run() { + var repo = new SqlRepository(); + repo.Find(42); + repo.Find("alice", true); + repo.Save("test"); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-overload-dispatch/IRepository.cs b/gitnexus/test/fixtures/lang-resolution/csharp-overload-dispatch/IRepository.cs new file mode 100644 index 000000000..35bc58c56 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-overload-dispatch/IRepository.cs @@ -0,0 +1,5 @@ +public interface IRepository { + string Find(int id); + string Find(string name, bool exact); + void Save(string data); +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-overload-dispatch/SqlRepository.cs b/gitnexus/test/fixtures/lang-resolution/csharp-overload-dispatch/SqlRepository.cs new file mode 100644 index 000000000..aae4b8c4f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-overload-dispatch/SqlRepository.cs @@ -0,0 +1,11 @@ +public class SqlRepository : IRepository { + public string Find(int id) { + return "found-by-id"; + } + public string Find(string name, bool exact) { + return "found-by-name"; + } + public void Save(string data) { + Console.WriteLine(data); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/app.dart b/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/app.dart new file mode 100644 index 000000000..edec34da6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/app.dart @@ -0,0 +1,7 @@ +import 'sql_repository.dart'; + +void main() { + final repo = SqlRepository(); + repo.find(1); + repo.save("test"); +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/repository.dart b/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/repository.dart new file mode 100644 index 000000000..c5edd9557 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/repository.dart @@ -0,0 +1,4 @@ +abstract class Repository { + String find(int id); + bool save(String entity); +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/sql_repository.dart b/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/sql_repository.dart new file mode 100644 index 000000000..68fd26642 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/sql_repository.dart @@ -0,0 +1,13 @@ +import 'repository.dart'; + +class SqlRepository implements Repository { + @override + String find(int id) { + return "found"; + } + + @override + bool save(String entity) { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-overload-dispatch/App.java b/gitnexus/test/fixtures/lang-resolution/java-overload-dispatch/App.java new file mode 100644 index 000000000..1da1ce3d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-overload-dispatch/App.java @@ -0,0 +1,8 @@ +public class App { + public void run() { + SqlRepository repo = new SqlRepository(); + repo.find(42); + repo.find("alice", true); + repo.save("test"); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-overload-dispatch/Repository.java b/gitnexus/test/fixtures/lang-resolution/java-overload-dispatch/Repository.java new file mode 100644 index 000000000..b4e855473 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-overload-dispatch/Repository.java @@ -0,0 +1,5 @@ +public interface Repository { + String find(int id); + String find(String name, boolean exact); + void save(String data); +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-overload-dispatch/SqlRepository.java b/gitnexus/test/fixtures/lang-resolution/java-overload-dispatch/SqlRepository.java new file mode 100644 index 000000000..315a8e0a6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-overload-dispatch/SqlRepository.java @@ -0,0 +1,13 @@ +public class SqlRepository implements Repository { + public String find(int id) { + return "found-by-id"; + } + + public String find(String name, boolean exact) { + return "found-by-name"; + } + + public void save(String data) { + System.out.println(data); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/App.kt new file mode 100644 index 000000000..588a9c461 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/App.kt @@ -0,0 +1,5 @@ +fun main() { + val repo: Repository = SqlRepository() + repo.find(1) + repo.save("test") +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/Repository.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/Repository.kt new file mode 100644 index 000000000..02be9c8e6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/Repository.kt @@ -0,0 +1,4 @@ +interface Repository { + fun find(id: Int): String + fun save(entity: String): Boolean +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/SqlRepository.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/SqlRepository.kt new file mode 100644 index 000000000..d44fd09bd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/SqlRepository.kt @@ -0,0 +1,9 @@ +class SqlRepository : Repository { + override fun find(id: Int): String { + return "found" + } + + override fun save(entity: String): Boolean { + return true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-overload-dispatch/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-overload-dispatch/App.kt new file mode 100644 index 000000000..13a655ea0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-overload-dispatch/App.kt @@ -0,0 +1,6 @@ +fun main() { + val repo = SqlRepository() + repo.find(42) + repo.find("alice", true) + repo.save("test") +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-overload-dispatch/Repository.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-overload-dispatch/Repository.kt new file mode 100644 index 000000000..92b73e1b0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-overload-dispatch/Repository.kt @@ -0,0 +1,5 @@ +interface Repository { + fun find(id: Int): String + fun find(name: String, exact: Boolean): String + fun save(data: String) +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-overload-dispatch/SqlRepository.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-overload-dispatch/SqlRepository.kt new file mode 100644 index 000000000..a562a8dc8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-overload-dispatch/SqlRepository.kt @@ -0,0 +1,5 @@ +class SqlRepository : Repository { + override fun find(id: Int): String = "found-by-id" + override fun find(name: String, exact: Boolean): String = "found-by-name" + override fun save(data: String) { println(data) } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-overload-dispatch/App.swift b/gitnexus/test/fixtures/lang-resolution/swift-overload-dispatch/App.swift new file mode 100644 index 000000000..d57e328a8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-overload-dispatch/App.swift @@ -0,0 +1,4 @@ +let repo = SqlRepository() +repo.find(id: 42) +repo.find(name: "alice", exact: true) +repo.save(data: "test") diff --git a/gitnexus/test/fixtures/lang-resolution/swift-overload-dispatch/Repository.swift b/gitnexus/test/fixtures/lang-resolution/swift-overload-dispatch/Repository.swift new file mode 100644 index 000000000..fa735509a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-overload-dispatch/Repository.swift @@ -0,0 +1,5 @@ +protocol Repository { + func find(id: Int) -> String + func find(name: String, exact: Bool) -> String + func save(data: String) +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-overload-dispatch/SqlRepository.swift b/gitnexus/test/fixtures/lang-resolution/swift-overload-dispatch/SqlRepository.swift new file mode 100644 index 000000000..b6d2a1546 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-overload-dispatch/SqlRepository.swift @@ -0,0 +1,5 @@ +class SqlRepository: Repository { + func find(id: Int) -> String { return "found-by-id" } + func find(name: String, exact: Bool) -> String { return "found-by-name" } + func save(data: String) { print(data) } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-overload-dispatch/app.ts b/gitnexus/test/fixtures/lang-resolution/ts-overload-dispatch/app.ts new file mode 100644 index 000000000..e526de6a2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-overload-dispatch/app.ts @@ -0,0 +1,6 @@ +import { SqlRepository } from './sql-repository'; + +const repo = new SqlRepository(); +repo.find(42); +repo.find('alice'); +repo.save('test'); diff --git a/gitnexus/test/fixtures/lang-resolution/ts-overload-dispatch/repository.ts b/gitnexus/test/fixtures/lang-resolution/ts-overload-dispatch/repository.ts new file mode 100644 index 000000000..6ad14c0da --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-overload-dispatch/repository.ts @@ -0,0 +1,5 @@ +export interface IRepository { + find(id: number): string; + find(name: string): string; + save(data: string): void; +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-overload-dispatch/sql-repository.ts b/gitnexus/test/fixtures/lang-resolution/ts-overload-dispatch/sql-repository.ts new file mode 100644 index 000000000..e937ee689 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-overload-dispatch/sql-repository.ts @@ -0,0 +1,12 @@ +import { IRepository } from './repository'; + +export class SqlRepository implements IRepository { + find(id: number): string; + find(name: string): string; + find(arg: number | string): string { + return typeof arg === 'number' ? 'found-by-id' : 'found-by-name'; + } + save(data: string): void { + console.log(data); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/app.ts new file mode 100644 index 000000000..9cfb446c0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/app.ts @@ -0,0 +1,5 @@ +import { SqlRepository } from './sql-repository'; + +const repo = new SqlRepository(); +repo.find(1); +repo.save("test"); diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/repository.ts b/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/repository.ts new file mode 100644 index 000000000..a86ca660a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/repository.ts @@ -0,0 +1,4 @@ +export interface IRepository { + find(id: number): string; + save(entity: string): boolean; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/sql-repository.ts b/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/sql-repository.ts new file mode 100644 index 000000000..b623e6e9c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/sql-repository.ts @@ -0,0 +1,11 @@ +import { IRepository } from './repository'; + +export class SqlRepository implements IRepository { + find(id: number): string { + return "found"; + } + + save(entity: string): boolean { + return true; + } +} diff --git a/gitnexus/test/fixtures/local-backend-seed.ts b/gitnexus/test/fixtures/local-backend-seed.ts index 6958fe5b5..9a4d4e0c1 100644 --- a/gitnexus/test/fixtures/local-backend-seed.ts +++ b/gitnexus/test/fixtures/local-backend-seed.ts @@ -34,7 +34,7 @@ export const LOCAL_BACKEND_SEED_DATA = [ CREATE (c)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'class-method', step: 0}]->(m)`, // OVERRIDES: AuthService.authenticate -> BaseService.authenticate `MATCH (a:Method), (b:Method) WHERE a.id = 'method:AuthService.authenticate' AND b.id = 'method:BaseService.authenticate' - CREATE (a)-[:CodeRelation {type: 'OVERRIDES', confidence: 1.0, reason: 'mro-resolution', step: 0}]->(b)`, + CREATE (a)-[:CodeRelation {type: 'METHOD_OVERRIDES', confidence: 1.0, reason: 'mro-resolution', step: 0}]->(b)`, // HAS_METHOD: BaseService -> authenticate `MATCH (c:Class), (m:Method) WHERE c.id = 'class:BaseService' AND m.id = 'method:BaseService.authenticate' CREATE (c)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'class-method', step: 0}]->(m)`, diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts index f012a96db..55e4110f3 100644 --- a/gitnexus/test/integration/local-backend-calltool.test.ts +++ b/gitnexus/test/integration/local-backend-calltool.test.ts @@ -144,7 +144,7 @@ withTestLbugDB( const result = await backend.callTool('impact', { target: 'authenticate', direction: 'downstream', - relationTypes: ['OVERRIDES'], + relationTypes: ['METHOD_OVERRIDES'], }); expect(result).not.toHaveProperty('error'); // AuthService.authenticate overrides BaseService.authenticate @@ -154,6 +154,22 @@ withTestLbugDB( expect(names).toContain('authenticate'); }); + it('expands legacy OVERRIDES to include METHOD_OVERRIDES (dual-read)', async () => { + // Pass the LEGACY alias 'OVERRIDES' — impactByUid should flatMap-expand + // it to ['OVERRIDES', 'METHOD_OVERRIDES'] so the METHOD_OVERRIDES edge + // between BaseService.authenticate and AuthService.authenticate is found. + const result = await backend.callTool('impact', { + target: 'authenticate', + direction: 'downstream', + relationTypes: ['OVERRIDES'], + }); + expect(result).not.toHaveProperty('error'); + expect(result.impactedCount).toBeGreaterThanOrEqual(1); + const d1 = result.byDepth[1] || result.byDepth['1'] || []; + const names = d1.map((d: any) => d.name); + expect(names).toContain('authenticate'); + }); + it('does not return HAS_METHOD results when filtering by CALLS only', async () => { const result = await backend.callTool('impact', { target: 'AuthService', diff --git a/gitnexus/test/integration/local-backend.test.ts b/gitnexus/test/integration/local-backend.test.ts index 992820419..be35a3c98 100644 --- a/gitnexus/test/integration/local-backend.test.ts +++ b/gitnexus/test/integration/local-backend.test.ts @@ -94,7 +94,7 @@ withTestLbugDB( 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', - 'OVERRIDES', + 'METHOD_OVERRIDES', 'ACCESSES', ]; const invalidTypes = ['CONTAINS', 'STEP_IN_PROCESS', 'MEMBER_OF', 'DROP_TABLE']; diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 7bda177c5..e9a340564 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -61,7 +61,7 @@ describe('C++ diamond inheritance', () => { }); it('no OVERRIDES edges target Property nodes', () => { - const overrides = getRelationships(result, 'OVERRIDES'); + const overrides = getRelationships(result, 'METHOD_OVERRIDES'); for (const edge of overrides) { const target = result.graph.getNode(edge.rel.targetId); expect(target).toBeDefined(); diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index d88b97c11..0348f1eb6 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -86,7 +86,7 @@ describe('C# heritage resolution', () => { }); it('no OVERRIDES edges target Property nodes', () => { - const overrides = getRelationships(result, 'OVERRIDES'); + const overrides = getRelationships(result, 'METHOD_OVERRIDES'); for (const edge of overrides) { const target = result.graph.getNode(edge.rel.targetId); expect(target).toBeDefined(); @@ -1722,3 +1722,109 @@ describe('C# method enrichment', () => { expect(classifyCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Interface dispatch: METHOD_IMPLEMENTS edges +// --------------------------------------------------------------------------- + +describe('C# interface dispatch (METHOD_IMPLEMENTS)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-interface-dispatch'), () => {}); + }, 60000); + + it('detects IRepository interface and SqlRepository class', () => { + const classes = getNodesByLabel(result, 'Class'); + const ifaces = getNodesByLabel(result, 'Interface'); + expect(classes).toContain('SqlRepository'); + expect(ifaces).toContain('IRepository'); + }); + + it('emits IMPLEMENTS edge SqlRepository → IRepository', () => { + const impl = getRelationships(result, 'IMPLEMENTS'); + const edge = impl.find((e) => e.source === 'SqlRepository' && e.target === 'IRepository'); + expect(edge).toBeDefined(); + }); + + it('emits METHOD_IMPLEMENTS edges for Find and Save', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const findEdge = mi.find( + (e) => + e.source === 'Find' && + e.target === 'Find' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('IRepository'), + ); + const saveEdge = mi.find( + (e) => + e.source === 'Save' && + e.target === 'Save' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('IRepository'), + ); + expect(findEdge).toBeDefined(); + expect(saveEdge).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Overloaded method disambiguation: METHOD_IMPLEMENTS with overloads +// IRepository declares Find(int), Find(string), Save(string). +// SqlRepository implements all three. +// Overloaded methods (same name, different params) collapse into a single +// graph node (generateId drops startLine), so Find appears once per file. +// METHOD_IMPLEMENTS still emits one edge per unique (source, target) pair. +// --------------------------------------------------------------------------- + +describe('C# overloaded method disambiguation (METHOD_IMPLEMENTS)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-overload-dispatch'), () => {}); + }, 60000); + + it('detects 2 distinct Find Method nodes on SqlRepository (different arities)', () => { + const methods = getNodesByLabelFull(result, 'Method'); + const findOnSql = methods.filter( + (m) => m.name === 'Find' && m.properties.filePath?.includes('SqlRepository'), + ); + expect(findOnSql.length).toBe(2); + }); + + it('emits METHOD_IMPLEMENTS edges for both Find overloads', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const findEdges = mi.filter( + (e) => + e.source === 'Find' && + e.target === 'Find' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('IRepository'), + ); + expect(findEdges.length).toBe(2); + }); + + it('emits METHOD_IMPLEMENTS for Save -> IRepository.Save', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const saveEdge = mi.find( + (e) => + e.source === 'Save' && + e.target === 'Save' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('IRepository'), + ); + expect(saveEdge).toBeDefined(); + }); + + it('emits exactly 3 METHOD_IMPLEMENTS edges', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + expect(mi.length).toBe(3); + }); + + it('detects SqlRepository class and IRepository interface', () => { + const classes = getNodesByLabel(result, 'Class'); + const ifaces = getNodesByLabel(result, 'Interface'); + expect(classes).toContain('SqlRepository'); + expect(ifaces).toContain('IRepository'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/dart.test.ts b/gitnexus/test/integration/resolvers/dart.test.ts index 8839e49f6..7f995e09d 100644 --- a/gitnexus/test/integration/resolvers/dart.test.ts +++ b/gitnexus/test/integration/resolvers/dart.test.ts @@ -429,3 +429,48 @@ describe.skipIf(!dartAvailable)('Dart async method detection', () => { expect(formatName!.properties.returnType).toBe('String'); }); }); + +// --------------------------------------------------------------------------- +// Interface dispatch: METHOD_IMPLEMENTS edges from concrete → abstract methods +// abstract Repository with find/save, SqlRepository implements them +// --------------------------------------------------------------------------- + +describe.skipIf(!dartAvailable)('Dart interface dispatch (METHOD_IMPLEMENTS)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-interface-dispatch'), () => {}); + }, 60000); + + it('detects Repository class and SqlRepository class', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Repository'); + expect(classes).toContain('SqlRepository'); + }); + + it('emits IMPLEMENTS edge SqlRepository → Repository', () => { + const impl = getRelationships(result, 'IMPLEMENTS'); + const edge = impl.find((e) => e.source === 'SqlRepository' && e.target === 'Repository'); + expect(edge).toBeDefined(); + }); + + it('emits METHOD_IMPLEMENTS edges for find and save', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const findEdge = mi.find( + (e) => + e.source === 'find' && + e.target === 'find' && + e.sourceFilePath.includes('sql_repository') && + e.targetFilePath.includes('repository'), + ); + const saveEdge = mi.find( + (e) => + e.source === 'save' && + e.target === 'save' && + e.sourceFilePath.includes('sql_repository') && + e.targetFilePath.includes('repository'), + ); + expect(findEdge).toBeDefined(); + expect(saveEdge).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index a52ad1775..0ec9b2f6c 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -80,7 +80,7 @@ describe('Go package import & call resolution', () => { }); it('no OVERRIDES edges target Property nodes', () => { - const overrides = getRelationships(result, 'OVERRIDES'); + const overrides = getRelationships(result, 'METHOD_OVERRIDES'); for (const edge of overrides) { const target = result.graph.getNode(edge.rel.targetId); expect(target).toBeDefined(); diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 2d8f59303..6fa89c590 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -71,7 +71,7 @@ describe('Java heritage resolution', () => { }); it('no OVERRIDES edges target Property nodes', () => { - const overrides = getRelationships(result, 'OVERRIDES'); + const overrides = getRelationships(result, 'METHOD_OVERRIDES'); for (const edge of overrides) { const target = result.graph.getNode(edge.rel.targetId); expect(target).toBeDefined(); @@ -385,6 +385,42 @@ describe('Java variadic call resolution', () => { expect(logCall!.source).toBe('run'); expect(logCall!.targetFilePath).toBe('com/example/util/Logger.java'); }); + + it('CALLS edges from within variadic method have valid sourceId (no ID mismatch)', () => { + // Collect all CALLS edges whose source is in Logger.java + const danglingSourceIds: string[] = []; + for (const rel of result.graph.iterRelationships()) { + if (rel.type !== 'CALLS') continue; + const sourceNode = result.graph.getNode(rel.sourceId); + if (!sourceNode) { + danglingSourceIds.push(rel.sourceId); + continue; + } + // Specifically flag Logger.java sources that don't resolve + if ( + sourceNode.properties.filePath === 'com/example/util/Logger.java' && + !result.graph.getNode(rel.sourceId) + ) { + danglingSourceIds.push(rel.sourceId); + } + } + + // No CALLS edge should have a dangling (unresolvable) sourceId. + // This catches the bug where definition creates Method:...record#N but + // findEnclosingFunctionId generates Method:...record (no suffix), + // producing CALLS edges whose sourceId doesn't match any graph node. + expect(danglingSourceIds).toEqual([]); + + // Additionally verify that ALL relationships (not just CALLS) have + // resolvable sourceIds — a stronger invariant. + const allDangling: string[] = []; + for (const rel of result.graph.iterRelationships()) { + if (!result.graph.getNode(rel.sourceId)) { + allDangling.push(`${rel.type}:${rel.sourceId}`); + } + } + expect(allDangling).toEqual([]); + }); }); // --------------------------------------------------------------------------- @@ -1651,3 +1687,165 @@ describe('Java method enrichment', () => { expect(classifyCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Java interface dispatch (METHOD_IMPLEMENTS) +// Action interface: execute(), priority() +// LogEvent implements Action, SendEmail implements Action +// --------------------------------------------------------------------------- + +describe('Java interface dispatch (METHOD_IMPLEMENTS)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-interface-dispatch'), () => {}); + }, 60000); + + it('emits METHOD_IMPLEMENTS edges from LogEvent.execute → Action.execute', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const edge = mi.find( + (e) => + e.source === 'execute' && + e.target === 'execute' && + e.sourceFilePath.includes('LogEvent') && + e.targetFilePath.includes('Action'), + ); + expect(edge).toBeDefined(); + }); + + it('emits METHOD_IMPLEMENTS edges from SendEmail.execute → Action.execute', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const edge = mi.find( + (e) => + e.source === 'execute' && + e.target === 'execute' && + e.sourceFilePath.includes('SendEmail') && + e.targetFilePath.includes('Action'), + ); + expect(edge).toBeDefined(); + }); + + it('emits METHOD_IMPLEMENTS for priority() in both implementors', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const priorityEdges = mi.filter( + (e) => + e.source === 'priority' && e.target === 'priority' && e.targetFilePath.includes('Action'), + ); + expect(priorityEdges.length).toBe(2); + const sourceFiles = priorityEdges.map((e) => e.sourceFilePath).sort(); + expect(sourceFiles.some((f) => f.includes('LogEvent'))).toBe(true); + expect(sourceFiles.some((f) => f.includes('SendEmail'))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Java overloaded method disambiguation (METHOD_IMPLEMENTS with arity) +// Repository interface: find(int), find(String, boolean), save(String) +// SqlRepository implements Repository with matching overloads +// --------------------------------------------------------------------------- + +describe('Java overloaded method disambiguation (METHOD_IMPLEMENTS)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-overload-dispatch'), () => {}); + }, 60000); + + it('detects distinct Method nodes for overloaded find methods on SqlRepository', () => { + const methods = getNodesByLabelFull(result, 'Method'); + const findMethods = methods.filter( + (m) => m.name === 'find' && m.properties.filePath?.includes('SqlRepository'), + ); + expect(findMethods.length).toBe(2); + const paramCounts = findMethods.map((m) => m.properties.parameterCount).sort(); + expect(paramCounts).toEqual([1, 2]); + }); + + it('detects distinct Method nodes for overloaded find methods on Repository interface', () => { + const methods = getNodesByLabelFull(result, 'Method'); + const findMethods = methods.filter( + (m) => + m.name === 'find' && + m.properties.filePath?.includes('Repository') && + !m.properties.filePath?.includes('SqlRepository'), + ); + expect(findMethods.length).toBe(2); + const paramCounts = findMethods.map((m) => m.properties.parameterCount).sort(); + expect(paramCounts).toEqual([1, 2]); + }); + + it('emits METHOD_IMPLEMENTS for find(int) → Repository.find(int)', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const edge = mi.find( + (e) => + e.source === 'find' && + e.target === 'find' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('Repository'), + ); + expect(edge).toBeDefined(); + // Verify at least one find→find edge has arity 1 on source side + const findEdges = mi.filter( + (e) => + e.source === 'find' && + e.target === 'find' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('Repository'), + ); + const sourceNodes = findEdges.map((e) => { + const methods = getNodesByLabelFull(result, 'Method'); + return methods.find( + (m) => + m.name === 'find' && + m.properties.filePath?.includes('SqlRepository') && + m.properties.parameterCount === 1, + ); + }); + expect(sourceNodes.some((n) => n !== undefined)).toBe(true); + }); + + it('emits METHOD_IMPLEMENTS for find(String, boolean) → Repository.find(String, boolean)', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const findEdges = mi.filter( + (e) => + e.source === 'find' && + e.target === 'find' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('Repository'), + ); + // There should be two find→find edges (one per overload) + expect(findEdges.length).toBe(2); + }); + + it('emits METHOD_IMPLEMENTS for save(String) → Repository.save(String)', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const edge = mi.find( + (e) => + e.source === 'save' && + e.target === 'save' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('Repository'), + ); + expect(edge).toBeDefined(); + }); + + it('emits exactly 3 METHOD_IMPLEMENTS edges', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const edges = mi.filter( + (e) => e.sourceFilePath.includes('SqlRepository') && e.targetFilePath.includes('Repository'), + ); + expect(edges.length).toBe(3); + }); + + it('emits CALLS edges from run() to both find overloads', () => { + const calls = getRelationships(result, 'CALLS'); + const findCalls = calls.filter( + (c) => + c.source === 'run' && + c.target === 'find' && + c.sourceFilePath.includes('App') && + c.targetFilePath.includes('SqlRepository'), + ); + expect(findCalls.length).toBe(2); + }); +}); diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index 90f5b8aa3..361996527 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -90,7 +90,7 @@ describe('Kotlin heritage resolution', () => { }); it('no OVERRIDES edges target Property nodes', () => { - const overrides = getRelationships(result, 'OVERRIDES'); + const overrides = getRelationships(result, 'METHOD_OVERRIDES'); for (const edge of overrides) { const target = result.graph.getNode(edge.rel.targetId); expect(target).toBeDefined(); @@ -1811,3 +1811,100 @@ describe('Kotlin method enrichment', () => { expect(classifyCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Interface dispatch: METHOD_IMPLEMENTS edges from concrete → interface methods +// Repository interface with find/save, SqlRepository implements them +// --------------------------------------------------------------------------- + +describe('Kotlin interface dispatch (METHOD_IMPLEMENTS)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'kotlin-interface-dispatch'), () => {}); + }, 60000); + + it('detects Repository interface and SqlRepository class', () => { + const classes = getNodesByLabel(result, 'Class'); + const ifaces = getNodesByLabel(result, 'Interface'); + expect(classes).toContain('SqlRepository'); + expect(ifaces).toContain('Repository'); + }); + + it('emits IMPLEMENTS edge SqlRepository → Repository', () => { + const impl = getRelationships(result, 'IMPLEMENTS'); + const edge = impl.find((e) => e.source === 'SqlRepository' && e.target === 'Repository'); + expect(edge).toBeDefined(); + }); + + it('emits METHOD_IMPLEMENTS edges for find and save', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const findEdge = mi.find( + (e) => + e.source === 'find' && + e.target === 'find' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('Repository'), + ); + const saveEdge = mi.find( + (e) => + e.source === 'save' && + e.target === 'save' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('Repository'), + ); + expect(findEdge).toBeDefined(); + expect(saveEdge).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Overloaded method disambiguation: interface with overloaded find + save, +// concrete class implements all three. Verifies METHOD_IMPLEMENTS edges +// correctly distinguish between overloaded signatures. +// --------------------------------------------------------------------------- + +describe('Kotlin overloaded method disambiguation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'kotlin-overload-dispatch'), () => {}); + }, 60000); + + it('detects 2 distinct find Method nodes on SqlRepository', () => { + const methods = getNodesByLabelFull(result, 'Method'); + const sqlRepoFinds = methods.filter( + (m) => m.name === 'find' && m.properties.filePath?.includes('SqlRepository'), + ); + expect(sqlRepoFinds.length).toBe(2); + }); + + it('emits METHOD_IMPLEMENTS edges for both find overloads', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const findEdges = mi.filter( + (e) => + e.source === 'find' && + e.target === 'find' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('Repository'), + ); + expect(findEdges.length).toBe(2); + }); + + it('emits METHOD_IMPLEMENTS edge for save', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const saveEdge = mi.find( + (e) => + e.source === 'save' && + e.target === 'save' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('Repository'), + ); + expect(saveEdge).toBeDefined(); + }); + + it('emits exactly 3 METHOD_IMPLEMENTS edges total', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + expect(mi.length).toBe(3); + }); +}); diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index c7dfdc016..a72da9439 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -128,7 +128,7 @@ describe('PHP heritage & import resolution', () => { // --- Property OVERRIDES exclusion --- it('does not emit OVERRIDES for property name collisions ($status in both traits)', () => { - const overrides = getRelationships(result, 'OVERRIDES'); + const overrides = getRelationships(result, 'METHOD_OVERRIDES'); // OVERRIDES should only target Method nodes, never Property nodes for (const edge of overrides) { const target = result.graph.getNode(edge.rel.targetId); @@ -140,7 +140,7 @@ describe('PHP heritage & import resolution', () => { // --- MRO: OVERRIDES edge --- it('emits OVERRIDES edge for User overriding log (inherited from BaseModel)', () => { - const overrides = getRelationships(result, 'OVERRIDES'); + const overrides = getRelationships(result, 'METHOD_OVERRIDES'); expect(overrides.length).toBe(1); const logOverride = overrides.find((e) => e.source === 'User' && e.target === 'log'); expect(logOverride).toBeDefined(); @@ -1769,4 +1769,14 @@ describe('PHP abstract dispatch', () => { expect(params).toContain('int'); } }); + + it('emits METHOD_IMPLEMENTS edges from SqlRepository methods → Repository interface methods', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const edges = mi.filter( + (e) => e.sourceFilePath.includes('SqlRepository') && e.targetFilePath.includes('Repository'), + ); + expect(edges.length).toBe(2); + const names = edges.map((e) => e.source).sort(); + expect(names).toEqual(['find', 'save']); + }); }); diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 0fa62f4c0..f1108dec6 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -64,7 +64,7 @@ describe('Python relative import & heritage resolution', () => { }); it('no OVERRIDES edges target Property nodes', () => { - const overrides = getRelationships(result, 'OVERRIDES'); + const overrides = getRelationships(result, 'METHOD_OVERRIDES'); for (const edge of overrides) { const target = result.graph.getNode(edge.rel.targetId); expect(target).toBeDefined(); @@ -2100,4 +2100,14 @@ describe('Python abstract dispatch', () => { expect(params).toContain('int'); } }); + + it('does not emit METHOD_IMPLEMENTS for abstract-class inheritance (only interface/trait parents)', () => { + // Python ABC is modelled as a Class with EXTENDS (not Interface with IMPLEMENTS), + // so the MRO processor does not emit METHOD_IMPLEMENTS edges here. + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const edges = mi.filter( + (e) => e.sourceFilePath.includes('impl.py') && e.targetFilePath.includes('base.py'), + ); + expect(edges.length).toBe(0); + }); }); diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index bdb76f4ac..84bb137eb 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -194,7 +194,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- No OVERRIDES edges target Property nodes --- it('no OVERRIDES edges target Property nodes', () => { - const overrides = getRelationships(result, 'OVERRIDES'); + const overrides = getRelationships(result, 'METHOD_OVERRIDES'); for (const edge of overrides) { const target = result.graph.getNode(edge.rel.targetId); expect(target).toBeDefined(); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index ad205eee2..496f15e9b 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -66,7 +66,7 @@ describe('Rust trait implementation resolution', () => { }); it('no OVERRIDES edges target Property nodes', () => { - const overrides = getRelationships(result, 'OVERRIDES'); + const overrides = getRelationships(result, 'METHOD_OVERRIDES'); for (const edge of overrides) { const target = result.graph.getNode(edge.rel.targetId); expect(target).toBeDefined(); @@ -1847,4 +1847,13 @@ describe('Rust abstract dispatch (Repository trait)', () => { expect(saveCall).toBeDefined(); expect(countCall).toBeDefined(); }); + + it('emits METHOD_IMPLEMENTS edges from SqlRepo impl methods → Repository trait methods', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + // find and save are required trait methods; count has a default impl so no METHOD_IMPLEMENTS + const libEdges = mi.filter((e) => e.sourceFilePath.includes('lib.rs')); + expect(libEdges.length).toBe(2); + const names = libEdges.map((e) => e.source).sort(); + expect(names).toEqual(['find', 'save']); + }); }); diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts index 8534927b8..272770527 100644 --- a/gitnexus/test/integration/resolvers/swift.test.ts +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -799,4 +799,69 @@ describe.skipIf(!swiftAvailable)('Swift abstract dispatch', () => { expect(sqlFind).toBeDefined(); expect(sqlFind!.properties.returnType).toBe('String'); }); + + it('emits METHOD_IMPLEMENTS edges from SqlRepository methods → Repository protocol methods', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const edges = mi.filter((e) => e.sourceFilePath.includes('Repository.swift')); + expect(edges.length).toBe(2); + const names = edges.map((e) => e.source).sort(); + expect(names).toEqual(['find', 'save']); + }); +}); + +// --------------------------------------------------------------------------- +// Overloaded method disambiguation: protocol with overloaded find + save, +// concrete class implements all three. Verifies METHOD_IMPLEMENTS edges +// correctly distinguish between overloaded signatures. +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)('Swift overloaded method disambiguation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'swift-overload-dispatch'), () => {}); + }, 60000); + + it('detects 2 distinct find Method nodes on SqlRepository', () => { + const methods = getNodesByLabelFull(result, 'Method'); + const sqlRepoFinds = methods.filter( + (m) => m.name === 'find' && m.properties.filePath?.includes('SqlRepository'), + ); + // Swift class methods may be emitted as Function nodes + const functions = getNodesByLabelFull(result, 'Function'); + const sqlRepoFindFns = functions.filter( + (m) => m.name === 'find' && m.properties.filePath?.includes('SqlRepository'), + ); + const totalFinds = sqlRepoFinds.length + sqlRepoFindFns.length; + expect(totalFinds).toBe(2); + }); + + it('emits METHOD_IMPLEMENTS edges for both find overloads', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const findEdges = mi.filter( + (e) => + e.source === 'find' && + e.target === 'find' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('Repository'), + ); + expect(findEdges.length).toBe(2); + }); + + it('emits METHOD_IMPLEMENTS edge for save', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const saveEdge = mi.find( + (e) => + e.source === 'save' && + e.target === 'save' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('Repository'), + ); + expect(saveEdge).toBeDefined(); + }); + + it('emits exactly 3 METHOD_IMPLEMENTS edges total', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + expect(mi.length).toBe(3); + }); }); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 00742c2cc..cd55301d3 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -75,7 +75,7 @@ describe('TypeScript heritage resolution', () => { }); it('no OVERRIDES edges target Property nodes', () => { - const overrides = getRelationships(result, 'OVERRIDES'); + const overrides = getRelationships(result, 'METHOD_OVERRIDES'); for (const edge of overrides) { const target = result.graph.getNode(edge.rel.targetId); expect(target).toBeDefined(); @@ -886,7 +886,7 @@ describe('TypeScript return type inference via explicit function return type', ( }); it('resolves user.save() to User#save via return type of getUser(): User', () => { - // TS has explicit return types in the source, so extractMethodSignature captures + // TS has explicit return types in the source, so the method extractor captures // the return type. The TS extractInitializer handles `const user = getUser()` // via the variable_declarator path, enabling save() to resolve to User#save. const calls = getRelationships(result, 'CALLS'); @@ -2395,3 +2395,104 @@ describe('TypeScript method enrichment', () => { expect(classifyCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Interface dispatch: METHOD_IMPLEMENTS edges +// --------------------------------------------------------------------------- + +describe('TypeScript interface dispatch (METHOD_IMPLEMENTS)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-interface-dispatch'), + () => {}, + ); + }, 60000); + + it('detects IRepository interface and SqlRepository class', () => { + const classes = getNodesByLabel(result, 'Class'); + const ifaces = getNodesByLabel(result, 'Interface'); + expect(classes).toContain('SqlRepository'); + expect(ifaces).toContain('IRepository'); + }); + + it('emits IMPLEMENTS edge SqlRepository → IRepository', () => { + const impl = getRelationships(result, 'IMPLEMENTS'); + const edge = impl.find((e) => e.source === 'SqlRepository' && e.target === 'IRepository'); + expect(edge).toBeDefined(); + }); + + it('emits METHOD_IMPLEMENTS edges for find and save', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const findEdge = mi.find( + (e) => + e.source === 'find' && + e.target === 'find' && + e.sourceFilePath.includes('sql-repository') && + e.targetFilePath.includes('repository'), + ); + const saveEdge = mi.find( + (e) => + e.source === 'save' && + e.target === 'save' && + e.sourceFilePath.includes('sql-repository') && + e.targetFilePath.includes('repository'), + ); + expect(findEdge).toBeDefined(); + expect(saveEdge).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Overloaded method disambiguation: interface with overloaded find + save, +// concrete class implements all three. TypeScript overloads collapse to one +// implementation signature — expect the implementation body, not individual +// overload signatures. +// --------------------------------------------------------------------------- + +describe('TypeScript overloaded method disambiguation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'ts-overload-dispatch'), () => {}); + }, 60000); + + it('emits METHOD_IMPLEMENTS edge for find', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const findEdge = mi.find( + (e) => + e.source === 'find' && + e.target === 'find' && + e.sourceFilePath.includes('sql-repository') && + e.targetFilePath.includes('repository'), + ); + expect(findEdge).toBeDefined(); + }); + + it('emits METHOD_IMPLEMENTS edge for save', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const saveEdge = mi.find( + (e) => + e.source === 'save' && + e.target === 'save' && + e.sourceFilePath.includes('sql-repository') && + e.targetFilePath.includes('repository'), + ); + expect(saveEdge).toBeDefined(); + }); + + it('TypeScript overloads collapse — find has one implementation METHOD_IMPLEMENTS edge', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + // TypeScript overloads collapse to one implementation signature, + // so we expect a single METHOD_IMPLEMENTS edge for find (not two) + const findEdges = mi.filter( + (e) => + e.source === 'find' && + e.target === 'find' && + e.sourceFilePath.includes('sql-repository') && + e.targetFilePath.includes('repository'), + ); + expect(findEdges.length).toBe(1); + }); +}); diff --git a/gitnexus/test/unit/impact-confidence.test.ts b/gitnexus/test/unit/impact-confidence.test.ts index c7c3f8b53..d5ecefc0f 100644 --- a/gitnexus/test/unit/impact-confidence.test.ts +++ b/gitnexus/test/unit/impact-confidence.test.ts @@ -34,8 +34,12 @@ describe('IMPACT_RELATION_CONFIDENCE', () => { expect(IMPACT_RELATION_CONFIDENCE['IMPLEMENTS']).toBe(0.85); }); - it('OVERRIDES has confidence 0.85 (statically verifiable override)', () => { - expect(IMPACT_RELATION_CONFIDENCE['OVERRIDES']).toBe(0.85); + it('METHOD_OVERRIDES has confidence 0.85 (statically verifiable override)', () => { + expect(IMPACT_RELATION_CONFIDENCE['METHOD_OVERRIDES']).toBe(0.85); + }); + + it('METHOD_IMPLEMENTS has confidence 0.85 (statically verifiable implementation)', () => { + expect(IMPACT_RELATION_CONFIDENCE['METHOD_IMPLEMENTS']).toBe(0.85); }); it('HAS_METHOD has confidence 0.95 (structural containment)', () => { @@ -76,7 +80,8 @@ describe('confidenceForRelType', () => { expect(confidenceForRelType('IMPORTS')).toBe(0.9); expect(confidenceForRelType('EXTENDS')).toBe(0.85); expect(confidenceForRelType('IMPLEMENTS')).toBe(0.85); - expect(confidenceForRelType('OVERRIDES')).toBe(0.85); + expect(confidenceForRelType('METHOD_OVERRIDES')).toBe(0.85); + expect(confidenceForRelType('METHOD_IMPLEMENTS')).toBe(0.85); expect(confidenceForRelType('HAS_METHOD')).toBe(0.95); expect(confidenceForRelType('HAS_PROPERTY')).toBe(0.95); expect(confidenceForRelType('ACCESSES')).toBe(0.8); diff --git a/gitnexus/test/unit/ingestion-utils.test.ts b/gitnexus/test/unit/ingestion-utils.test.ts index a2f2c9e82..c41b2e43b 100644 --- a/gitnexus/test/unit/ingestion-utils.test.ts +++ b/gitnexus/test/unit/ingestion-utils.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect } from 'vitest'; import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; import { getProvider } from '../../src/core/ingestion/languages/index.js'; -import { extractFunctionName } from '../../src/core/ingestion/utils/ast-helpers.js'; +import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js'; +import type { NodeLabel } from 'gitnexus-shared'; +import type { LanguageProvider } from '../../src/core/ingestion/language-provider.js'; import { getTreeSitterBufferSize, TREE_SITTER_BUFFER_SIZE, @@ -341,8 +343,23 @@ describe('isBuiltInOrNoise', () => { }); }); -describe('extractFunctionName', () => { +describe('extractFunctionName (via methodExtractor)', () => { const parser = new Parser(); + const cProvider = getProvider(SupportedLanguages.C); + const cppProvider = getProvider(SupportedLanguages.CPlusPlus); + const tsProvider = getProvider(SupportedLanguages.TypeScript); + + /** Test helper: extracts function name using methodExtractor hook with generic fallback. */ + const extractFunctionName = ( + node: SyntaxNode | null, + provider?: LanguageProvider, + ): { funcName: string | null; label: NodeLabel } => { + if (!node) return { funcName: null, label: 'Function' }; + const result = provider?.methodExtractor?.extractFunctionName?.(node); + if (result) return result; + const funcName = node.childForFieldName?.('name')?.text ?? null; + return { funcName, label: 'Function' }; + }; describe('C', () => { it('extracts function name from C function definition', () => { @@ -351,7 +368,7 @@ describe('extractFunctionName', () => { const tree = parser.parse(code); const funcNode = tree.rootNode.child(0); - const result = extractFunctionName(funcNode); + const result = extractFunctionName(funcNode, cProvider); expect(result.funcName).toBe('main'); expect(result.label).toBe('Function'); @@ -363,7 +380,7 @@ describe('extractFunctionName', () => { const tree = parser.parse(code); const funcNode = tree.rootNode.child(0); - const result = extractFunctionName(funcNode); + const result = extractFunctionName(funcNode, cProvider); expect(result.funcName).toBe('helper'); expect(result.label).toBe('Function'); @@ -377,7 +394,7 @@ describe('extractFunctionName', () => { const tree = parser.parse(code); const funcNode = tree.rootNode.child(0); - const result = extractFunctionName(funcNode); + const result = extractFunctionName(funcNode, cppProvider); expect(result.funcName).toBe('OnEncryptData'); expect(result.label).toBe('Method'); @@ -389,7 +406,7 @@ describe('extractFunctionName', () => { const tree = parser.parse(code); const funcNode = tree.rootNode.child(0); - const result = extractFunctionName(funcNode); + const result = extractFunctionName(funcNode, cppProvider); expect(result.funcName).toBe('OnDataOprEvent'); expect(result.label).toBe('Method'); @@ -401,7 +418,7 @@ describe('extractFunctionName', () => { const tree = parser.parse(code); const funcNode = tree.rootNode.child(0); - const result = extractFunctionName(funcNode); + const result = extractFunctionName(funcNode, cppProvider); expect(result.funcName).toBe('standalone_function'); expect(result.label).toBe('Function'); @@ -413,7 +430,7 @@ describe('extractFunctionName', () => { const tree = parser.parse(code); const funcNode = tree.rootNode.child(0); - const result = extractFunctionName(funcNode); + const result = extractFunctionName(funcNode, cppProvider); expect(result.funcName).toBe('handler'); expect(result.label).toBe('Method'); @@ -427,7 +444,7 @@ describe('extractFunctionName', () => { const tree = parser.parse(code); const funcNode = tree.rootNode.child(0); - const result = extractFunctionName(funcNode); + const result = extractFunctionName(funcNode, cProvider); expect(result.funcName).toBe('get_data'); expect(result.label).toBe('Function'); @@ -439,7 +456,7 @@ describe('extractFunctionName', () => { const tree = parser.parse(code); const funcNode = tree.rootNode.child(0); - const result = extractFunctionName(funcNode); + const result = extractFunctionName(funcNode, cProvider); expect(result.funcName).toBe('get_strings'); expect(result.label).toBe('Function'); @@ -451,7 +468,7 @@ describe('extractFunctionName', () => { const tree = parser.parse(code); const funcNode = tree.rootNode.child(0); - const result = extractFunctionName(funcNode); + const result = extractFunctionName(funcNode, cProvider); expect(result.funcName).toBe('create_node'); expect(result.label).toBe('Function'); @@ -465,7 +482,7 @@ describe('extractFunctionName', () => { const tree = parser.parse(code); const funcNode = tree.rootNode.child(0); - const result = extractFunctionName(funcNode); + const result = extractFunctionName(funcNode, cppProvider); expect(result.funcName).toBe('getData'); expect(result.label).toBe('Method'); @@ -477,7 +494,7 @@ describe('extractFunctionName', () => { const tree = parser.parse(code); const funcNode = tree.rootNode.child(0); - const result = extractFunctionName(funcNode); + const result = extractFunctionName(funcNode, cppProvider); expect(result.funcName).toBe('get_name'); expect(result.label).toBe('Function'); @@ -489,7 +506,7 @@ describe('extractFunctionName', () => { const tree = parser.parse(code); const funcNode = tree.rootNode.child(0); - const result = extractFunctionName(funcNode); + const result = extractFunctionName(funcNode, cppProvider); expect(result.funcName).toBe('at'); expect(result.label).toBe('Method'); @@ -501,7 +518,7 @@ describe('extractFunctionName', () => { const tree = parser.parse(code); const funcNode = tree.rootNode.child(0); - const result = extractFunctionName(funcNode); + const result = extractFunctionName(funcNode, cppProvider); expect(result.funcName).toBe('getName'); expect(result.label).toBe('Method'); @@ -515,7 +532,7 @@ describe('extractFunctionName', () => { const tree = parser.parse(code); const funcNode = tree.rootNode.child(0); - const result = extractFunctionName(funcNode); + const result = extractFunctionName(funcNode, cppProvider); // destructor_name includes the ~ prefix expect(result.funcName).toBe('~MyClass'); @@ -533,7 +550,7 @@ describe('extractFunctionName', () => { const declarator = varDecl!.namedChild(0); const arrowFunc = declarator!.namedChild(1); - const result = extractFunctionName(arrowFunc); + const result = extractFunctionName(arrowFunc, tsProvider); expect(result.funcName).toBe('myHandler'); expect(result.label).toBe('Function'); @@ -548,7 +565,7 @@ describe('extractFunctionName', () => { const declarator = varDecl!.namedChild(0); const funcExpr = declarator!.namedChild(1); - const result = extractFunctionName(funcExpr); + const result = extractFunctionName(funcExpr, tsProvider); expect(result.funcName).toBe('processItem'); expect(result.label).toBe('Function'); diff --git a/gitnexus/test/unit/method-signature.test.ts b/gitnexus/test/unit/method-signature.test.ts deleted file mode 100644 index af9e79e55..000000000 --- a/gitnexus/test/unit/method-signature.test.ts +++ /dev/null @@ -1,523 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { extractMethodSignature } from '../../src/core/ingestion/utils/ast-helpers.js'; -import Parser from 'tree-sitter'; -import TypeScript from 'tree-sitter-typescript'; -import Python from 'tree-sitter-python'; -import Java from 'tree-sitter-java'; -import CSharp from 'tree-sitter-c-sharp'; -import Kotlin from 'tree-sitter-kotlin'; -import CPP from 'tree-sitter-cpp'; -import Go from 'tree-sitter-go'; -import Rust from 'tree-sitter-rust'; - -describe('extractMethodSignature', () => { - const parser = new Parser(); - - it('returns zero params and no return type for null node', () => { - const sig = extractMethodSignature(null); - expect(sig.parameterCount).toBe(0); - expect(sig.returnType).toBeUndefined(); - }); - - describe('TypeScript', () => { - it('extracts params and return type from a typed method', () => { - parser.setLanguage(TypeScript.typescript); - const code = `class Foo { - greet(name: string, age: number): boolean { return true; } -}`; - const tree = parser.parse(code); - const classNode = tree.rootNode.child(0)!; - const classBody = classNode.childForFieldName('body')!; - const methodNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(methodNode); - expect(sig.parameterCount).toBe(2); - expect(sig.returnType).toBe('boolean'); - }); - - it('extracts zero params from a method with no parameters', () => { - parser.setLanguage(TypeScript.typescript); - const code = `class Foo { - run(): void {} -}`; - const tree = parser.parse(code); - const classNode = tree.rootNode.child(0)!; - const classBody = classNode.childForFieldName('body')!; - const methodNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(methodNode); - expect(sig.parameterCount).toBe(0); - expect(sig.returnType).toBe('void'); - }); - - it('extracts params without return type annotation', () => { - parser.setLanguage(TypeScript.typescript); - const code = `class Foo { - process(x: number) { return x + 1; } -}`; - const tree = parser.parse(code); - const classNode = tree.rootNode.child(0)!; - const classBody = classNode.childForFieldName('body')!; - const methodNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(methodNode); - expect(sig.parameterCount).toBe(1); - expect(sig.returnType).toBeUndefined(); - }); - - it('skips TypeScript this-parameter (compile-time constraint)', () => { - parser.setLanguage(TypeScript.typescript); - const code = `class Handler { - handle(this: void, event: Event): void {} -}`; - const tree = parser.parse(code); - const classNode = tree.rootNode.child(0)!; - const classBody = classNode.childForFieldName('body')!; - const methodNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(methodNode); - // 'this' is not a real parameter — only 'event' should be counted - expect(sig.parameterCount).toBe(1); - }); - - it('skips this-parameter in top-level function', () => { - parser.setLanguage(TypeScript.typescript); - const code = `function onClick(this: HTMLElement, ev: MouseEvent): void {}`; - const tree = parser.parse(code); - const funcNode = tree.rootNode.child(0)!; - - const sig = extractMethodSignature(funcNode); - expect(sig.parameterCount).toBe(1); - }); - }); - - describe('Python', () => { - it('skips self parameter', () => { - parser.setLanguage(Python); - const code = `class Foo: - def bar(self, x, y): - pass`; - const tree = parser.parse(code); - const classNode = tree.rootNode.child(0)!; - const classBody = classNode.childForFieldName('body')!; - const methodNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(methodNode); - expect(sig.parameterCount).toBe(2); - expect(sig.returnType).toBeUndefined(); - }); - - it('handles method with only self', () => { - parser.setLanguage(Python); - const code = `class Foo: - def noop(self): - pass`; - const tree = parser.parse(code); - const classNode = tree.rootNode.child(0)!; - const classBody = classNode.childForFieldName('body')!; - const methodNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(methodNode); - expect(sig.parameterCount).toBe(0); - }); - - it('handles Python return type annotation', () => { - parser.setLanguage(Python); - const code = `class Foo: - def bar(self, x: int) -> bool: - return True`; - const tree = parser.parse(code); - const classNode = tree.rootNode.child(0)!; - const classBody = classNode.childForFieldName('body')!; - const methodNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(methodNode); - expect(sig.parameterCount).toBe(1); - // The important thing is parameterCount is correct; returnType may vary. - }); - }); - - describe('Java', () => { - it('extracts params from a Java method', () => { - parser.setLanguage(Java); - const code = `class Foo { - public int add(int a, int b) { return a + b; } -}`; - const tree = parser.parse(code); - const classNode = tree.rootNode.child(0)!; - const classBody = classNode.childForFieldName('body')!; - const methodNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(methodNode); - expect(sig.parameterCount).toBe(2); - }); - - it('extracts zero params from no-arg Java method', () => { - parser.setLanguage(Java); - const code = `class Foo { - public void run() {} -}`; - const tree = parser.parse(code); - const classNode = tree.rootNode.child(0)!; - const classBody = classNode.childForFieldName('body')!; - const methodNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(methodNode); - expect(sig.parameterCount).toBe(0); - }); - - it('extracts parameterTypes for Java overloaded methods', () => { - parser.setLanguage(Java); - const code = `class Svc { - public User lookup(int id) { return null; } - public User lookup(String name) { return null; } - public void process(int code, String msg) {} -}`; - const tree = parser.parse(code); - const classBody = tree.rootNode.child(0)!.childForFieldName('body')!; - - const sig0 = extractMethodSignature(classBody.namedChild(0)!); - expect(sig0.parameterCount).toBe(1); - expect(sig0.parameterTypes).toEqual(['int']); - - const sig1 = extractMethodSignature(classBody.namedChild(1)!); - expect(sig1.parameterCount).toBe(1); - expect(sig1.parameterTypes).toEqual(['String']); - - const sig2 = extractMethodSignature(classBody.namedChild(2)!); - expect(sig2.parameterCount).toBe(2); - expect(sig2.parameterTypes).toEqual(['int', 'String']); - }); - }); - - describe('Kotlin', () => { - it('extracts params from a Kotlin function declaration', () => { - parser.setLanguage(Kotlin); - const code = `object OneArg { - fun writeAudit(message: String): String { - return message - } -}`; - const tree = parser.parse(code); - const objectNode = tree.rootNode.child(0)!; - const classBody = objectNode.namedChild(1)!; - const functionNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(functionNode); - expect(sig.parameterCount).toBe(1); - }); - - it('extracts zero params from a no-arg Kotlin function', () => { - parser.setLanguage(Kotlin); - const code = `object ZeroArg { - fun writeAudit(): String { - return "zero" - } -}`; - const tree = parser.parse(code); - const objectNode = tree.rootNode.child(0)!; - const classBody = objectNode.namedChild(1)!; - const functionNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(functionNode); - expect(sig.parameterCount).toBe(0); - }); - - it('extracts parameterTypes for Kotlin overloaded functions', () => { - parser.setLanguage(Kotlin); - const code = `class Svc { - fun lookup(id: Int): User? { return null } - fun lookup(name: String): User? { return null } -}`; - const tree = parser.parse(code); - const classBody = tree.rootNode.child(0)!.namedChild(1)!; - - const sig0 = extractMethodSignature(classBody.namedChild(0)!); - expect(sig0.parameterCount).toBe(1); - expect(sig0.parameterTypes).toEqual(['Int']); - - const sig1 = extractMethodSignature(classBody.namedChild(1)!); - expect(sig1.parameterCount).toBe(1); - expect(sig1.parameterTypes).toEqual(['String']); - }); - }); - - describe('C++', () => { - it('extracts params from a nested C++ declarator', () => { - parser.setLanguage(CPP); - const code = `inline const char* write_audit(const char* message) { - return message; -}`; - const tree = parser.parse(code); - const functionNode = tree.rootNode.namedChild(0)!; - - const sig = extractMethodSignature(functionNode); - expect(sig.parameterCount).toBe(1); - }); - - it('extracts zero params from a no-arg C++ function', () => { - parser.setLanguage(CPP); - const code = `inline const char* write_audit() { - return "zero"; -}`; - const tree = parser.parse(code); - const functionNode = tree.rootNode.namedChild(0)!; - - const sig = extractMethodSignature(functionNode); - expect(sig.parameterCount).toBe(0); - }); - - it('extracts parameterTypes for C++ overloaded functions', () => { - parser.setLanguage(CPP); - const code = `User* lookup(int id) { return nullptr; } -User* lookup(string name) { return nullptr; }`; - const tree = parser.parse(code); - - const sig0 = extractMethodSignature(tree.rootNode.namedChild(0)!); - expect(sig0.parameterCount).toBe(1); - expect(sig0.parameterTypes).toEqual(['int']); - - const sig1 = extractMethodSignature(tree.rootNode.namedChild(1)!); - expect(sig1.parameterCount).toBe(1); - expect(sig1.parameterTypes).toEqual(['string']); - }); - }); - - describe('C#', () => { - it('extracts params from a C# method', () => { - parser.setLanguage(CSharp); - const code = `class Foo { - public bool Check(string name, int count) { return true; } -}`; - const tree = parser.parse(code); - const classNode = tree.rootNode.child(0)!; - const classBody = classNode.childForFieldName('body')!; - const methodNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(methodNode); - expect(sig.parameterCount).toBe(2); - }); - - it('extracts parameterTypes for C# overloaded methods', () => { - parser.setLanguage(CSharp); - const code = `class Svc { - public User Lookup(int id) { return null; } - public User Lookup(string name) { return null; } -}`; - const tree = parser.parse(code); - const classBody = tree.rootNode.child(0)!.childForFieldName('body')!; - - const sig0 = extractMethodSignature(classBody.namedChild(0)!); - expect(sig0.parameterCount).toBe(1); - expect(sig0.parameterTypes).toEqual(['int']); - - const sig1 = extractMethodSignature(classBody.namedChild(1)!); - expect(sig1.parameterCount).toBe(1); - expect(sig1.parameterTypes).toEqual(['string']); - }); - - it('handles C# method with no params', () => { - parser.setLanguage(CSharp); - const code = `class Foo { - public void Execute() {} -}`; - const tree = parser.parse(code); - const classNode = tree.rootNode.child(0)!; - const classBody = classNode.childForFieldName('body')!; - const methodNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(methodNode); - expect(sig.parameterCount).toBe(0); - }); - - it('extracts return type from C# method', () => { - parser.setLanguage(CSharp); - const code = `class Svc { - public User GetUser(string name) { return null; } -}`; - const tree = parser.parse(code); - const classNode = tree.rootNode.child(0)!; - const classBody = classNode.childForFieldName('body')!; - const methodNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(methodNode); - expect(sig.returnType).toBe('User'); - }); - }); - - describe('Go', () => { - it('extracts params and single return type', () => { - parser.setLanguage(Go); - const code = `package main -func add(a int, b int) int { return a + b }`; - const tree = parser.parse(code); - const funcNode = tree.rootNode.namedChildren.find((c) => c.type === 'function_declaration')!; - - const sig = extractMethodSignature(funcNode); - expect(sig.parameterCount).toBe(2); - expect(sig.returnType).toBe('int'); - }); - - it('extracts multi-return type', () => { - parser.setLanguage(Go); - const code = `package main -func parse(s string) (string, error) { return s, nil }`; - const tree = parser.parse(code); - const funcNode = tree.rootNode.namedChildren.find((c) => c.type === 'function_declaration')!; - - const sig = extractMethodSignature(funcNode); - expect(sig.parameterCount).toBe(1); - expect(sig.returnType).toBe('string'); - }); - - it('handles no return type', () => { - parser.setLanguage(Go); - const code = `package main -func doSomething(x int) { }`; - const tree = parser.parse(code); - const funcNode = tree.rootNode.namedChildren.find((c) => c.type === 'function_declaration')!; - - const sig = extractMethodSignature(funcNode); - expect(sig.parameterCount).toBe(1); - expect(sig.returnType).toBeUndefined(); - }); - - it('marks variadic function with undefined parameterCount', () => { - parser.setLanguage(Go); - const code = `package main -func log(args ...string) int { return 0 }`; - const tree = parser.parse(code); - const funcNode = tree.rootNode.namedChildren.find((c) => c.type === 'function_declaration')!; - - const sig = extractMethodSignature(funcNode); - expect(sig.parameterCount).toBeUndefined(); - expect(sig.returnType).toBe('int'); - }); - }); - - describe('Rust', () => { - it('extracts return type from function', () => { - parser.setLanguage(Rust); - const code = `fn add(a: i32, b: i32) -> i32 { a + b }`; - const tree = parser.parse(code); - const funcNode = tree.rootNode.namedChild(0)!; - - const sig = extractMethodSignature(funcNode); - expect(sig.parameterCount).toBe(2); - expect(sig.returnType).toBe('i32'); - }); - }); - - describe('C++ return types', () => { - it('extracts primitive return type', () => { - parser.setLanguage(CPP); - const code = `int add(int a, int b) { return a + b; }`; - const tree = parser.parse(code); - const funcNode = tree.rootNode.namedChild(0)!; - - const sig = extractMethodSignature(funcNode); - expect(sig.parameterCount).toBe(2); - expect(sig.returnType).toBe('int'); - }); - - it('extracts qualified return type', () => { - parser.setLanguage(CPP); - const code = `std::string getName() { return ""; }`; - const tree = parser.parse(code); - const funcNode = tree.rootNode.namedChild(0)!; - - const sig = extractMethodSignature(funcNode); - expect(sig.parameterCount).toBe(0); - expect(sig.returnType).toBe('std::string'); - }); - - it('returns undefined returnType for void', () => { - parser.setLanguage(CPP); - const code = `void doNothing() { }`; - const tree = parser.parse(code); - const funcNode = tree.rootNode.namedChild(0)!; - - const sig = extractMethodSignature(funcNode); - expect(sig.returnType).toBeUndefined(); - }); - - it('marks variadic function with undefined parameterCount', () => { - parser.setLanguage(CPP); - const code = `int printf(const char* fmt, ...) { return 0; }`; - const tree = parser.parse(code); - const funcNode = tree.rootNode.namedChild(0)!; - - const sig = extractMethodSignature(funcNode); - expect(sig.parameterCount).toBeUndefined(); - expect(sig.returnType).toBe('int'); - }); - }); - - describe('variadic params', () => { - it('Java: marks varargs with undefined parameterCount', () => { - parser.setLanguage(Java); - const code = `class Foo { - public void log(String fmt, Object... args) {} -}`; - const tree = parser.parse(code); - const classNode = tree.rootNode.child(0)!; - const classBody = classNode.childForFieldName('body')!; - const methodNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(methodNode); - expect(sig.parameterCount).toBeUndefined(); - }); - - it('Python: marks *args with undefined parameterCount', () => { - parser.setLanguage(Python); - const code = `class Foo: - def log(self, fmt, *args): - pass`; - const tree = parser.parse(code); - const classNode = tree.rootNode.child(0)!; - const classBody = classNode.childForFieldName('body')!; - const methodNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(methodNode); - expect(sig.parameterCount).toBeUndefined(); - }); - - it('Python: marks **kwargs with undefined parameterCount', () => { - parser.setLanguage(Python); - const code = `class Foo: - def config(self, **kwargs): - pass`; - const tree = parser.parse(code); - const classNode = tree.rootNode.child(0)!; - const classBody = classNode.childForFieldName('body')!; - const methodNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(methodNode); - expect(sig.parameterCount).toBeUndefined(); - }); - - it('TypeScript: marks rest params with undefined parameterCount', () => { - parser.setLanguage(TypeScript.typescript); - const code = `function logEntry(...messages: string[]): void {}`; - const tree = parser.parse(code); - const funcNode = tree.rootNode.namedChild(0)!; - - const sig = extractMethodSignature(funcNode); - expect(sig.parameterCount).toBeUndefined(); - }); - - it('Kotlin: marks vararg with undefined parameterCount', () => { - parser.setLanguage(Kotlin); - const code = `object Foo { - fun log(vararg args: String) {} -}`; - const tree = parser.parse(code); - const objectNode = tree.rootNode.child(0)!; - const classBody = objectNode.namedChild(1)!; - const functionNode = classBody.namedChild(0)!; - - const sig = extractMethodSignature(functionNode); - expect(sig.parameterCount).toBeUndefined(); - }); - }); -}); diff --git a/gitnexus/test/unit/mro-processor.test.ts b/gitnexus/test/unit/mro-processor.test.ts index d071e41e2..51b2791e0 100644 --- a/gitnexus/test/unit/mro-processor.test.ts +++ b/gitnexus/test/unit/mro-processor.test.ts @@ -28,13 +28,23 @@ function addMethod( className: string, methodName: string, classLabel: 'Class' | 'Interface' | 'Struct' | 'Trait' = 'Class', + parameterTypes?: string[], + opts?: { isAbstract?: boolean; parameterCount?: number }, ) { + // Derive arity for the ID suffix: explicit parameterCount > parameterTypes.length > 0 + const arity = opts?.parameterCount ?? parameterTypes?.length ?? 0; const classId = generateId(classLabel, className); - const methodId = generateId('Method', `${className}.${methodName}`); + const methodId = generateId('Method', `${className}.${methodName}#${arity}`); graph.addNode({ id: methodId, label: 'Method', - properties: { name: methodName, filePath: `src/${className}.ts` }, + properties: { + name: methodName, + filePath: `src/${className}.ts`, + parameterCount: arity, + ...(parameterTypes ? { parameterTypes } : {}), + ...(opts?.isAbstract !== undefined ? { isAbstract: opts.isAbstract } : {}), + }, }); graph.addRelationship({ id: generateId('HAS_METHOD', `${classId}->${methodId}`), @@ -66,6 +76,25 @@ function addExtends( }); } +function addInterfaceExtends( + graph: KnowledgeGraph, + childName: string, + parentName: string, + childLabel: 'Interface' | 'Trait' = 'Interface', + parentLabel: 'Interface' | 'Trait' = 'Interface', +) { + const childId = generateId(childLabel, childName); + const parentId = generateId(parentLabel, parentName); + graph.addRelationship({ + id: generateId('EXTENDS', `${childId}->${parentId}`), + sourceId: childId, + targetId: parentId, + type: 'EXTENDS', + confidence: 1.0, + reason: '', + }); +} + function addImplements( graph: KnowledgeGraph, childName: string, @@ -128,7 +157,7 @@ describe('computeMRO', () => { // OVERRIDES edge emitted expect(result.overrideEdges).toBeGreaterThanOrEqual(1); - const overrides = graph.relationships.filter((r) => r.type === 'OVERRIDES'); + const overrides = graph.relationships.filter((r) => r.type === 'METHOD_OVERRIDES'); expect(overrides.some((r) => r.sourceId === dId && r.targetId === bFoo)).toBe(true); }); @@ -296,7 +325,7 @@ describe('computeMRO', () => { // No OVERRIDES edge emitted for Rust ambiguity const overrides = graph.relationships.filter( - (r) => r.type === 'OVERRIDES' && r.sourceId === generateId('Struct', 'MyStruct'), + (r) => r.type === 'METHOD_OVERRIDES' && r.sourceId === generateId('Struct', 'MyStruct'), ); expect(overrides).toHaveLength(0); }); @@ -347,7 +376,7 @@ describe('computeMRO', () => { const result = computeMRO(graph); // No OVERRIDES edge should be emitted for properties - const overrides = graph.relationships.filter((r) => r.type === 'OVERRIDES'); + const overrides = graph.relationships.filter((r) => r.type === 'METHOD_OVERRIDES'); expect(overrides).toHaveLength(0); expect(result.overrideEdges).toBe(0); }); @@ -399,7 +428,7 @@ describe('computeMRO', () => { const result = computeMRO(graph); // Only 1 OVERRIDES edge (for the method, not the property) - const overrides = graph.relationships.filter((r) => r.type === 'OVERRIDES'); + const overrides = graph.relationships.filter((r) => r.type === 'METHOD_OVERRIDES'); expect(overrides).toHaveLength(1); expect(overrides[0].targetId).toBe(methodA); // leftmost base wins for C++ expect(result.overrideEdges).toBe(1); @@ -511,4 +540,1180 @@ describe('computeMRO', () => { expect(result).toBeDefined(); }); }); + + // ---- METHOD_IMPLEMENTS edges ----------------------------------------------- + describe('METHOD_IMPLEMENTS edges', () => { + it('emits METHOD_IMPLEMENTS for class implementing interface method', () => { + // IAnimal { speak() } <-- Dog { speak() } + const graph = createKnowledgeGraph(); + addClass(graph, 'IAnimal', 'java', 'Interface'); + addClass(graph, 'Dog', 'java'); + addImplements(graph, 'Dog', 'IAnimal'); + const ifaceMethod = addMethod(graph, 'IAnimal', 'speak', 'Interface'); + const classMethod = addMethod(graph, 'Dog', 'speak'); + + const result = computeMRO(graph); + expect(result.methodImplementsEdges).toBe(1); + + // Verify the edge exists: ConcreteMethod → InterfaceMethod + const edges: any[] = []; + graph.forEachRelationship((rel) => { + if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel); + }); + expect(edges).toHaveLength(1); + expect(edges[0].sourceId).toBe(classMethod); + expect(edges[0].targetId).toBe(ifaceMethod); + // Both sides have parameterCount=0 (arity match) → confidence 1.0 + expect(edges[0].confidence).toBe(1.0); + }); + + it('emits METHOD_IMPLEMENTS for Rust struct implementing trait', () => { + // Drawable { draw() } <-- Circle { draw() } + const graph = createKnowledgeGraph(); + addClass(graph, 'Drawable', 'rust', 'Trait'); + addClass(graph, 'Circle', 'rust', 'Struct'); + addImplements(graph, 'Circle', 'Drawable', 'Struct', 'Trait'); + const traitMethod = addMethod(graph, 'Drawable', 'draw', 'Trait'); + const structMethod = addMethod(graph, 'Circle', 'draw', 'Struct'); + + const result = computeMRO(graph); + expect(result.methodImplementsEdges).toBe(1); + + const edges: any[] = []; + graph.forEachRelationship((rel) => { + if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel); + }); + expect(edges[0].sourceId).toBe(structMethod); + expect(edges[0].targetId).toBe(traitMethod); + }); + + it('matches overloaded interface methods by parameterTypes', () => { + // IRepo { find(String), find(String, int) } <-- SqlRepo { find(String), find(String, int) } + const graph = createKnowledgeGraph(); + addClass(graph, 'IRepo', 'java', 'Interface'); + addClass(graph, 'SqlRepo', 'java'); + addImplements(graph, 'SqlRepo', 'IRepo'); + + // Use manual IDs to avoid overloaded-name collision (same name, different types) + const ifaceFind1 = generateId('Method', 'IRepo.find#1'); + graph.addNode({ + id: ifaceFind1, + label: 'Method', + properties: { + name: 'find', + filePath: 'src/IRepo.ts', + parameterTypes: ['String'], + parameterCount: 1, + }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Interface', 'IRepo')}->${ifaceFind1}`), + sourceId: generateId('Interface', 'IRepo'), + targetId: ifaceFind1, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + const ifaceFind2 = generateId('Method', 'IRepo.find#2'); + graph.addNode({ + id: ifaceFind2, + label: 'Method', + properties: { + name: 'find', + filePath: 'src/IRepo.ts', + parameterTypes: ['String', 'int'], + parameterCount: 2, + }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Interface', 'IRepo')}->${ifaceFind2}`), + sourceId: generateId('Interface', 'IRepo'), + targetId: ifaceFind2, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + const sqlFind1Id = generateId('Method', 'SqlRepo.find#1'); + graph.addNode({ + id: sqlFind1Id, + label: 'Method', + properties: { + name: 'find', + filePath: 'src/SqlRepo.ts', + parameterTypes: ['String'], + parameterCount: 1, + }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Class', 'SqlRepo')}->${sqlFind1Id}`), + sourceId: generateId('Class', 'SqlRepo'), + targetId: sqlFind1Id, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + const sqlFind2Id = generateId('Method', 'SqlRepo.find#2'); + graph.addNode({ + id: sqlFind2Id, + label: 'Method', + properties: { + name: 'find', + filePath: 'src/SqlRepo.ts', + parameterTypes: ['String', 'int'], + parameterCount: 2, + }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Class', 'SqlRepo')}->${sqlFind2Id}`), + sourceId: generateId('Class', 'SqlRepo'), + targetId: sqlFind2Id, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + const result = computeMRO(graph); + expect(result.methodImplementsEdges).toBe(2); + + const edges: any[] = []; + graph.forEachRelationship((rel) => { + if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel); + }); + expect(edges).toHaveLength(2); + // find(String) → find(String) and find(String, int) → find(String, int) + const edge1 = edges.find((e) => e.targetId === ifaceFind1); + const edge2 = edges.find((e) => e.targetId === ifaceFind2); + expect(edge1).toBeDefined(); + expect(edge1!.sourceId).toBe(sqlFind1Id); + expect(edge2).toBeDefined(); + expect(edge2!.sourceId).toBe(sqlFind2Id); + }); + + it('includes default interface methods (not just abstract)', () => { + // Java 8 default method: IFoo { bar() } <-- Baz { bar() } + const graph = createKnowledgeGraph(); + addClass(graph, 'IFoo', 'java', 'Interface'); + addClass(graph, 'Baz', 'java'); + addImplements(graph, 'Baz', 'IFoo'); + // Default method (has body, not abstract) — should still get METHOD_IMPLEMENTS + addMethod(graph, 'IFoo', 'bar', 'Interface'); + addMethod(graph, 'Baz', 'bar'); + + const result = computeMRO(graph); + expect(result.methodImplementsEdges).toBe(1); + }); + + it('does not emit METHOD_IMPLEMENTS for class extending another class', () => { + // Animal { speak() } <-- Dog { speak() } — EXTENDS, not IMPLEMENTS + const graph = createKnowledgeGraph(); + addClass(graph, 'Animal', 'java'); + addClass(graph, 'Dog', 'java'); + addExtends(graph, 'Dog', 'Animal'); + addMethod(graph, 'Animal', 'speak'); + addMethod(graph, 'Dog', 'speak'); + + const result = computeMRO(graph); + expect(result.methodImplementsEdges).toBe(0); + }); + + it('does not emit METHOD_IMPLEMENTS when class has no matching method', () => { + // IAnimal { speak() } <-- Dog { bark() } — no name match + const graph = createKnowledgeGraph(); + addClass(graph, 'IAnimal', 'java', 'Interface'); + addClass(graph, 'Dog', 'java'); + addImplements(graph, 'Dog', 'IAnimal'); + addMethod(graph, 'IAnimal', 'speak', 'Interface'); + addMethod(graph, 'Dog', 'bark'); + + const result = computeMRO(graph); + expect(result.methodImplementsEdges).toBe(0); + }); + + it('skips Property nodes on interface', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'IFoo', 'csharp', 'Interface'); + addClass(graph, 'Bar', 'csharp'); + addImplements(graph, 'Bar', 'IFoo'); + + // Add a Property to the interface (not a Method) + const propId = generateId('Property', 'IFoo.name'); + graph.addNode({ + id: propId, + label: 'Property', + properties: { name: 'name', filePath: 'src/IFoo.ts' }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Interface', 'IFoo')}->${propId}`), + sourceId: generateId('Interface', 'IFoo'), + targetId: propId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + addMethod(graph, 'Bar', 'name'); + + const result = computeMRO(graph); + expect(result.methodImplementsEdges).toBe(0); + }); + + describe('METHOD_IMPLEMENTS transitive ancestors', () => { + it('transitive interface chain: C.foo links to both B.foo and A.foo', () => { + // A (Interface) has foo, B (Interface) has foo extends A, C (Class) implements B + const graph = createKnowledgeGraph(); + addClass(graph, 'A', 'java', 'Interface'); + addClass(graph, 'B', 'java', 'Interface'); + addClass(graph, 'C', 'java'); + + addInterfaceExtends(graph, 'B', 'A'); + addImplements(graph, 'C', 'B'); + + const aFoo = addMethod(graph, 'A', 'foo', 'Interface'); + const bFoo = addMethod(graph, 'B', 'foo', 'Interface'); + addMethod(graph, 'C', 'foo'); + + const result = computeMRO(graph); + + const edges: any[] = []; + graph.forEachRelationship((rel) => { + if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel); + }); + + // C.foo should link to both B.foo and A.foo + expect(edges.some((e) => e.targetId === bFoo)).toBe(true); + expect(edges.some((e) => e.targetId === aFoo)).toBe(true); + expect(result.methodImplementsEdges).toBeGreaterThanOrEqual(2); + }); + + it('inherited contract method only on grandparent: C.bar links to A.bar', () => { + // A (Interface) has bar, B (Interface) extends A but has NO bar, C implements B + const graph = createKnowledgeGraph(); + addClass(graph, 'A', 'java', 'Interface'); + addClass(graph, 'B', 'java', 'Interface'); + addClass(graph, 'C', 'java'); + + addInterfaceExtends(graph, 'B', 'A'); + addImplements(graph, 'C', 'B'); + + const aBar = addMethod(graph, 'A', 'bar', 'Interface'); + // B has no bar method + addMethod(graph, 'C', 'bar'); + + const result = computeMRO(graph); + + const edges: any[] = []; + graph.forEachRelationship((rel) => { + if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel); + }); + + // C.bar should link to A.bar even though A is not a direct parent + expect(edges.some((e) => e.targetId === aBar)).toBe(true); + expect(result.methodImplementsEdges).toBeGreaterThanOrEqual(1); + }); + + it('diamond deduplication: E.foo gets exactly one edge to A.foo', () => { + // A (Interface) has foo + // B (Interface) has foo, extends A + // D (Interface) has foo, extends A + // E (Class) implements B and D + const graph = createKnowledgeGraph(); + addClass(graph, 'A', 'java', 'Interface'); + addClass(graph, 'B', 'java', 'Interface'); + addClass(graph, 'D', 'java', 'Interface'); + addClass(graph, 'E', 'java'); + + addInterfaceExtends(graph, 'B', 'A'); + addInterfaceExtends(graph, 'D', 'A'); + addImplements(graph, 'E', 'B'); + addImplements(graph, 'E', 'D'); + + const aFoo = addMethod(graph, 'A', 'foo', 'Interface'); + const bFoo = addMethod(graph, 'B', 'foo', 'Interface'); + const dFoo = addMethod(graph, 'D', 'foo', 'Interface'); + addMethod(graph, 'E', 'foo'); + + const result = computeMRO(graph); + + const eFoo = generateId('Method', 'E.foo#0'); + const edges: any[] = []; + graph.forEachRelationship((rel) => { + if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel); + }); + + // Filter to only edges FROM E.foo + const eFooEdges = edges.filter((e) => e.sourceId === eFoo); + + // E.foo should link to B.foo, D.foo, and exactly ONE A.foo (deduplicated) + expect(eFooEdges.filter((e) => e.targetId === bFoo)).toHaveLength(1); + expect(eFooEdges.filter((e) => e.targetId === dFoo)).toHaveLength(1); + expect(eFooEdges.filter((e) => e.targetId === aFoo)).toHaveLength(1); + // Total from E.foo: 3 edges (B.foo + D.foo + A.foo), not 4 + expect(eFooEdges).toHaveLength(3); + }); + + it('no transitive through class-only chain', () => { + // A (Class) has foo, B (Class) extends A has foo, C (Class) extends B has foo + const graph = createKnowledgeGraph(); + addClass(graph, 'A', 'java'); + addClass(graph, 'B', 'java'); + addClass(graph, 'C', 'java'); + + addExtends(graph, 'B', 'A'); + addExtends(graph, 'C', 'B'); + + addMethod(graph, 'A', 'foo'); + addMethod(graph, 'B', 'foo'); + addMethod(graph, 'C', 'foo'); + + const result = computeMRO(graph); + + // All class-extends, no interface involved → 0 METHOD_IMPLEMENTS edges + expect(result.methodImplementsEdges).toBe(0); + }); + }); + + it('is queryable via MATCH pattern', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'IRepo', 'typescript', 'Interface'); + addClass(graph, 'SqlRepo', 'typescript'); + addImplements(graph, 'SqlRepo', 'IRepo'); + addMethod(graph, 'IRepo', 'fetch', 'Interface'); + const concreteId = addMethod(graph, 'SqlRepo', 'fetch'); + + computeMRO(graph); + + // Simulate MATCH (m)-[:METHOD_IMPLEMENTS]->(i) RETURN m + const implementingMethods: string[] = []; + graph.forEachRelationship((rel) => { + if (rel.type === 'METHOD_IMPLEMENTS') { + implementingMethods.push(rel.sourceId); + } + }); + expect(implementingMethods).toContain(concreteId); + }); + + describe('METHOD_IMPLEMENTS inherited + arity matching', () => { + it('inherited implementation: Base.foo satisfies I.foo when C has no own foo', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'Base', 'java'); + addClass(graph, 'I', 'java', 'Interface'); + addClass(graph, 'C', 'java'); + + addExtends(graph, 'C', 'Base'); + addImplements(graph, 'C', 'I'); + + const baseFoo = addMethod(graph, 'Base', 'foo'); + const iFoo = addMethod(graph, 'I', 'foo', 'Interface'); + + const result = computeMRO(graph); + + const edges: any[] = []; + graph.forEachRelationship((rel) => { + if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel); + }); + + expect(edges).toHaveLength(1); + expect(edges[0].sourceId).toBe(baseFoo); + expect(edges[0].targetId).toBe(iFoo); + expect(result.methodImplementsEdges).toBe(1); + }); + + it('class has own method — no inherited lookup needed', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'Base2', 'java'); + addClass(graph, 'I2', 'java', 'Interface'); + addClass(graph, 'C2', 'java'); + + addExtends(graph, 'C2', 'Base2'); + addImplements(graph, 'C2', 'I2'); + + const baseFoo = addMethod(graph, 'Base2', 'foo'); + const iFoo = addMethod(graph, 'I2', 'foo', 'Interface'); + const cFoo = addMethod(graph, 'C2', 'foo'); + + const result = computeMRO(graph); + + const edges: any[] = []; + graph.forEachRelationship((rel) => { + if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel); + }); + + // Should use C2.foo, not Base2.foo + expect(edges).toHaveLength(1); + expect(edges[0].sourceId).toBe(cFoo); + expect(edges[0].targetId).toBe(iFoo); + }); + + it('deep inheritance chain: GrandBase.foo satisfies I.foo', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'GrandBase', 'java'); + addClass(graph, 'Base3', 'java'); + addClass(graph, 'I3', 'java', 'Interface'); + addClass(graph, 'C3', 'java'); + + addExtends(graph, 'Base3', 'GrandBase'); + addExtends(graph, 'C3', 'Base3'); + addImplements(graph, 'C3', 'I3'); + + const grandFoo = addMethod(graph, 'GrandBase', 'foo'); + // Base3 has NO foo + const iFoo = addMethod(graph, 'I3', 'foo', 'Interface'); + + const result = computeMRO(graph); + + const edges: any[] = []; + graph.forEachRelationship((rel) => { + if (rel.type === 'METHOD_IMPLEMENTS') edges.push(rel); + }); + + expect(edges).toHaveLength(1); + expect(edges[0].sourceId).toBe(grandFoo); + expect(edges[0].targetId).toBe(iFoo); + expect(result.methodImplementsEdges).toBe(1); + }); + + it('arity mismatch prevents false match', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'IArity', 'java', 'Interface'); + addClass(graph, 'CArity', 'java'); + addImplements(graph, 'CArity', 'IArity'); + + // Interface method: parameterCount=2, no parameterTypes + const iMethodId = generateId('Method', 'IArity.process#2'); + graph.addNode({ + id: iMethodId, + label: 'Method', + properties: { name: 'process', filePath: 'src/IArity.ts', parameterCount: 2 }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Interface', 'IArity')}->${iMethodId}`), + sourceId: generateId('Interface', 'IArity'), + targetId: iMethodId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + // Class method: parameterCount=3, no parameterTypes + const cMethodId = generateId('Method', 'CArity.process#3'); + graph.addNode({ + id: cMethodId, + label: 'Method', + properties: { name: 'process', filePath: 'src/CArity.ts', parameterCount: 3 }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Class', 'CArity')}->${cMethodId}`), + sourceId: generateId('Class', 'CArity'), + targetId: cMethodId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + const result = computeMRO(graph); + expect(result.methodImplementsEdges).toBe(0); + }); + + it('arity match when types missing', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'IArityOk', 'java', 'Interface'); + addClass(graph, 'CArityOk', 'java'); + addImplements(graph, 'CArityOk', 'IArityOk'); + + // Interface method: parameterCount=2, no parameterTypes + const iMethodId = generateId('Method', 'IArityOk.process#2'); + graph.addNode({ + id: iMethodId, + label: 'Method', + properties: { name: 'process', filePath: 'src/IArityOk.ts', parameterCount: 2 }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Interface', 'IArityOk')}->${iMethodId}`), + sourceId: generateId('Interface', 'IArityOk'), + targetId: iMethodId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + // Class method: parameterCount=2, no parameterTypes + const cMethodId = generateId('Method', 'CArityOk.process#2'); + graph.addNode({ + id: cMethodId, + label: 'Method', + properties: { name: 'process', filePath: 'src/CArityOk.ts', parameterCount: 2 }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Class', 'CArityOk')}->${cMethodId}`), + sourceId: generateId('Class', 'CArityOk'), + targetId: cMethodId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + const result = computeMRO(graph); + expect(result.methodImplementsEdges).toBe(1); + }); + + it('multiple same-arity candidates = ambiguous, no edge emitted', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'IAmbig', 'java', 'Interface'); + addClass(graph, 'CAmbig', 'java'); + addImplements(graph, 'CAmbig', 'IAmbig'); + + // Interface method: parameterCount=1, no parameterTypes + const iMethodId = generateId('Method', 'IAmbig.handle#1'); + graph.addNode({ + id: iMethodId, + label: 'Method', + properties: { name: 'handle', filePath: 'src/IAmbig.ts', parameterCount: 1 }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Interface', 'IAmbig')}->${iMethodId}`), + sourceId: generateId('Interface', 'IAmbig'), + targetId: iMethodId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + // Two class methods named handle, both with parameterCount=1 + const cMethod1 = generateId('Method', 'CAmbig.handle.1#1'); + graph.addNode({ + id: cMethod1, + label: 'Method', + properties: { name: 'handle', filePath: 'src/CAmbig.ts', parameterCount: 1 }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Class', 'CAmbig')}->${cMethod1}`), + sourceId: generateId('Class', 'CAmbig'), + targetId: cMethod1, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + const cMethod2 = generateId('Method', 'CAmbig.handle.2#1'); + graph.addNode({ + id: cMethod2, + label: 'Method', + properties: { name: 'handle', filePath: 'src/CAmbig.ts', parameterCount: 1 }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Class', 'CAmbig')}->${cMethod2}`), + sourceId: generateId('Class', 'CAmbig'), + targetId: cMethod2, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + const result = computeMRO(graph); + expect(result.methodImplementsEdges).toBe(0); + }); + }); + }); + + // ---- findInheritedMethod ambiguity detection ------------------------------ + describe('findInheritedMethod ambiguity', () => { + it('returns null when two EXTENDS parents both provide matching method', () => { + // I { foo() }, B { foo() }, M { foo() }, C extends B + M, C implements I + const graph = createKnowledgeGraph(); + addClass(graph, 'I', 'cpp', 'Interface'); + addClass(graph, 'B', 'cpp'); + addClass(graph, 'M', 'cpp'); + addClass(graph, 'C', 'cpp'); + addImplements(graph, 'C', 'I'); + addExtends(graph, 'C', 'B'); + addExtends(graph, 'C', 'M'); + addMethod(graph, 'I', 'foo', 'Interface'); + addMethod(graph, 'B', 'foo'); + addMethod(graph, 'M', 'foo'); + // C has NO own foo — must walk EXTENDS chain + + const result = computeMRO(graph); + // Ambiguous: B.foo and M.foo both match — no METHOD_IMPLEMENTS edge + const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + const fooEdges = mi.filter((e) => graph.getNode(e.targetId)?.properties.name === 'foo'); + expect(fooEdges).toHaveLength(0); + }); + + it('diamond dedup: same method via two paths is NOT ambiguous', () => { + // I { foo() }, GrandBase { foo() }, B extends GrandBase, M extends GrandBase + // C extends B + M, C implements I + const graph = createKnowledgeGraph(); + addClass(graph, 'I', 'cpp', 'Interface'); + addClass(graph, 'GrandBase', 'cpp'); + addClass(graph, 'B', 'cpp'); + addClass(graph, 'M', 'cpp'); + addClass(graph, 'C', 'cpp'); + addImplements(graph, 'C', 'I'); + addExtends(graph, 'C', 'B'); + addExtends(graph, 'C', 'M'); + addExtends(graph, 'B', 'GrandBase'); + addExtends(graph, 'M', 'GrandBase'); + addMethod(graph, 'I', 'foo', 'Interface'); + const gbFoo = addMethod(graph, 'GrandBase', 'foo'); + // B and M have NO own foo — both inherit from GrandBase + + const result = computeMRO(graph); + // Not ambiguous: same GrandBase.foo via both paths + const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + const fooEdge = mi.find((e) => e.sourceId === gbFoo); + expect(fooEdge).toBeDefined(); + }); + + it('C extends B extends A, B and A both have foo → returns B.foo (nearest)', () => { + // I { foo() }, A { foo() }, B extends A { foo() }, C extends B implements I { no foo } + const graph = createKnowledgeGraph(); + addClass(graph, 'I', 'java', 'Interface'); + addClass(graph, 'A', 'java'); + addClass(graph, 'B', 'java'); + addClass(graph, 'C', 'java'); + addImplements(graph, 'C', 'I'); + addExtends(graph, 'C', 'B'); + addExtends(graph, 'B', 'A'); + addMethod(graph, 'I', 'foo', 'Interface'); + addMethod(graph, 'A', 'foo'); + const bFoo = addMethod(graph, 'B', 'foo'); + // C has NO own foo — nearest is B.foo at depth 1 + + computeMRO(graph); + const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + const fooEdge = mi.find((e) => e.sourceId === bFoo); + expect(fooEdge).toBeDefined(); + // A.foo should NOT be reached + const aFooId = generateId('Method', 'A.foo#0'); + const aFooEdge = mi.find((e) => e.sourceId === aFooId); + expect(aFooEdge).toBeUndefined(); + }); + + it('C extends B extends A, only A has foo → returns A.foo (single match at depth 2)', () => { + // I { foo() }, A { foo() }, B extends A { no foo }, C extends B implements I { no foo } + const graph = createKnowledgeGraph(); + addClass(graph, 'I', 'java', 'Interface'); + addClass(graph, 'A', 'java'); + addClass(graph, 'B', 'java'); + addClass(graph, 'C', 'java'); + addImplements(graph, 'C', 'I'); + addExtends(graph, 'C', 'B'); + addExtends(graph, 'B', 'A'); + addMethod(graph, 'I', 'foo', 'Interface'); + const aFoo = addMethod(graph, 'A', 'foo'); + // B has NO foo, C has NO foo — only A.foo at depth 2 + + computeMRO(graph); + const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + const fooEdge = mi.find((e) => e.sourceId === aFoo); + expect(fooEdge).toBeDefined(); + }); + }); + + // ---- METHOD_IMPLEMENTS concrete-source guard ---------------------------- + describe('METHOD_IMPLEMENTS concrete-source guard', () => { + it('interface B extends interface A, B redeclares foo → 0 METHOD_IMPLEMENTS', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'A', 'java', 'Interface'); + addClass(graph, 'B', 'java', 'Interface'); + addInterfaceExtends(graph, 'B', 'A'); + addMethod(graph, 'A', 'foo', 'Interface'); + addMethod(graph, 'B', 'foo', 'Interface'); + + computeMRO(graph); + + const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + expect(mi).toHaveLength(0); + }); + + it('abstract class C implements I, C has abstract foo → 0 METHOD_IMPLEMENTS for foo', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'I', 'java', 'Interface'); + addClass(graph, 'C', 'java', 'Class'); + addImplements(graph, 'C', 'I'); + addMethod(graph, 'I', 'foo', 'Interface'); + + // Add abstract method manually with isAbstract flag + const classId = generateId('Class', 'C'); + const methodId = generateId('Method', 'C.foo#0'); + graph.addNode({ + id: methodId, + label: 'Method', + properties: { name: 'foo', filePath: 'src/C.ts', isAbstract: true, parameterCount: 0 }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${classId}->${methodId}`), + sourceId: classId, + targetId: methodId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + computeMRO(graph); + + const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + expect(mi).toHaveLength(0); + }); + + it('abstract class C implements I, C has concrete bar → 1 METHOD_IMPLEMENTS for bar', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'I', 'java', 'Interface'); + addClass(graph, 'C', 'java', 'Class'); + addImplements(graph, 'C', 'I'); + addMethod(graph, 'I', 'bar', 'Interface'); + const cBar = addMethod(graph, 'C', 'bar'); + + computeMRO(graph); + + const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + expect(mi).toHaveLength(1); + expect(mi[0].sourceId).toBe(cBar); + }); + + it('concrete class implements interface → 1 METHOD_IMPLEMENTS (regression)', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'I', 'java', 'Interface'); + addClass(graph, 'C', 'java', 'Class'); + addImplements(graph, 'C', 'I'); + addMethod(graph, 'I', 'foo', 'Interface'); + const cFoo = addMethod(graph, 'C', 'foo'); + + computeMRO(graph); + + const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + expect(mi).toHaveLength(1); + expect(mi[0].sourceId).toBe(cFoo); + }); + }); + + describe('default interface method resolution', () => { + it('interface default method satisfies grandparent interface contract', () => { + // I1 has abstract bar, I2 extends I1 and provides concrete bar, + // C implements I2 with no own bar → edge from I2.bar → I1.bar + const graph = createKnowledgeGraph(); + addClass(graph, 'I1Def', 'java', 'Interface'); + addClass(graph, 'I2Def', 'java', 'Interface'); + addClass(graph, 'CDef', 'java'); + + addInterfaceExtends(graph, 'I2Def', 'I1Def'); + addImplements(graph, 'CDef', 'I2Def'); + + const i1Bar = addMethod(graph, 'I1Def', 'bar', 'Interface', undefined, { isAbstract: true }); + const i2Bar = addMethod(graph, 'I2Def', 'bar', 'Interface', undefined, { + isAbstract: false, + }); + + const result = computeMRO(graph); + + const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + expect(edges).toHaveLength(1); + expect(edges[0].sourceId).toBe(i2Bar); + expect(edges[0].targetId).toBe(i1Bar); + }); + + it('own method takes priority over interface default', () => { + // I has concrete default bar, C implements I and has own bar + // → edge from C.bar → I.bar (own method wins, no IMPLEMENTS fallback needed) + const graph = createKnowledgeGraph(); + addClass(graph, 'IOwn', 'java', 'Interface'); + addClass(graph, 'COwn', 'java'); + + addImplements(graph, 'COwn', 'IOwn'); + + const iBar = addMethod(graph, 'IOwn', 'bar', 'Interface', undefined, { isAbstract: false }); + const cBar = addMethod(graph, 'COwn', 'bar'); + + computeMRO(graph); + + const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + expect(edges).toHaveLength(1); + expect(edges[0].sourceId).toBe(cBar); + expect(edges[0].targetId).toBe(iBar); + }); + + it('transitive interface default: C implements I2, I2 extends I1, I1 has abstract bar, I2 has default bar → I2.bar satisfies I1.bar', () => { + // I1 (Interface) has abstract bar + // I2 (Interface) extends I1, has concrete default bar + // C (Class) implements I2, has NO bar + // The main emitter processes CImpl's ancestor I1 (transitive via I2). + // I1.bar is abstract → CImpl has no own bar → findInheritedMethod runs. + // EXTENDS BFS: nothing. IMPLEMENTS BFS: walks I2 → finds concrete I2.bar. + // Edge: I2.bar → I1.bar + const graph = createKnowledgeGraph(); + addClass(graph, 'I1', 'java', 'Interface'); + addClass(graph, 'I2', 'java', 'Interface'); + addClass(graph, 'CImpl', 'java'); + + // I1 has abstract bar + const i1Bar = addMethod(graph, 'I1', 'bar', 'Interface', undefined, { isAbstract: true }); + // I2 has concrete default bar + const i2Bar = addMethod(graph, 'I2', 'bar', 'Interface'); + + // I2 extends I1 + const i2Id = generateId('Interface', 'I2'); + const i1Id = generateId('Interface', 'I1'); + graph.addRelationship({ + id: generateId('EXTENDS', `${i2Id}->${i1Id}`), + sourceId: i2Id, + targetId: i1Id, + type: 'EXTENDS', + confidence: 1.0, + reason: '', + }); + // CImpl implements I2 + addImplements(graph, 'CImpl', 'I2'); + + const result = computeMRO(graph); + const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + // I2.bar (concrete default) satisfies I1.bar (abstract contract) + const barEdge = mi.find((e) => e.targetId === i1Bar && e.sourceId === i2Bar); + expect(barEdge).toBeDefined(); + }); + + it('EXTENDS method takes priority over interface default', () => { + // I has concrete default foo, Base has concrete foo, + // C extends Base and implements I with no own foo + // → edge from Base.foo → I.foo (EXTENDS wins over IMPLEMENTS default) + const graph = createKnowledgeGraph(); + addClass(graph, 'IExtPri', 'java', 'Interface'); + addClass(graph, 'BaseExtPri', 'java'); + addClass(graph, 'CExtPri', 'java'); + + addExtends(graph, 'CExtPri', 'BaseExtPri'); + addImplements(graph, 'CExtPri', 'IExtPri'); + + const iFoo = addMethod(graph, 'IExtPri', 'foo', 'Interface', undefined, { + isAbstract: false, + }); + const baseFoo = addMethod(graph, 'BaseExtPri', 'foo'); + + computeMRO(graph); + + const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + expect(edges).toHaveLength(1); + expect(edges[0].sourceId).toBe(baseFoo); + expect(edges[0].targetId).toBe(iFoo); + }); + + it('Dart implements Class — does NOT inherit concrete method bodies', () => { + // Dart: class C implements AbstractBase (labeled Class, not Interface) + // AbstractBase has concrete method foo + // C has NO foo — but Dart implements does NOT inherit bodies + // → 0 METHOD_IMPLEMENTS edges from the IMPLEMENTS fallback + const graph = createKnowledgeGraph(); + addClass(graph, 'AbstractBase', 'dart'); // Class label, not Interface + addClass(graph, 'DartImpl', 'dart'); + + // AbstractBase has concrete foo + addMethod(graph, 'AbstractBase', 'foo'); + + // DartImpl implements AbstractBase (IMPLEMENTS edge to a Class) + addImplements(graph, 'DartImpl', 'AbstractBase', 'Class', 'Interface'); + // But we need AbstractBase to be a Class, not Interface — fix the label + // Actually addImplements creates the edge, but AbstractBase was added as Class. + // The IMPLEMENTS edge target needs to match the actual node ID. + // Let's do this manually: + const dartImplId = generateId('Class', 'DartImpl'); + const absBaseId = generateId('Class', 'AbstractBase'); + graph.addRelationship({ + id: generateId('IMPLEMENTS', `${dartImplId}->${absBaseId}`), + sourceId: dartImplId, + targetId: absBaseId, + type: 'IMPLEMENTS', + confidence: 1.0, + reason: '', + }); + + computeMRO(graph); + const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + // No edges — IMPLEMENTS fallback skips Class-labeled parents + expect(mi).toHaveLength(0); + }); + + it('Interface default still works after Dart label gate', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'IDefault', 'java', 'Interface'); + addClass(graph, 'Impl', 'java'); + addImplements(graph, 'Impl', 'IDefault'); + // IDefault has abstract contract method + const iFoo = addMethod(graph, 'IDefault', 'foo', 'Interface', undefined, { + isAbstract: true, + }); + // IDefault also has concrete default bar + const iBar = addMethod(graph, 'IDefault', 'bar', 'Interface'); + // Impl has foo but not bar + addMethod(graph, 'Impl', 'foo'); + + computeMRO(graph); + const mi = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + // foo: own method matches → edge from Impl.foo → IDefault.foo + const fooEdge = mi.find((e) => e.targetId === iFoo); + expect(fooEdge).toBeDefined(); + // bar: no own method, IMPLEMENTS fallback finds IDefault.bar (Interface label OK) + const barEdge = mi.find((e) => e.sourceId === iBar && e.targetId === iBar); + // Actually bar is the same method — it's the default implementation satisfying itself. + // The emitter processes IDefault.bar as an ancestor method, Impl has no bar, + // findInheritedMethod runs, walks IMPLEMENTS → finds IDefault.bar (non-abstract). + // But excludeMethodId = ancestorMethodId = iBar → skipped to prevent self-edge! + // So no bar edge. This is correct — the default satisfies the contract inherently. + }); + }); + + describe('METHOD_IMPLEMENTS confidence tiering', () => { + it('fully-typed match gets confidence 1.0', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'ITyped', 'java', 'Interface'); + addClass(graph, 'CTyped', 'java'); + addImplements(graph, 'CTyped', 'ITyped'); + + const iFoo = addMethod(graph, 'ITyped', 'foo', 'Interface', ['int', 'String']); + const cFoo = addMethod(graph, 'CTyped', 'foo', 'Class', ['int', 'String']); + + computeMRO(graph); + + const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + expect(edges).toHaveLength(1); + expect(edges[0].sourceId).toBe(cFoo); + expect(edges[0].targetId).toBe(iFoo); + expect(edges[0].confidence).toBe(1.0); + }); + + it('arity-only match (both have parameterCount, no types) gets confidence 1.0', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'IArity', 'java', 'Interface'); + addClass(graph, 'CArity', 'java'); + addImplements(graph, 'CArity', 'IArity'); + + // Manually add methods with parameterCount but no parameterTypes + const iBarId = generateId('Method', 'IArity.bar#2'); + graph.addNode({ + id: iBarId, + label: 'Method', + properties: { name: 'bar', filePath: 'src/IArity.ts', parameterCount: 2 }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Interface', 'IArity')}->${iBarId}`), + sourceId: generateId('Interface', 'IArity'), + targetId: iBarId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + const cBarId = generateId('Method', 'CArity.bar#2'); + graph.addNode({ + id: cBarId, + label: 'Method', + properties: { name: 'bar', filePath: 'src/CArity.ts', parameterCount: 2 }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Class', 'CArity')}->${cBarId}`), + sourceId: generateId('Class', 'CArity'), + targetId: cBarId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + computeMRO(graph); + + const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + expect(edges).toHaveLength(1); + expect(edges[0].sourceId).toBe(cBarId); + expect(edges[0].targetId).toBe(iBarId); + expect(edges[0].confidence).toBe(1.0); + }); + + it('lenient match (no types, no parameterCount on both sides) gets confidence 0.7', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'ILenient', 'java', 'Interface'); + addClass(graph, 'CLenient', 'java'); + addImplements(graph, 'CLenient', 'ILenient'); + + // Manually create methods WITHOUT parameterCount to simulate legacy/missing arity + const iBazId = generateId('Method', 'ILenient.baz'); + graph.addNode({ + id: iBazId, + label: 'Method', + properties: { name: 'baz', filePath: 'src/ILenient.ts' }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Interface', 'ILenient')}->${iBazId}`), + sourceId: generateId('Interface', 'ILenient'), + targetId: iBazId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + const cBazId = generateId('Method', 'CLenient.baz'); + graph.addNode({ + id: cBazId, + label: 'Method', + properties: { name: 'baz', filePath: 'src/CLenient.ts' }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Class', 'CLenient')}->${cBazId}`), + sourceId: generateId('Class', 'CLenient'), + targetId: cBazId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + computeMRO(graph); + + const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + expect(edges).toHaveLength(1); + expect(edges[0].sourceId).toBe(cBazId); + expect(edges[0].targetId).toBe(iBazId); + expect(edges[0].confidence).toBe(0.7); + }); + + it('one side has parameterCount, other does not → confidence 0.7', () => { + const graph = createKnowledgeGraph(); + addClass(graph, 'IHalf', 'java', 'Interface'); + addClass(graph, 'CHalf', 'java'); + addImplements(graph, 'CHalf', 'IHalf'); + + // Interface method has parameterCount but no parameterTypes + const iQuxId = generateId('Method', 'IHalf.qux#2'); + graph.addNode({ + id: iQuxId, + label: 'Method', + properties: { name: 'qux', filePath: 'src/IHalf.ts', parameterCount: 2 }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Interface', 'IHalf')}->${iQuxId}`), + sourceId: generateId('Interface', 'IHalf'), + targetId: iQuxId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + // Class method has neither parameterTypes nor parameterCount (manually constructed) + const cQuxId = generateId('Method', 'CHalf.qux'); + graph.addNode({ + id: cQuxId, + label: 'Method', + properties: { name: 'qux', filePath: 'src/CHalf.ts' }, + }); + graph.addRelationship({ + id: generateId('HAS_METHOD', `${generateId('Class', 'CHalf')}->${cQuxId}`), + sourceId: generateId('Class', 'CHalf'), + targetId: cQuxId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: '', + }); + + computeMRO(graph); + + const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + expect(edges).toHaveLength(1); + expect(edges[0].sourceId).toBe(cQuxId); + expect(edges[0].targetId).toBe(iQuxId); + expect(edges[0].confidence).toBe(0.7); + }); + }); + + // ---- IMPLEMENTS BFS ambiguity for default methods ------------------------- + describe('IMPLEMENTS BFS ambiguity for default methods', () => { + it('does not emit METHOD_IMPLEMENTS when two interfaces provide same default method (ambiguous)', () => { + // IAncestor (Interface) has abstract process() + // IAlpha (Interface) extends IAncestor, has concrete process() + // IBeta (Interface) extends IAncestor, has concrete process() + // CImpl (Class) implements IAlpha, implements IBeta + // CImpl has NO process() method + // + // findInheritedMethod walks IMPLEMENTS BFS and finds process() in BOTH + // IAlpha and IBeta => ambiguous => null => no METHOD_IMPLEMENTS edge. + const graph = createKnowledgeGraph(); + addClass(graph, 'IAncestor', 'java', 'Interface'); + addClass(graph, 'IAlpha', 'java', 'Interface'); + addClass(graph, 'IBeta', 'java', 'Interface'); + addClass(graph, 'CImpl', 'java'); + + addInterfaceExtends(graph, 'IAlpha', 'IAncestor'); + addInterfaceExtends(graph, 'IBeta', 'IAncestor'); + addImplements(graph, 'CImpl', 'IAlpha'); + addImplements(graph, 'CImpl', 'IBeta'); + + addMethod(graph, 'IAncestor', 'process', 'Interface', undefined, { isAbstract: true }); + addMethod(graph, 'IAlpha', 'process', 'Interface'); + addMethod(graph, 'IBeta', 'process', 'Interface'); + + const result = computeMRO(graph); + + // IAlpha.process -> IAncestor.process and IBeta.process -> IAncestor.process + // are legitimate edges from sub-interface processing. The ambiguity check + // ensures that NO additional edge is emitted on behalf of CImpl (which has + // no own process()). Since CImpl has no methods, no edge should be sourced + // from a CImpl method. Verify by checking that the only METHOD_IMPLEMENTS + // edges for process() are the two interface-to-interface ones. + const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + const processEdges = edges.filter((e) => { + const target = graph.getNode(e.targetId); + return target?.properties.name === 'process'; + }); + // IAlpha.process->IAncestor.process and IBeta.process->IAncestor.process + // are emitted from the sub-interface processing (each concrete method + // implements the ancestor's abstract method). No additional edge should be + // emitted on behalf of CImpl because findInheritedMethod returns null + // (ambiguous: two candidates at the same BFS depth). + expect(processEdges).toHaveLength(2); + const alphaProcess = generateId('Method', 'IAlpha.process#0'); + const betaProcess = generateId('Method', 'IBeta.process#0'); + const sourceIds = processEdges.map((e) => e.sourceId).sort(); + expect(sourceIds).toEqual([alphaProcess, betaProcess].sort()); + }); + + it('emits METHOD_IMPLEMENTS when only one interface provides the default method (unambiguous)', () => { + // IAncestor (Interface) has abstract process() + // IAlpha (Interface) extends IAncestor, has concrete process() + // IBeta (Interface) extends IAncestor, does NOT have process() + // CImpl (Class) implements IAlpha, implements IBeta + // CImpl has NO process() method + // + // findInheritedMethod walks IMPLEMENTS BFS and finds process() only in + // IAlpha => unambiguous => emits 1 METHOD_IMPLEMENTS edge. + const graph = createKnowledgeGraph(); + addClass(graph, 'IAncestor', 'java', 'Interface'); + addClass(graph, 'IAlpha', 'java', 'Interface'); + addClass(graph, 'IBeta', 'java', 'Interface'); + addClass(graph, 'CImpl', 'java'); + + addInterfaceExtends(graph, 'IAlpha', 'IAncestor'); + addInterfaceExtends(graph, 'IBeta', 'IAncestor'); + addImplements(graph, 'CImpl', 'IAlpha'); + addImplements(graph, 'CImpl', 'IBeta'); + + const ancestorProcess = addMethod(graph, 'IAncestor', 'process', 'Interface', undefined, { + isAbstract: true, + }); + const alphaProcess = addMethod(graph, 'IAlpha', 'process', 'Interface'); + // IBeta has no process() method + + const result = computeMRO(graph); + + const edges = graph.relationships.filter((r) => r.type === 'METHOD_IMPLEMENTS'); + const processEdges = edges.filter((e) => { + const target = graph.getNode(e.targetId); + return target?.properties.name === 'process'; + }); + // At minimum, IAlpha.process -> IAncestor.process is emitted (from IAlpha's + // own processing). CImpl's findInheritedMethod also finds IAlpha.process as + // the sole unambiguous match, potentially emitting the same edge again. + expect(processEdges.length).toBeGreaterThanOrEqual(1); + // Every process edge should point from IAlpha.process to IAncestor.process + for (const edge of processEdges) { + expect(edge.sourceId).toBe(alphaProcess); + expect(edge.targetId).toBe(ancestorProcess); + } + }); + }); }); diff --git a/gitnexus/test/unit/security.test.ts b/gitnexus/test/unit/security.test.ts index 7d16880ae..0adee1915 100644 --- a/gitnexus/test/unit/security.test.ts +++ b/gitnexus/test/unit/security.test.ts @@ -106,7 +106,7 @@ describe('isWriteQuery', () => { describe('VALID_RELATION_TYPES', () => { it('contains all expected relation types', () => { - expect(VALID_RELATION_TYPES.size).toBe(13); + expect(VALID_RELATION_TYPES.size).toBe(15); for (const t of [ 'CALLS', 'IMPORTS', @@ -114,7 +114,9 @@ describe('VALID_RELATION_TYPES', () => { 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', + 'METHOD_OVERRIDES', 'OVERRIDES', + 'METHOD_IMPLEMENTS', 'ACCESSES', 'HANDLES_ROUTE', 'FETCHES',