Compare commits

...
Author SHA1 Message Date
Gergo Magyar 19c7c65552 fix: MRO performance + variadic METHOD_IMPLEMENTS confidence
Performance: pre-compute gatherAncestors + buildTransitiveEdgeTypes
during main MRO loop and pass to emitMethodImplementsEdges, eliminating
redundant O(N*D) BFS for every class.

Variadic: when one side is variadic (parameterCount undefined) and the
other has a fixed count, downgrade METHOD_IMPLEMENTS confidence to 0.7
instead of falsely confident 1.0. Variadic foo(String...) does not
truly implement foo(String) in TS/C#/Rust.

Update ARCHITECTURE.md confidence tiering table with variadic row.
2026-04-04 18:05:53 +01:00
Gergo Magyar c009678acd refactor: post-extraction cleanup — deduplicate helpers, remove dead code, complete provider migration
Phase 1 — Deduplicate shared helpers:
- Move genericFuncName (was triplicated in 3 files) to ast-helpers.ts
- Move inferFunctionLabel + METHOD_LABEL/CONSTRUCTOR_LABEL node type
  Sets (was duplicated in 2 files) to ast-helpers.ts
- Extract buildMethodProps + arityForIdFromInfo to shared method-props.ts
- Replace 50-line inline MethodInfo conversion in parse-worker.ts with
  shared buildMethodProps call

Phase 2 — Remove dead code:
- Delete TypeConfig.extractReturnType from LanguageTypeConfig interface
  (zero callers since post-hoc fallback was removed)
- Delete 3 dead extractReturnType implementations in type-extractors
  (php.ts, ruby.ts, typescript.ts) — ~130 lines of unreachable code
- Delete ReturnTypeExtractor type alias

Phase 3 — Complete provider migration:
- Move isCppInsideClassOrStruct from ast-helpers.ts to c-cpp.ts
- Move isKotlinClassMethod from ast-helpers.ts to kotlin.ts

Phase 4 — Hygiene:
- Merge duplicate CLASS_CONTAINER_TYPES import in parse-worker.ts
- Move mid-file LanguageProvider import to top of parse-worker.ts
- Add FUNCTION_NODE_TYPES drift-prevention comment documenting the
  invariant with MethodExtractionConfig.methodNodeTypes

Net: -281 lines (391 removed, 110 added)
2026-04-04 17:45:50 +01:00
Gergo Magyar 739de2f645 refactor: eliminate extractFunctionName from ast-helpers — MethodExtractor owns all extraction
Complete removal of extractFunctionName from ast-helpers.ts. Function
name extraction is now exclusively owned by provider.methodExtractor:

- Add extractFunctionName to MethodExtractor interface and config
- Move from LanguageProvider to MethodExtractor for all 6 providers
  (C++, Swift, Rust, TS/JS, Ruby; generic fallback for others)
- Thread extractFunctionName through buildTypeEnv options so type-env.ts
  can access provider-specific extraction without importing from
  ast-helpers
- Inline FUNCTION_DECLARATION_TYPES into C++ provider (sole consumer)
- Delete extractFunctionName function and FUNCTION_DECLARATION_TYPES
  from ast-helpers.ts

ast-helpers.ts: 518 → 420 lines. Now contains only shared constants
(FUNCTION_NODE_TYPES, CALL_ARGUMENT_LIST_TYPES, CLASS_CONTAINER_TYPES)
and generic traversal helpers — zero language-specific extraction logic.
2026-04-04 17:06:04 +01:00
Gergo Magyar 16f752413a refactor: provider-routed function name extraction + remove countMethodParameters
Make ast-helpers.ts language-agnostic by moving all language-specific
logic into per-language providers:

extractFunctionName:
- Add optional extractFunctionName hook to LanguageProvider interface
- Move C/C++ declarator unwrapping to c-cpp.ts provider
- Move Swift init/deinit to swift.ts provider
- Move Rust impl_item to rust.ts provider
- Move TS arrow_function/function_expression to typescript.ts provider
- Move Ruby method/singleton_method to ruby.ts provider
- Replace 250-line monolithic switch with thin dispatcher + generic
  fallback (name field lookup + label inference from node type)
- Callers with provider available (parse-worker, call-processor) pass
  it for language-specific resolution; callers without (type-env) use
  generic fallback

countMethodParameters:
- Remove entirely from ast-helpers.ts (90 lines + PARAM_LIST_TYPES,
  VARIADIC_PARAM_TYPES constants)
