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:
@@ -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,
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
template<class T>
|
||||
struct A {};
|
||||
|
||||
template<class T>
|
||||
struct B {};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "base.h"
|
||||
|
||||
template<class T>
|
||||
struct Derived : A<T>, B<T> {};
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
namespace outer {
|
||||
inline namespace v1 {
|
||||
template<class T>
|
||||
struct Base {
|
||||
void f();
|
||||
};
|
||||
|
||||
void free_fn();
|
||||
}
|
||||
}
|
||||
Vendored
+11
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
]),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user