feat: METHOD_IMPLEMENTS edges, overload disambiguation, MethodExtractor unification (#574) (#642)

This commit is contained in:
Gergő Magyar 2026-04-04 18:41:47 +01:00 committed by GitHub
parent 153262304c
commit 0561d24efd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 3482 additions and 1583 deletions

View file

@ -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 (`#<paramCount>`) 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.

27
MIGRATION.md Normal file
View file

@ -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.

View file

@ -97,7 +97,8 @@ export type RelationshipType =
| 'CONTAINS'
| 'CALLS'
| 'INHERITS'
| 'OVERRIDES'
| 'METHOD_OVERRIDES'
| 'METHOD_IMPLEMENTS'
| 'IMPORTS'
| 'USES'
| 'DEFINES'

View file

@ -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',

View file

@ -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 #<arity> 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 #<arity> 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 #<arity> 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 #<arity> suffix (e.g. "save#2" → "save")
const hashIdx = raw.indexOf('#');
return hashIdx >= 0 ? raw.slice(0, hashIdx) : raw;
};
/**

View file

@ -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<string> = 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<LanguageProvider['labelOverride']> = (
@ -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,
});

View file

@ -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;
};

View file

@ -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<string> = new Set([
'println',
'print',

View file

@ -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<string> = 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,
});

View file

@ -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<string> = 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,
});

View file

@ -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<string> = 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,
});

View file

@ -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<string> = 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,
});

View file

@ -187,6 +187,7 @@ export const csharpMethodConfig: MethodExtractionConfig = {
'destructor_declaration',
'operator_declaration',
'conversion_operator_declaration',
'local_function_statement',
],
bodyNodeTypes: ['declaration_list'],

View file

@ -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<string> = 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*</.test(type)) return type;
return undefined;
}
/**
* Walk backwards through preceding siblings of `node` to find a PHPDoc
* `@return Type` annotation. Skips `attribute_list` nodes (PHP 8 attributes).
*/
function extractPhpDocReturnType(node: SyntaxNode): string | undefined {
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 && !PHPDOC_SKIP_NODE_TYPES.has(sibling.type)) {
break;
}
sibling = sibling.previousSibling;
}
return undefined;
}
const PHP_VIS = new Set<MethodVisibility>(['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) {

View file

@ -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<User>"
* -> "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<Symbol, User>
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<User>" -> "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;
},

View file

@ -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<User>`) 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<MethodExtractionConfig, 'language'> = {
// 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) {

View file

@ -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 } : {}),
};
}

View file

@ -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;
}

View file

@ -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<string, string[]>();
const edgeTypesMap = new Map<string, Map<string, 'EXTENDS' | 'IMPLEMENTS'>>();
// 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 `#<paramCount>` 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<string, string[]>,
methodMap: Map<string, string[]>,
parentEdgeType: Map<string, Map<string, 'EXTENDS' | 'IMPLEMENTS'>>,
ancestorsMap: Map<string, string[]>,
edgeTypesMap: Map<string, Map<string, 'EXTENDS' | 'IMPLEMENTS'>>,
): 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<string>();
// 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<string, string[]>,
methodMap: Map<string, string[]>,
parentEdgeType: Map<string, Map<string, 'EXTENDS' | 'IMPLEMENTS'>>,
/** 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<string>();
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<string>();
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)
}
/**

View file

@ -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<string, unknown> {
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<string, unknown> = {};
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 #<paramCount> 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,

View file

@ -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<typeof createKnowledgeGraph>,
@ -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`,
);
}

View file

@ -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, readonly string[]>,
): 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<string, ReadonlyMap<string, string>>,

View file

@ -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<User> Collection<User> (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*</.test(type)) return type;
return undefined;
};
/**
* Extract return type from PHPDoc `@return Type` annotation preceding a method.
* Walks backwards through preceding siblings looking for comment nodes.
* Preserves array notation (e.g., User[]) for for-loop element type extraction.
*/
const extractReturnType: ReturnTypeExtractor = (node) => {
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,
};

View file

@ -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,
};

View file

@ -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.

View file