- Replace 4 call sites with MethodExtractor-based arity via
  getMethodInfo (parse-worker) / extractFromNode (call-processor)
- When MethodExtractor doesn't produce arity, no #<N> suffix is
  appended (matches definition phase behavior)

ast-helpers.ts: 897 → 518 lines (-42%)
2026-04-04 16:45:47 +01:00
Gergo Magyar f309328372 refactor: remove extractMethodSignature — MethodExtractor is sole source of method metadata
Remove extractMethodSignature (270-line generic AST heuristic) and its
MethodSignature type. All 12 languages now use provider.methodExtractor
exclusively for parameterCount, parameterTypes, and returnType.

Key changes:
- Enhance PHP/Ruby/TS MethodExtractor configs with doc-comment return
  type parsing (PHPDoc @return, YARD @return, JSDoc @returns), moved
  from TypeConfig.extractReturnType post-hoc fallback
- Add function_definition/function_declaration/function_signature to
  PHP/TS/C# methodNodeTypes so extractFromNode covers top-level functions
- Remove extractMethodSignature fallback from definition phase in both
  parse-worker.ts and parsing-processor.ts
- Remove TypeConfig.extractReturnType post-hoc upgrade block
- Replace extractMethodSignature with lightweight countMethodParameters
  for findEnclosingFunctionId arity fallback
- Delete method-signature.test.ts (tested the removed function)
2026-04-04 16:21:25 +01:00
Gergo Magyar 7751af16bd fix: Codex Round 6 — impactByUid flatMap, variadic ID align, IMPLEMENTS BFS ambiguity, docs
1. impactByUid: .map() → .flatMap() for OVERRIDES alias expansion,
   matching the pattern in impact() (local-backend.ts)
2. Variadic methods: arityForId now undefined for variadic, consistent
   with extractMethodSignature — prevents sourceId/nodeId mismatch for
   CALLS edges originating inside variadic methods
3. IMPLEMENTS fallback BFS: collect all matches, return null if >1
   (ambiguous) instead of returning first match — matches EXTENDS BFS
   ambiguity detection pattern
4. tools.ts: add METHOD_IMPLEMENTS to edge type lists in cypher and
   impact tool descriptions

Integration tests added for each finding:
- impactByUid legacy OVERRIDES dual-read (local-backend-calltool.test.ts)
- Variadic method sourceId validity invariant (java.test.ts)
- IMPLEMENTS BFS ambiguity detection: ambiguous + unambiguous cases
  (mro-processor.test.ts)
2026-04-04 10:24:35 +01:00
Gergo Magyar afa4e81a5f test: overloaded method disambiguation integration tests for 5 languages
Add comprehensive integration tests verifying that overloaded methods with
different arities produce distinct graph nodes and correct METHOD_IMPLEMENTS
edges. Each fixture uses different parameter counts (1 vs 2) to exercise
the #<paramCount> arity suffix.

Languages covered:
- Java: 7 tests (distinct nodes, 3 METHOD_IMPLEMENTS edges, 2 CALLS edges)
- C#: 5 tests (distinct Find nodes, 3 METHOD_IMPLEMENTS edges)
- Kotlin: 4 tests (distinct find nodes, 3 METHOD_IMPLEMENTS edges)
- TypeScript: 3 tests (TS overloads collapse to 1 impl — expects 2 edges)
- Swift: 4 tests (skipped when parser unavailable)

All assertions use strict toBe() — no toBeGreaterThanOrEqual.
2026-04-04 10:04:22 +01:00
Gergo Magyar 3c5079f044 feat: disambiguate overloaded method node IDs with #<paramCount> arity suffix
Method and Constructor node IDs now include a #<paramCount> suffix to
distinguish overloaded methods: Method:file:Class.method#1 vs #2.

Core changes:
- Reorder parameter extraction before generateId in both parsing-processor.ts
  (sequential) and parse-worker.ts (worker) so arity is available at ID time
- Add arity suffix to findEnclosingFunctionId in both call-processor.ts and
  parse-worker.ts so CALLS/ACCESSES edge sourceIds match definition-phase IDs
- Strip #<arity> in extractFuncNameFromSourceId for receiver-type index lookups
- Fix resolveMethodReturnType to handle class names directly as receiver types
  (pre-existing latent bug surfaced by substituteThisReceiver + fixpoint interaction)
- Add MIGRATION.md documenting OVERRIDES → METHOD_OVERRIDES rename
- Update ARCHITECTURE.md Known Limitations with overload resolution status

