fix(extraction): resolve C++ inheritance from templated base classes (#1043) (#1048)

A C++ class deriving from a template — `class Derived : public Base<int>`,
a CRTP base `class App : public CRTPBase<App>`, a struct inheriting a
template, or a templated base mixed into a multi-base clause — recorded its
base as the full instantiation text (`Base<int>`). That never name-matched
the template, which is indexed as the bare node `Base`, so the `extends`
edge never resolved and the derived class looked like it inherited from
nothing — callers/impact analysis stopped at the boundary.

Strip the template arguments from the base-type reference name in the
`base_class_clause` handler via a new `stripCppTemplateArgs` helper: it
removes every balanced `<…>` group (any nesting/position), so `Base<int>`
→ `Base` and `ns::Tpl<int>` → `ns::Tpl`. The remaining qualified head is
exactly what the non-templated base case already produces, so resolution
treats templated and non-templated bases identically; a name with no
template args passes through unchanged.

Covers same-file and same-namespace bases (the dominant real-world
patterns). A base in a different namespace referenced with its qualifier
(`other_ns::Tpl<int>`) still doesn't resolve, but that's a pre-existing,
orthogonal namespace-resolution gap — the non-templated `other_ns::Plain`
fails identically — not a template issue.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-28 20:43:48 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 4c0c87ff6e
commit f4e03e9cdc
5 changed files with 122 additions and 2 deletions
+42
View File
@@ -11,6 +11,7 @@ import * as os from 'os';
import { CodeGraph } from '../src';
import { extractFromSource, scanDirectory, buildDefaultIgnore } from '../src/extraction';
import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars, isSourceFile } from '../src/extraction/grammars';
import { stripCppTemplateArgs } from '../src/extraction/languages/c-cpp';
import { normalizePath } from '../src/utils';
beforeAll(async () => {
@@ -2721,6 +2722,47 @@ class Plain : public Base { public: int y; };
});
});
describe('C++ templated base-class inheritance (#1043)', () => {
// Inheriting from a template (`class D : public Base<int>`) recorded the base
// ref as the full instantiation `Base<int>`, which never name-matched the
// template indexed as the bare node `Base`. The `<…>` args are stripped so the
// `extends` reference matches.
it('strips template args from a templated base so the extends ref is the bare name', () => {
const code = `
template<typename T> class Base {};
template<typename D> class CRTPBase {};
namespace ns { template<typename T> class Tpl {}; }
class Plain {};
class Widget : public Base<int> {};
class App : public CRTPBase<App> {};
class Q : public ns::Tpl<int> {};
class Both : public Base<char>, public Plain {};
`;
const extendsRefs = extractFromSource('f.cpp', code).unresolvedReferences.filter(
(r) => r.referenceKind === 'extends'
);
const names = extendsRefs.map((r) => r.referenceName);
// Templated bases carry the bare name, NOT the `<…>` instantiation.
expect(names).toContain('Base'); // from Base<int> / Base<char>
expect(names).toContain('CRTPBase'); // from CRTPBase<App> (CRTP)
expect(names).toContain('ns::Tpl'); // qualified head preserved, args dropped
expect(names).toContain('Plain'); // non-templated base unchanged
// No reference still carries angle brackets.
expect(names.find((n) => n.includes('<'))).toBeUndefined();
});
it('stripCppTemplateArgs removes balanced <…> at any depth and is a no-op without them', () => {
expect(stripCppTemplateArgs('Base<int>')).toBe('Base');
expect(stripCppTemplateArgs('ns::Tpl<int>')).toBe('ns::Tpl');
expect(stripCppTemplateArgs('ns::Tpl<Foo<int>>')).toBe('ns::Tpl'); // nested
expect(stripCppTemplateArgs('Outer<int>::Inner')).toBe('Outer::Inner'); // mid-name
expect(stripCppTemplateArgs('Base')).toBe('Base'); // no-op
expect(stripCppTemplateArgs('ns::Plain')).toBe('ns::Plain'); // no-op qualified
});
});
describe('C/C++ imports', () => {
it('should extract system include', () => {
const code = `#include <iostream>`;
+45
View File
@@ -2173,6 +2173,51 @@ func main() {
});
});
describe('C++ templated base-class inheritance (#1043)', () => {
// A class deriving from a TEMPLATE — `class D : public Base<int>` (or a CRTP
// `class W : public CRTPBase<W>`, or a qualified `class Q : public ns::Tpl<int>`)
// recorded its base as the full instantiation text (`Base<int>`), which never
// name-matched the template, indexed as the bare node `Base`. The `<…>` args
// are now stripped so the `extends` edge resolves end-to-end.
it('resolves an extends edge to a templated base (plain, CRTP, struct, multi-base)', async () => {
fs.writeFileSync(
path.join(tempDir, 'lib.hpp'),
`#pragma once
template<typename T> class Base { public: void foo(); };
template<typename Derived> class CRTPBase {};
class Plain {};
class Widget : public Base<int> {}; // plain template base
class App : public CRTPBase<App> {}; // CRTP (curiously-recurring)
struct Node : public Base<double> {}; // struct inheriting a template
class Both : public Base<char>, public Plain {}; // templated + plain in one clause
`
);
cg = await CodeGraph.init(tempDir, { index: true });
const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db'));
const edges = db
.getDb()
.prepare(
`select src.name as fromName, dst.name as toName
from edges e
join nodes src on e.source = src.id
join nodes dst on e.target = dst.id
where e.kind = 'extends'`
)
.all() as Array<{ fromName: string; toName: string }>;
const has = (from: string, to: string) =>
edges.some((r) => r.fromName === from && r.toName === to);
// Every templated base now resolves to the bare template node.
expect(has('Widget', 'Base'), 'Widget : Base<int>').toBe(true);
expect(has('App', 'CRTPBase'), 'App : CRTPBase<App> (CRTP)').toBe(true);
expect(has('Node', 'Base'), 'struct Node : Base<double>').toBe(true);
// A mixed clause resolves BOTH the templated and the plain base.
expect(has('Both', 'Base'), 'Both : Base<char>').toBe(true);
expect(has('Both', 'Plain'), 'Both : Plain (non-templated, regression guard)').toBe(true);
});
});
describe('PHP Include Resolution', () => {
it('isPhpIncludePathRef distinguishes include paths from namespace use (#660)', () => {
const mk = (name: string, over: Partial<UnresolvedRef> = {}): UnresolvedRef => ({