@ -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<User>`) 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<string> = 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,

View file

@ -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<string, string> = {
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, =, <literal> — 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++) {

View file

@ -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<string, unknown> {
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 } : {}),
};
}

View file

@ -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 #<arity> 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 #<arity> 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 #<arity> suffix in the ID.
let declaredType: string | undefined;
let methodProps: Record<string, unknown> = {};
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 #<paramCount> 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);

View file

@ -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<Record<string, number>> = {
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, {

View file

@ -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

View file

@ -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)' },

View file

@ -0,0 +1,7 @@
public class App {
public static void Main() {
IRepository repo = new SqlRepository();
repo.Find(1);
repo.Save("test");
}
}

View file

@ -0,0 +1,4 @@
public interface IRepository {
string Find(int id);
bool Save(string entity);
}

View file

@ -0,0 +1,9 @@
public class SqlRepository : IRepository {
public string Find(int id) {
return "found";
}
public bool Save(string entity) {
return true;
}
}

View file

@ -0,0 +1,8 @@
public class App {
public void Run() {
var repo = new SqlRepository();
repo.Find(42);
repo.Find("alice", true);
repo.Save("test");
}
}

View file

@ -0,0 +1,5 @@
public interface IRepository {
string Find(int id);
string Find(string name, bool exact);
void Save(string data);
}

View file

@ -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);
}
}

View file

@ -0,0 +1,7 @@
import 'sql_repository.dart';
void main() {
final repo = SqlRepository();
repo.find(1);
repo.save("test");
}

View file

@ -0,0 +1,4 @@
abstract class Repository {
String find(int id);
bool save(String entity);
}

View file

@ -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;
}
}

View file

@ -0,0 +1,8 @@
public class App {
public void run() {
SqlRepository repo = new SqlRepository();
repo.find(42);
repo.find("alice", true);
repo.save("test");
}
}

View file

@ -0,0 +1,5 @@
public interface Repository {
String find(int id);
String find(String name, boolean exact);
void save(String data);
}

View file

@ -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);
}
}

View file

@ -0,0 +1,5 @@
fun main() {
val repo: Repository = SqlRepository()
repo.find(1)
repo.save("test")
}

View file

@ -0,0 +1,4 @@
interface Repository {
fun find(id: Int): String
fun save(entity: String): Boolean
}

View file

@ -0,0 +1,9 @@
class SqlRepository : Repository {
override fun find(id: Int): String {
return "found"
}
override fun save(entity: String): Boolean {
return true
}
}

View file

@ -0,0 +1,6 @@
fun main() {
val repo = SqlRepository()
repo.find(42)
repo.find("alice", true)
repo.save("test")
}

View file

@ -0,0 +1,5 @@
interface Repository {
fun find(id: Int): String
fun find(name: String, exact: Boolean): String
fun save(data: String)
}

View file

@ -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) }
}

View file

@ -0,0 +1,4 @@
let repo = SqlRepository()
repo.find(id: 42)
repo.find(name: "alice", exact: true)
repo.save(data: "test")

View file

@ -0,0 +1,5 @@
protocol Repository {
func find(id: Int) -> String
func find(name: String, exact: Bool) -> String
func save(data: String)
}

View file

@ -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) }
}

View file

@ -0,0 +1,6 @@
import { SqlRepository } from './sql-repository';
const repo = new SqlRepository();
repo.find(42);
repo.find('alice');
repo.save('test');

View file

@ -0,0 +1,5 @@
export interface IRepository {
find(id: number): string;
find(name: string): string;
save(data: string): void;
}

View file

@ -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);
}
}

View file

@ -0,0 +1,5 @@
import { SqlRepository } from './sql-repository';
const repo = new SqlRepository();
repo.find(1);
repo.save("test");

View file

@ -0,0 +1,4 @@
export interface IRepository {
find(id: number): string;
save(entity: string): boolean;
}

View file

@ -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;
}
}

View file

@ -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)`,

View file

@ -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',

View file

@ -94,7 +94,7 @@ withTestLbugDB(
'EXTENDS',
'IMPLEMENTS',
'HAS_METHOD',
'OVERRIDES',
'METHOD_OVERRIDES',
'ACCESSES',
];
const invalidTypes = ['CONTAINS', 'STEP_IN_PROCESS', 'MEMBER_OF', 'DROP_TABLE'];

View file

@ -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();

View file

@ -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');
});
});

View file

@ -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();
});
});

View file

@ -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();

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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']);
});
});

View file

@ -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);
});
});

View file

@ -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();

View file

@ -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']);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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);

View file

@ -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');

View file

@ -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();
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -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',