From 75cb49477ea313f0258391251ea07dc37fcf4b7a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 12:25:05 +0100 Subject: [PATCH] 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> --- .../core/ingestion/languages/cpp/captures.ts | 73 +++++++++++++++-- .../src/core/ingestion/languages/cpp/query.ts | 19 +++++ .../contract/scope-resolver.ts | 3 + .../scope-resolution/pipeline/run.ts | 79 ++++++++++++++++++- .../base.h | 7 ++ .../derived.h | 6 ++ .../base.h | 12 +++ .../derived.h | 11 +++ .../test/integration/resolvers/cpp.test.ts | 72 +++++++++++++---- .../test/integration/resolvers/helpers.ts | 7 +- 10 files changed, 262 insertions(+), 27 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-template-multi-base-list/base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-template-multi-base-list/derived.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-u5-qualified-inline-base-call/base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-u5-qualified-inline-base-call/derived.h diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index e6d63635a..325742bfb 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -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` / `outer::v1::Base` + // 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` → `Base`, + * `outer::v1::Base` → `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): 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` → `Base`, + * `outer::v1::Base` → `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 ''; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts index 4e451617d..0e6a0a7ae 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/query.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -417,6 +417,25 @@ const CPP_SCOPE_QUERY = ` scope: (_) @reference.receiver name: (identifier) @reference.name)) @reference.call.qualified +;; Nested qualified receiver: outer::v1::Base::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::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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index d7b3618a2..d29b6efa8 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 4f74cfce2..0809d59ca 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -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, + nodeLookup: ReturnType, +): Set { + const handledSites = new Set(); + const seen = new Set(); + const existing = new Set(); + 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(); + const handledSites = new Set(preEmittedInheritanceSites); const receiverExtras = emitReceiverBoundCalls( graph, indexes, diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-template-multi-base-list/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-template-multi-base-list/base.h new file mode 100644 index 000000000..30b2f5bd6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-template-multi-base-list/base.h @@ -0,0 +1,7 @@ +#pragma once + +template +struct A {}; + +template +struct B {}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-template-multi-base-list/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-template-multi-base-list/derived.h new file mode 100644 index 000000000..7fe417b6a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-template-multi-base-list/derived.h @@ -0,0 +1,6 @@ +#pragma once + +#include "base.h" + +template +struct Derived : A, B {}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-u5-qualified-inline-base-call/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-u5-qualified-inline-base-call/base.h new file mode 100644 index 000000000..de3e84def --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-u5-qualified-inline-base-call/base.h @@ -0,0 +1,12 @@ +#pragma once + +namespace outer { + inline namespace v1 { + template + struct Base { + void f(); + }; + + void free_fn(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-u5-qualified-inline-base-call/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-u5-qualified-inline-base-call/derived.h new file mode 100644 index 000000000..2ff5523a1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-u5-qualified-inline-base-call/derived.h @@ -0,0 +1,11 @@ +#pragma once + +#include "base.h" + +template +struct Derived : outer::v1::Base { + void g() { + outer::v1::Base::f(); + outer::v1::free_fn(); + } +}; diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 1f6135408..bcc90350a 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -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::method() inside template body (no false positives)', () => { +describe('C++ Phase 5 U1×U3 — qualified Base::method() inside template body', () => { let result: PipelineResult; beforeAll(async () => { @@ -2108,22 +2108,33 @@ describe('C++ Phase 5 U1×U3 — qualified Base::method() inside template bod ); }, 60000); - it('Base::method() does NOT mis-route to a class method outside the MRO', () => { + it('emits EXTENDS edge: Derived → Base for template base Base', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Derived → Base'); + }); + + it('Base::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` 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 (inline)' expect(fLeaks.length).toBe(0); }); }); + +describe('C++ Phase 5 U1×U3×U5 — qualified outer::v1::Base::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', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Derived → Base'); + }); + + it('outer::v1::Base::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'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 7cbfaaf6c..418bbc0fb 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -155,8 +155,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly::method() does NOT mis-route to a class method outside the MRO', + 'emits EXTENDS edge: Derived → Base for template base Base', + 'emits EXTENDS edges: Derived → A, Derived → B for template multi-base list', + 'Base::method() resolves to Base::method inside template body', 'unqualified f() inside Derived::g() does NOT bind to outer::v1::Base::f (dependent base across inline namespace)', + 'emits EXTENDS edge: Derived → Base for qualified template base outer::v1::Base', + 'outer::v1::Base::f() resolves to Base::f inside template body', + 'outer::v1::free_fn() resolves as a namespace free function, not a super-receiver method', ]), };