feat(cpp): emit EXTENDS edges for template and qualified template bases (#1581)

* Initial plan

* fix: emit cpp extends edges for template bases

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/eaddb1ac-7b57-4f44-94ba-a07a578d078d

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore: address final review notes

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/eaddb1ac-7b57-4f44-94ba-a07a578d078d

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: keep cpp extends edges class-owned

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b10bbb4d-6746-46fa-9b82-5c0962cd8b3f

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test: address cpp follow-up review findings

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/be67e437-055f-4a71-a24e-d3bfb87ad0cd

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
This commit is contained in:
Copilot
2026-05-14 12:25:05 +01:00
committed by GitHub
co-authored by magyargergo
parent e01f0912bc
commit 75cb49477e
10 changed files with 262 additions and 27 deletions
@@ -301,6 +301,13 @@ export function emitCppScopeCaptures(
out.push(grouped);
}
// ── Emit inheritance references for scope-resolution MRO / EXTENDS ──
// Walk every class/struct base list and synthesize `@reference.inherits`
// captures consumed by the registry-primary graph bridge. The lookup name
// is normalized to the bare class name so `Base<T>` / `outer::v1::Base<T>`
// resolve through V1's simple-name `findClassBindingInScope('Base')`.
emitCppInheritanceCaptures(tree.rootNode, out);
// ── Detect dependent-base relationships for two-phase template lookup ──
// Walk the tree once, finding every `template_declaration` whose
// child is a class/struct definition with a `base_class_clause` whose
@@ -314,6 +321,40 @@ export function emitCppScopeCaptures(
return out;
}
/**
* Walk every C++ class/struct base clause and emit `@reference.inherits`
* captures for each base so scope resolution can resolve them into EXTENDS
* edges. Lookup names are normalized to bare class names (`Base<T>` → `Base`,
* `outer::v1::Base<T>` → `Base`) to match the V1 simple-name
* `findClassBindingInScope` contract. This intentionally preserves the
* existing scope-chain tradeoff: qualified namespace context is discarded
* here instead of introducing a C++-only name-resolution lane in shared
* ingestion infrastructure.
*/
function emitCppInheritanceCaptures(root: SyntaxNode, out: CaptureMatch[]): void {
const stack: SyntaxNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.type === 'class_specifier' || node.type === 'struct_specifier') {
const baseClause = findChildOfType(node, ['base_class_clause']);
if (baseClause !== null) {
for (const base of iterBaseClasses(baseClause)) {
const baseName = extractBaseLookupName(base);
if (baseName.length === 0) continue;
out.push({
'@reference.inherits': nodeToCapture('@reference.inherits', base),
'@reference.name': syntheticCapture('@reference.name', base, baseName),
});
}
}
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child !== null) stack.push(child);
}
}
}
/**
* Walk the AST finding every template_declaration containing a class or
* struct definition with a dependent base. Records (className, baseName)
@@ -344,7 +385,7 @@ function detectCppDependentBases(root: SyntaxNode, filePath: string): void {
if (baseClause !== null) {
for (const base of iterBaseClasses(baseClause)) {
if (isBaseDependent(base, params)) {
const baseName = extractBaseSimpleName(base);
const baseName = extractBaseLookupName(base);
if (baseName !== '') {
markCppDependentBase(filePath, className, baseName);
}
@@ -461,19 +502,35 @@ function isBaseDependent(baseNode: SyntaxNode, templateParams: Set<string>): boo
return false;
}
/** Extract the simple name of a base class node. */
function extractBaseSimpleName(baseNode: SyntaxNode): string {
if (baseNode.type === 'type_identifier') return baseNode.text;
/**
* Recursively extract the bare lookup name of a base class node.
* Examples: `Base` → `Base`, `Base<T>` → `Base`,
* `outer::v1::Base<T>` → `Base`. Namespace qualifiers are intentionally
* dropped to align with V1 scope-chain lookup everywhere else in the
* registry-primary pipeline.
*/
function extractBaseLookupName(baseNode: SyntaxNode): string {
if (baseNode.type === 'type_identifier' || baseNode.type === 'identifier') return baseNode.text;
if (baseNode.type === 'template_type') {
const nameNode = baseNode.childForFieldName('name');
if (nameNode !== null) return nameNode.text;
// Fallback: first type_identifier descendant.
const id = findFirstDescendantOfType(baseNode, 'type_identifier');
if (nameNode !== null) return extractBaseLookupName(nameNode);
const id =
findFirstDescendantOfType(baseNode, 'type_identifier') ??
findFirstDescendantOfType(baseNode, 'identifier');
if (id !== null) return id.text;
}
if (baseNode.type === 'qualified_identifier') {
const nameNode = baseNode.childForFieldName('name');
if (nameNode !== null) return nameNode.text;
if (nameNode !== null) {
const nested = extractBaseLookupName(nameNode);
if (nested.length > 0) return nested;
}
for (let i = baseNode.childCount - 1; i >= 0; i--) {
const child = baseNode.child(i);
if (child === null) continue;
const nested = extractBaseLookupName(child);
if (nested.length > 0) return nested;
}
}
return '';
}
@@ -417,6 +417,25 @@ const CPP_SCOPE_QUERY = `
scope: (_) @reference.receiver
name: (identifier) @reference.name)) @reference.call.qualified
;; Nested qualified receiver: outer::v1::Base<T>::f()
;; tree-sitter-cpp nests this as qualified_identifier(name:
;; qualified_identifier(scope: qualified_identifier(...), name: identifier)).
;; Capturing the innermost receiver still gives isSuperReceiverInContext
;; enough text to strip qualifiers/template args down to Base.
(call_expression
function: (qualified_identifier
name: (qualified_identifier
scope: (_) @reference.receiver
name: (identifier) @reference.name))) @reference.call.qualified
;; Double-nested qualified receiver: outer::v1::Base<T>::f()
(call_expression
function: (qualified_identifier
name: (qualified_identifier
name: (qualified_identifier
scope: (_) @reference.receiver
name: (identifier) @reference.name)))) @reference.call.qualified
;; ─── References — member calls (obj.method() / ptr->method()) ───────
(call_expression
function: (field_expression
@@ -87,6 +87,9 @@
* attempting emission (even on dedup-collapse), because the
* per-(caller, target) collapse semantics require multiple call
* sites in the same caller body not produce multiple edges.
* `preEmitInheritanceEdges` also pre-marks every `inherits` site so
* the generic bridge cannot remap class heritage into method-owned
* EXTENDS edges via `resolveCallerGraphId`.
*
* - **I3 — `propagateImportedReturnTypes` mutation timing + ordering.**
* The pass mutates `Scope.typeBindings` (a plain `new Map(...)` from
@@ -32,16 +32,88 @@ import { extractParsedFile } from '../../scope-extractor-bridge.js';
import { finalizeScopeModel } from '../../finalize-orchestrator.js';
import { resolveReferenceSites, type ResolveStats } from '../../resolve-references.js';
import { buildGraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { resolveDefGraphId } from '../graph-bridge/ids.js';
import { buildPopulatedMethodDispatch } from '../graph-bridge/method-dispatch.js';
import { tryEmitEdge } from '../graph-bridge/edges.js';
import { propagateImportedReturnTypes } from '../passes/imported-return-types.js';
import { emitReceiverBoundCalls } from '../passes/receiver-bound-calls.js';
import { emitFreeCallFallback } from '../passes/free-call-fallback.js';
import { emitReferencesViaLookup } from '../graph-bridge/references-to-edges.js';
import { emitImportEdges } from '../graph-bridge/imports-to-edges.js';
import type { ScopeResolver } from '../contract/scope-resolver.js';
import { findClassBindingInScope, findEnclosingClassDef } from '../scope/walkers.js';
import { buildWorkspaceResolutionIndex } from '../workspace-index.js';
import { logger } from '../../../logger.js';
/**
* Resolve inheritance reference sites early and pre-emit their EXTENDS edges
* before MRO construction. This lets template-base captures contribute to the
* graph in time for `buildMro`, while `handledSites` prevents the generic
* reference-edge bridge from re-emitting the same sites later.
*
* @returns Site keys to seed the downstream handled-site skip set.
*/
function preEmitInheritanceEdges(
graph: KnowledgeGraph,
scopes: ReturnType<typeof finalizeScopeModel>,
nodeLookup: ReturnType<typeof buildGraphNodeLookup>,
): Set<string> {
const handledSites = new Set<string>();
const seen = new Set<string>();
const existing = new Set<string>();
for (const rel of graph.iterRelationshipsByType('EXTENDS')) {
existing.add(`${rel.sourceId}->${rel.targetId}`);
}
for (const site of scopes.referenceSites) {
if (site.kind !== 'inherits') continue;
const scope = scopes.scopeTree.getScope(site.inScope);
const siteKey =
scope?.filePath !== undefined
? `${scope.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`
: undefined;
if (siteKey !== undefined) {
// Intentionally suppress every `inherits` site from the generic
// reference bridge, even when this pre-pass can't emit an EXTENDS
// edge. The shared bridge resolves the source via
// `resolveCallerGraphId`, which can degrade class-heritage sites into
// method-owned EXTENDS edges once methods exist on the class. This
// pre-pass is the authoritative inheritance emitter, so broad
// suppression keeps `buildMro` and the final graph class-owned.
handledSites.add(siteKey);
}
const targetDef = findClassBindingInScope(site.inScope, site.name, scopes);
if (targetDef === undefined) continue;
const callerClass = findEnclosingClassDef(site.inScope, scopes);
if (callerClass === undefined) continue;
const callerGraphId = resolveDefGraphId(callerClass.filePath, callerClass, nodeLookup);
const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup);
if (callerGraphId === undefined || targetGraphId === undefined) continue;
const edgeKey = `${callerGraphId}->${targetGraphId}`;
if (existing.has(edgeKey)) continue;
if (
tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
targetDef,
'scope-resolution: inherits',
seen,
0.85,
)
) {
existing.add(edgeKey);
}
}
return handledSites;
}
interface RunScopeResolutionInput {
readonly graph: KnowledgeGraph;
/**
@@ -183,8 +255,6 @@ export function runScopeResolution(
// ── Phase 2: finalize → ScopeResolutionIndexes ─────────────────────────
const allFilePaths = new Set(parsedFiles.map((f) => f.filePath));
const nodeLookup = buildGraphNodeLookup(graph);
const mroByClassDefId = provider.buildMro(graph, parsedFiles, nodeLookup);
const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(graph, parsedFiles, nodeLookup);
const resolutionConfig = input.resolutionConfig;
const finalized = finalizeScopeModel(parsedFiles, {
@@ -197,6 +267,9 @@ export function runScopeResolution(
provider.mergeBindings(existing, incoming, scopeId),
},
});
const preEmittedInheritanceSites = preEmitInheritanceEdges(graph, finalized, nodeLookup);
const mroByClassDefId = provider.buildMro(graph, parsedFiles, nodeLookup);
const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(graph, parsedFiles, nodeLookup);
// Replace the empty MethodDispatchIndex that finalizeScopeModel
// builds by design with the populated one derived from the
@@ -273,7 +346,7 @@ export function runScopeResolution(
const tResolve = PROF ? process.hrtime.bigint() : 0n;
// ── Phase 4: emit graph edges (LOAD-BEARING ORDER — see I1) ────────────
const handledSites = new Set<string>();
const handledSites = new Set<string>(preEmittedInheritanceSites);
const receiverExtras = emitReceiverBoundCalls(
graph,
indexes,
@@ -0,0 +1,7 @@
#pragma once
template<class T>
struct A {};
template<class T>
struct B {};
@@ -0,0 +1,6 @@
#pragma once
#include "base.h"
template<class T>
struct Derived : A<T>, B<T> {};
@@ -0,0 +1,12 @@
#pragma once
namespace outer {
inline namespace v1 {
template<class T>
struct Base {
void f();
};
void free_fn();
}
}
@@ -0,0 +1,11 @@
#pragma once
#include "base.h"
template<class T>
struct Derived : outer::v1::Base<T> {
void g() {
outer::v1::Base<T>::f();
outer::v1::free_fn();
}
};
+57 -15
View File
@@ -2098,7 +2098,7 @@ describe('C++ inline namespace — ADL participation', () => {
// U3 (two-phase lookup), and U5 (inline namespaces).
// ---------------------------------------------------------------------------
describe('C++ Phase 5 U1×U3 — qualified Base<T>::method() inside template body (no false positives)', () => {
describe('C++ Phase 5 U1×U3 — qualified Base<T>::method() inside template body', () => {
let result: PipelineResult;
beforeAll(async () => {
@@ -2108,22 +2108,33 @@ describe('C++ Phase 5 U1×U3 — qualified Base<T>::method() inside template bod
);
}, 60000);
it('Base<T>::method() does NOT mis-route to a class method outside the MRO', () => {
it('emits EXTENDS edge: Derived → Base for template base Base<T>', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('Derived → Base');
});
it('Base<T>::method() resolves to Base::method inside template body', () => {
const calls = getRelationships(result, 'CALLS');
const methodCalls = calls.filter((c) => c.source === 'g' && c.target === 'method');
// V1 documented gap: cross-file (and same-file) template-class
// inheritance is not captured as an EXTENDS edge by the legacy DAG
// (the cpp captures.ts has no `base_class_clause` heritage emitter
// for template_type bases). Without an EXTENDS edge, MRO is empty
// and the U1 super branch can't dispatch. Result: 0 CALLS edges.
//
// This Phase 5 cross-unit composition test locks in that the
// template-arg-stripping U1 logic produces NO false positives —
// `Base<T>` correctly classifies as a super-receiver candidate but
// (due to empty MRO) doesn't accidentally route to an unrelated
// method named `method` via any other case. count > 0 here would
// indicate the U1 stripped lookup mis-resolved across cases.
expect(methodCalls.length).toBe(0);
expect(methodCalls.length).toBe(1);
expect(methodCalls[0].targetFilePath).toContain('classes.h');
});
});
describe('C++ Phase 5 U1×U3 — template multi-base list', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-phase5-u1-u3-template-multi-base-list'),
() => {},
);
}, 60000);
it('emits EXTENDS edges: Derived → A, Derived → B for template multi-base list', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(2);
expect(edgeSet(extends_)).toEqual(['Derived → A', 'Derived → B']);
});
});
@@ -2183,3 +2194,34 @@ describe('C++ Phase 5 U3×U5 — template Derived : outer::v1::Base<T> (inline)'
expect(fLeaks.length).toBe(0);
});
});
describe('C++ Phase 5 U1×U3×U5 — qualified outer::v1::Base<T>::f() inside template body', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-phase5-u1-u3-u5-qualified-inline-base-call'),
() => {},
);
}, 60000);
it('emits EXTENDS edge: Derived → Base for qualified template base outer::v1::Base<T>', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('Derived → Base');
});
it('outer::v1::Base<T>::f() resolves to Base::f inside template body', () => {
const calls = getRelationships(result, 'CALLS');
const fCalls = calls.filter((c) => c.source === 'g' && c.target === 'f');
expect(fCalls.length).toBe(1);
expect(fCalls[0].targetFilePath).toContain('base.h');
});
it('outer::v1::free_fn() resolves as a namespace free function, not a super-receiver method', () => {
const calls = getRelationships(result, 'CALLS');
const freeCalls = calls.filter((c) => c.source === 'g' && c.target === 'free_fn');
expect(freeCalls.length).toBe(1);
expect(freeCalls[0].targetLabel).toBe('Function');
expect(freeCalls[0].rel.reason).toBe('import-resolved');
});
});
@@ -155,8 +155,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
// edges where the registry-primary path correctly suppresses.
// Scope-resolver-only correctness wins (PR #1520 review follow-up
// plan 2026-05-13-001 Phase 5); backporting is out of scope.
'Base<T>::method() does NOT mis-route to a class method outside the MRO',
'emits EXTENDS edge: Derived → Base for template base Base<T>',
'emits EXTENDS edges: Derived → A, Derived → B for template multi-base list',
'Base<T>::method() resolves to Base::method inside template body',
'unqualified f() inside Derived<T>::g() does NOT bind to outer::v1::Base<T>::f (dependent base across inline namespace)',
'emits EXTENDS edge: Derived → Base for qualified template base outer::v1::Base<T>',
'outer::v1::Base<T>::f() resolves to Base::f inside template body',
'outer::v1::free_fn() resolves as a namespace free function, not a super-receiver method',
]),
};