Same-arity overloads (same param count, different types) remain collapsed —
deferred to future type-hash enhancement (issue #574).

All 2896 unit tests + 2213 integration tests pass.
2026-04-04 09:22:11 +01:00
Gergo Magyar a91bf54ac0 fix: Codex Round 5 — OVERRIDES alias expansion, overload docs, Dart label gate
1. OVERRIDES alias expansion: _impactImpl now uses flatMap to include
   BOTH OVERRIDES and METHOD_OVERRIDES in the BFS filter when the caller
   requests OVERRIDES. Ensures legacy indexes are traversed correctly.

2. Overload limitation documented: added JSDoc above emitMethodImplementsEdges
   explaining that parameterTypes matching assumes distinct overload nodes,
   which the graph model doesn't currently guarantee (separate RFC).

3. Dart label gate: findInheritedMethod IMPLEMENTS BFS now checks node
   label — only Interface/Trait parents are processed. Dart-style
   implements-Class patterns don't falsely inherit concrete method bodies.

2 new tests: Dart implements-Class produces 0 edges, Interface default
still works after label gate.
2026-04-03 21:41:54 +01:00
Gergo Magyar e54b0d02a0 fix: Codex Round 4 — default interface methods, REL_TYPES compat, confidence
1. Default interface method resolution: findInheritedMethod IMPLEMENTS
   fallback now uses BFS to walk transitive interface ancestry (I2
   extends I1). Non-abstract default methods on ancestor interfaces are
   found even when the direct IMPLEMENTS parent doesn't redeclare them.

2. REL_TYPES compat: add OVERRIDES back to shared schema-constants as
   legacy alias for rolling-upgrade compatibility.

3. Confidence tiering: parameterTypesMatch returns {match, confident}.
   Exact type + arity matches get 1.0, lenient (missing metadata) gets
   0.7. Applied at both emission sites.

8 new tests: transitive interface default, own/EXTENDS priority,
confidence tiering (4 scenarios), abstract method skip.
2026-04-03 21:14:04 +01:00
Gergo Magyar d11130378b fix: Codex Round 3 — METHOD_IMPLEMENTS soundness
1. Depth-based nearest-match in findInheritedMethod: level-order BFS
   stops at the first depth with matches. C extends B extends A where
   both B.foo and A.foo exist now correctly returns B.foo (nearest)
   instead of treating the chain as ambiguous.

2. Concrete-source guard: skip Interface/Trait nodes entirely in
   emitMethodImplementsEdges, and skip abstract methods when building
   candidate lists. Prevents false edges like interface B.foo → A.foo.

3. Add OVERRIDES to default impact traversal fallback arrays so
   pre-rename indexes are traversed without explicit relationTypes.

6 new tests: nearest-match chain, deep single-match, interface-extends
guard, abstract method skip, abstract+concrete mix, concrete regression.
2026-04-03 20:44:27 +01:00
Gergo Magyar 7149001877 fix: detect ambiguity in findInheritedMethod BFS
When multiple EXTENDS ancestors provide the same method matching an
interface contract, findInheritedMethod now collects all matches across
the full BFS and returns null (ambiguous) when 2+ distinct methodIds
are found. Diamond paths are deduplicated by methodId so the same
method reachable via two paths is not treated as ambiguous.

2 new tests: multi-parent ambiguity (returns null), diamond dedup
(returns the single shared method).
2026-04-03 19:58:34 +01:00
Gergo Magyar 4205261c92 fix: Codex Round 2 — METHOD_IMPLEMENTS hardening
1. Replace init-time OVERRIDES migration with dual-read: remove the
   unversioned database mutation from ensureInitialized, add OVERRIDES
   to Cypher IN clauses and VALID_RELATION_TYPES so both old and new
   edge type names are recognized without mutating the index.

2. Expand default impact traversal: METHOD_OVERRIDES and
   METHOD_IMPLEMENTS now included in the default fallback so interface
   contract changes report blast radius without explicit relationTypes.

3. Inherited implementation resolution: when class C extends Base and
   implements I, but only Base has foo(), emit METHOD_IMPLEMENTS from
   Base.foo → I.foo by walking the EXTENDS chain for concrete methods.

4. Arity-compatible matching: when parameterTypes are missing, fall back
   to parameterCount comparison. Multiple same-arity candidates produce
   no edge (ambiguous) instead of picking the first match.

6 new tests: inherited implementation (3 depths), arity mismatch,
arity match without types, ambiguous same-arity candidates.
2026-04-03 19:44:18 +01:00
Gergo Magyar 8521189044 fix: adversarial review findings for METHOD_IMPLEMENTS
Address 3 findings from Codex adversarial review:

1. Legacy OVERRIDES backward compat: map OVERRIDES → METHOD_OVERRIDES
   at the _impactImpl API boundary before VALID_RELATION_TYPES filtering.
   Prevents silent traversal widening when clients send legacy type name.

2. Thread METHOD_IMPLEMENTS through MCP tools:
   - Added to VALID_RELATION_TYPES (impact traversal)
   - Added to IMPACT_RELATION_CONFIDENCE (0.85 floor)
   - Added to context() incoming/outgoing Cypher queries
   - Updated security + confidence tests

3. Transitive interface ancestor support: emitMethodImplementsEdges()
   now uses gatherAncestors() + buildTransitiveEdgeTypes() to walk the
   full inheritance chain. C implements B extends A (interface) now
   links C.foo to both B.foo and A.foo. Diamond paths deduplicated.
   4 new unit tests covering transitive chains, inherited contracts,
   diamond dedup, and class-only exclusion.
2026-04-03 17:57:27 +01:00
Gergo Magyar deabfcb046 test: METHOD_IMPLEMENTS integration tests for C#, TypeScript, Kotlin, Dart
Add interface dispatch fixtures and METHOD_IMPLEMENTS edge assertions for
the 4 remaining languages that support interfaces:

- C#: IRepository interface with Find/Save, SqlRepository implements
- TypeScript: IRepository interface with find/save, SqlRepository implements
- Kotlin: Repository interface with find/save, SqlRepository implements
- Dart: abstract Repository with find/save, SqlRepository implements

Fix: Kotlin interfaces use class_declaration AST node type — added
keyword-based detection in findEnclosingClassInfo to correctly label
them as Interface (not Class), enabling METHOD_IMPLEMENTS edge emission.

Total METHOD_IMPLEMENTS integration coverage: 9 languages
(Java, PHP, Rust, Swift, Python, C#, TypeScript, Kotlin, Dart)
2026-04-03 17:09:20 +01:00
Gergo Magyar 1966242675 feat: METHOD_IMPLEMENTS edges + rename OVERRIDES to METHOD_OVERRIDES (#574)
Add METHOD_IMPLEMENTS relationship type to connect concrete methods to
the interface/abstract methods they implement, and rename OVERRIDES to
METHOD_OVERRIDES for naming consistency.

METHOD_IMPLEMENTS:
- New emitMethodImplementsEdges() pass in mro-processor after MRO loop
- Match by name + parameterTypes for overloaded interface methods
- Lenient matching when either side lacks type info
- Include ALL interface/trait methods (not just abstract)
- Edge direction: ConcreteMethod → InterfaceMethod
- 8 unit tests: interface dispatch, Rust trait, overloads, default
  methods, class-extends exclusion, Property skip, queryability
- 5 per-language integration tests: Java (interface-dispatch fixture),
  PHP (abstract-dispatch), Rust (trait impl), Swift (protocol), Python
  (documents ABC-extends behavior)

OVERRIDES → METHOD_OVERRIDES:
- Renamed across 24 files (shared types, schema, MRO processor, MCP
  tools, impact confidence, all resolver integration tests)
- Added migration in ensureInitialized: existing graphs with old
  OVERRIDES edges are auto-updated to METHOD_OVERRIDES on first connect
- Migration runs once per session per repo, silently skips if no edges

Closes #574
2026-04-03 16:47:17 +01:00
79 changed files with 3482 additions and 1583 deletions
+32
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
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.
+2 -1
View File
@@ -97,7 +97,8 @@ export type RelationshipType =
| 'CONTAINS'
| 'CALLS'
| 'INHERITS'
| 'OVERRIDES'
| 'METHOD_OVERRIDES'
| 'METHOD_IMPLEMENTS'
| 'IMPORTS'
| 'USES'
| 'DEFINES'
+3 -1
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',
+42 -7
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;
};
/**
+169 -3
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,
});
+18 -4
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;
};
@@ -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',
+25 -1
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,
});
+35 -1
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,
});
+15 -1
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,
});
@@ -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,
});
@@ -187,6 +187,7 @@ export const csharpMethodConfig: MethodExtractionConfig = {
'destructor_declaration',
'operator_declaration',
'conversion_operator_declaration',
'local_function_statement',
],
bodyNodeTypes: ['declaration_list'],
@@ -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) {
@@ -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;
},
@@ -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) {
@@ -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 } : {}),
};
}
@@ -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;
}
+402 -13
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)
}
/**
@@ -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,
+2 -2
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`,
);
}
+33 -6
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>>,
@@ -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,
};
@@ -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,
};
@@ -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.
@@ -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,
+55 -582
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++) {
@@ -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 } : {}),
};
}
@@ -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);
+61 -13
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, {
+2 -1
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
+4 -4
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)' },
@@ -0,0 +1,7 @@
public class App {
public static void Main() {
IRepository repo = new SqlRepository();
repo.Find(1);
repo.Save("test");
}
}
@@ -0,0 +1,4 @@
public interface IRepository {
string Find(int id);
bool Save(string entity);
}
@@ -0,0 +1,9 @@
public class SqlRepository : IRepository {
public string Find(int id) {
return "found";
}
public bool Save(string entity) {
return true;
}
}
@@ -0,0 +1,8 @@
public class App {
public void Run() {
var repo = new SqlRepository();
repo.Find(42);
repo.Find("alice", true);
repo.Save("test");
}
}
@@ -0,0 +1,5 @@
public interface IRepository {
string Find(int id);
string Find(string name, bool exact);
void Save(string data);
}
@@ -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);
}
}
@@ -0,0 +1,7 @@
import 'sql_repository.dart';
void main() {
final repo = SqlRepository();
repo.find(1);
repo.save("test");
}
@@ -0,0 +1,4 @@
abstract class Repository {
String find(int id);
bool save(String entity);
}
@@ -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;
}
}
@@ -0,0 +1,8 @@
public class App {
public void run() {
SqlRepository repo = new SqlRepository();
repo.find(42);
repo.find("alice", true);
repo.save("test");
}
}
@@ -0,0 +1,5 @@
public interface Repository {
String find(int id);
String find(String name, boolean exact);
void save(String data);
}
@@ -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);
}
}
@@ -0,0 +1,5 @@
fun main() {
val repo: Repository = SqlRepository()
repo.find(1)
repo.save("test")
}
@@ -0,0 +1,4 @@
interface Repository {
fun find(id: Int): String
fun save(entity: String): Boolean
}
@@ -0,0 +1,9 @@
class SqlRepository : Repository {
override fun find(id: Int): String {
return "found"
}
override fun save(entity: String): Boolean {
return true
}
}
@@ -0,0 +1,6 @@
fun main() {
val repo = SqlRepository()
repo.find(42)
repo.find("alice", true)
repo.save("test")
}
@@ -0,0 +1,5 @@
interface Repository {
fun find(id: Int): String
fun find(name: String, exact: Boolean): String
fun save(data: String)
}
@@ -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) }
}
@@ -0,0 +1,4 @@
let repo = SqlRepository()
repo.find(id: 42)
repo.find(name: "alice", exact: true)
repo.save(data: "test")
@@ -0,0 +1,5 @@
protocol Repository {
func find(id: Int) -> String
func find(name: String, exact: Bool) -> String
func save(data: String)
}
@@ -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) }
}
@@ -0,0 +1,6 @@
import { SqlRepository } from './sql-repository';
const repo = new SqlRepository();
repo.find(42);
repo.find('alice');
repo.save('test');
@@ -0,0 +1,5 @@
export interface IRepository {
find(id: number): string;
find(name: string): string;
save(data: string): void;
}
@@ -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);
}
}
@@ -0,0 +1,5 @@
import { SqlRepository } from './sql-repository';
const repo = new SqlRepository();
repo.find(1);
repo.save("test");
@@ -0,0 +1,4 @@
export interface IRepository {
find(id: number): string;
save(entity: string): boolean;
}
@@ -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;
}
}
+1 -1
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)`,
@@ -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',
@@ -94,7 +94,7 @@ withTestLbugDB(
'EXTENDS',
'IMPLEMENTS',
'HAS_METHOD',
'OVERRIDES',
'METHOD_OVERRIDES',
'ACCESSES',
];
const invalidTypes = ['CONTAINS', 'STEP_IN_PROCESS', 'MEMBER_OF', 'DROP_TABLE'];
@@ -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();
@@ -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');
});
});
@@ -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();
});
});
@@ -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();
@@ -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);
});
});
@@ -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);
});
});
@@ -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']);
});
});
@@ -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);
});
});
@@ -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();
@@ -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']);
});
});
@@ -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);
});
});
@@ -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);
});
});
+8 -3
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);
+35 -18
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');
-523
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
+3 -1
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',