Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixes

- Class inheritance edges are no longer silently dropped when the superclass or implemented interface carries generic type arguments — for example `class A extends Base<T>` or `class B implements Iface<T>` (including nested generics and qualified names). This affected Java, TypeScript, and C++ templates, and most often broke exactly the interface-to-implementation service classes that matter most for navigation, so `codegraph_callers`, `codegraph_impact`, and `codegraph_trace` now follow those inheritance links instead of dead-ending.


## [0.9.8] - 2026-06-01

Expand Down
41 changes: 41 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,47 @@ public class Splitter {
});
});

describe('Generic supertype extraction', () => {
// Regression: a generic superclass/interface parses as a `generic_type`
// (C++ `template_type`) node whose text carries the angle-bracket suffix.
// Without stripping the type arguments the reference name (`Base<T>`) never
// matched the `Base` class node during resolution, so the extends/implements
// edge was silently dropped — it wasn't even kept in unresolved_refs.
it('strips generic type arguments from Java extends/implements supertypes', () => {
const code = `
class Base<T> {}
interface Iface<T> {}
class A extends Base<String> {}
class B implements Iface<String> {}
class C extends Base<java.util.Map<String, Integer>> {}
`;
const result = extractFromSource('Generics.java', code);

const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
const implementsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'implements');

// `extends Base<String>` and the nested `extends Base<Map<...>>` both → "Base"
expect(extendsRefs.some((r) => r.referenceName === 'Base')).toBe(true);
// `implements Iface<String>` → "Iface"
expect(implementsRefs.some((r) => r.referenceName === 'Iface')).toBe(true);
// The angle-bracket suffix is fully removed, including for nested generics.
expect(extendsRefs.every((r) => !r.referenceName.includes('<'))).toBe(true);
expect(implementsRefs.every((r) => !r.referenceName.includes('<'))).toBe(true);
});

it('strips template arguments from C++ base classes', () => {
const code = `
template <typename T> class Base {};
class Derived : public Base<int> {};
`;
const result = extractFromSource('derived.cpp', code);

const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
expect(extendsRefs.some((r) => r.referenceName === 'Base')).toBe(true);
expect(extendsRefs.every((r) => !r.referenceName.includes('<'))).toBe(true);
});
});

describe('C# Extraction', () => {
it('should extract class declarations', () => {
const code = `
Expand Down
21 changes: 21 additions & 0 deletions src/extraction/tree-sitter-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ export function getChildByField(node: SyntaxNode, fieldName: string): SyntaxNode
return node.childForFieldName(fieldName);
}

/**
* Strip generic/template type arguments from a type reference name.
*
* A supertype written as `Base<T>` (or nested `Base<Map<K, V>>`) is captured by
* tree-sitter as a `generic_type`/`template_type` node whose text includes the
* angle-bracket suffix. Class nodes are indexed under their argument-free name
* (`Base`), so without stripping, an `extends Base<T>` reference resolves to
* nothing and the inheritance edge is silently dropped.
*
* Only the first `<` matters: no Java/C#/Kotlin/Scala/C++ type identifier
* contains `<` except as the generic-argument delimiter, so slicing there is
* safe even for nested generics. Qualified prefixes (`com.foo.Base`, `ns::Base`)
* are intentionally preserved — resolution uses them to disambiguate same-named
* types across packages/namespaces. The `> 0` guard leaves synthetic names that
* legitimately start with `<` (e.g. anonymous-class markers) untouched.
*/
export function stripTypeArguments(name: string): string {
const lt = name.indexOf('<');
return (lt > 0 ? name.slice(0, lt) : name).trim();
}

/**
* Get the docstring/comment preceding a node
*/
Expand Down
14 changes: 10 additions & 4 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
UnresolvedReference,
} from '../types';
import { getParser, detectLanguage, isLanguageSupported, isFileLevelOnlyLanguage } from './grammars';
import { generateNodeId, getNodeText, getChildByField, getPrecedingDocstring } from './tree-sitter-helpers';
import { generateNodeId, getNodeText, getChildByField, getPrecedingDocstring, stripTypeArguments } from './tree-sitter-helpers';
import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types';
import { EXTRACTORS } from './languages';
import { LiquidExtractor } from './liquid-extractor';
Expand Down Expand Up @@ -2127,7 +2127,9 @@ export class TreeSitterExtractor {
const targets = typeList ? typeList.namedChildren : [child.namedChild(0)];
for (const target of targets) {
if (target) {
const name = getNodeText(target, this.source);
// Generic supertypes parse as `generic_type` whose text is `Base<T>`;
// strip the type arguments so the ref resolves to the `Base` node.
const name = stripTypeArguments(getNodeText(target, this.source));
this.unresolvedReferences.push({
fromNodeId: classId,
referenceName: name,
Expand All @@ -2149,9 +2151,11 @@ export class TreeSitterExtractor {
t.type === 'qualified_identifier' ||
t.type === 'template_type'
) {
// `template_type` text is `Base<T>` — strip the template arguments
// so the base resolves to the `Base` node.
this.unresolvedReferences.push({
fromNodeId: classId,
referenceName: getNodeText(t, this.source),
referenceName: stripTypeArguments(getNodeText(t, this.source)),
referenceKind: 'extends',
line: t.startPosition.row + 1,
column: t.startPosition.column,
Expand All @@ -2172,7 +2176,9 @@ export class TreeSitterExtractor {
const targets = typeList ? typeList.namedChildren : child.namedChildren;
for (const iface of targets) {
if (iface) {
const name = getNodeText(iface, this.source);
// Generic interfaces parse as `generic_type` whose text is `Iface<T>`;
// strip the type arguments so the ref resolves to the `Iface` node.
const name = stripTypeArguments(getNodeText(iface, this.source));
this.unresolvedReferences.push({
fromNodeId: classId,
referenceName: name,
Expand Down