Skip to content
Merged
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
13 changes: 9 additions & 4 deletions gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,21 @@ export const cppScopeResolver: ScopeResolver = {
// fallback and wildcard expansion can suppress them as unqualified
// cross-file callables.
populateCppNonGloballyVisible(parsed);
// Resolve recorded template-class → dependent-base simple names to
// class nodeIds for two-phase template lookup (U3 of plan
// 2026-05-13-001).
populateCppDependentBases(parsed);
// Build the class-def → enclosing-namespace-qualified-name map used
// by ADL (U2 of plan 2026-05-13-001) to identify each argument type's
// associated namespace for Koenig lookup.
populateCppAssociatedNamespaces(parsed);
},

// Resolve recorded template-class → dependent-base simple names to
// class nodeIds for two-phase template lookup (U3 of plan
// 2026-05-13-001). Runs AFTER all files have had `populateOwners`
// applied so that cross-file base classes (e.g. Base in base.h,
// Derived in derived.h) are reachable in the workspace index.
populateWorkspaceOwners: (parsedFiles: readonly ParsedFile[]) => {
populateCppDependentBases(parsedFiles);
},

// Simple `isSuperReceiver` returns false for C++. Real super
// classification is caller-context-dependent and lives in
// `isSuperReceiverInContext` below — without scope context the
Expand Down
128 changes: 100 additions & 28 deletions gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,20 @@
* This module records — during `emitCppScopeCaptures` — which template
* class declarations have which dependent base class names (per file).
* `populateCppDependentBases` then resolves those names to class nodeIds
* using the workspace registry, building the per-class set the
* `isDependentBaseMember` predicate consumes.
* using a workspace-wide registry, building the per-class set the
* `isCppDependentBaseMember` predicate consumes.
*
* Cross-file resolution: `Base<T>` may be declared in a different header
* than `Derived<T>`. `populateCppDependentBases` therefore runs as a
* workspace-wide pass (`populateWorkspaceOwners` hook) after every file
* has had `populateOwners` applied, so all class defs are reachable.
*
* Namespace disambiguation: when multiple classes share a simple name
* (e.g., `Box` in two namespaces), the resolver prefers the candidate
* whose qualified-name prefix (namespace path) matches the deriving
* class's prefix. If no namespace match is found, a unique simple-name
* match is accepted; ambiguous matches (multiple candidates, no
* namespace winner) are skipped conservatively.
*
* NOTE: module-level state, single-process-single-repo use only.
* `clearFileLocalNames()` clears this state alongside file-local linkage
Expand Down Expand Up @@ -69,37 +81,97 @@ export function clearCppDependentBases(): void {
}

/**
* Resolve recorded dependent-base simple names to class nodeIds using
* the parsed file's localDefs. Run as part of `populateOwners` so the
* resolved set is available before any resolution pass consults it.
* Resolve recorded dependent-base simple names to class nodeIds using a
* workspace-wide index. Run as `populateWorkspaceOwners` after every
* file has had `populateOwners` applied, so class defs from ALL files
* are reachable.
*
* Matches by simple name within the same file (the template class and
* its base are typically declared in the same TU; cross-file template
* bases are an edge case deferred to V2).
* Disambiguation strategy (multiple classes sharing a simple name):
* 1. Prefer the candidate whose qualified-name namespace prefix matches
* the deriving class's namespace prefix (same-namespace bias).
* 2. Fall back to accepting a unique simple-name match.
* 3. Skip when multiple candidates exist and no namespace match is
* found (conservative: avoids false associations).
*/
export function populateCppDependentBases(parsed: ParsedFile): void {
const perFile = dependentBasesByFile.get(parsed.filePath);
if (perFile === undefined) return;

// Build simple-name → nodeId index for this file's class-like defs.
const classByName = new Map<string, string>();
for (const def of parsed.localDefs) {
if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
if (simple !== '') classByName.set(simple, def.nodeId);
export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): void {
if (dependentBasesByFile.size === 0) return;

// Build workspace-wide index: simpleName → {nodeId, nsPrefix}[]
// nsPrefix is the dot-joined namespace path (qualifiedName without the
// last segment). Classes at global scope have nsPrefix = ''.
const classesBySimpleName = new Map<string, { nodeId: string; nsPrefix: string }[]>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
const qn = def.qualifiedName ?? '';
const lastDot = qn.lastIndexOf('.');
const simple = lastDot >= 0 ? qn.slice(lastDot + 1) : qn;
if (simple === '') continue;
const nsPrefix = lastDot >= 0 ? qn.slice(0, lastDot) : '';
let entries = classesBySimpleName.get(simple);
if (entries === undefined) {
entries = [];
classesBySimpleName.set(simple, entries);
}
entries.push({ nodeId: def.nodeId, nsPrefix });
}
}

for (const [className, baseNames] of perFile) {
const classNodeId = classByName.get(className);
if (classNodeId === undefined) continue;
let bases = dependentBaseNodeIds.get(classNodeId);
if (bases === undefined) {
bases = new Set();
dependentBaseNodeIds.set(classNodeId, bases);
// Build a filePath → ParsedFile lookup for fast per-file access.
const parsedByFile = new Map<string, ParsedFile>();
for (const parsed of parsedFiles) parsedByFile.set(parsed.filePath, parsed);

for (const [filePath, perFile] of dependentBasesByFile) {
const parsed = parsedByFile.get(filePath);
if (parsed === undefined) continue;

// Build a simple-name → {nodeId, nsPrefix} map for THIS file's
// class-like defs so we can identify each template class precisely
// (avoids cross-file name collisions for the deriving class itself).
const localClassByName = new Map<string, { nodeId: string; nsPrefix: string }>();
for (const def of parsed.localDefs) {
if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
const qn = def.qualifiedName ?? '';
const lastDot = qn.lastIndexOf('.');
const simple = lastDot >= 0 ? qn.slice(lastDot + 1) : qn;
if (simple === '') continue;
const nsPrefix = lastDot >= 0 ? qn.slice(0, lastDot) : '';
localClassByName.set(simple, { nodeId: def.nodeId, nsPrefix });
}
for (const baseName of baseNames) {
const baseNodeId = classByName.get(baseName);
if (baseNodeId !== undefined) bases.add(baseNodeId);

for (const [className, baseNames] of perFile) {
const classEntry = localClassByName.get(className);
if (classEntry === undefined) continue;

let bases = dependentBaseNodeIds.get(classEntry.nodeId);
if (bases === undefined) {
bases = new Set();
dependentBaseNodeIds.set(classEntry.nodeId, bases);
}

for (const baseName of baseNames) {
const candidates = classesBySimpleName.get(baseName);
if (candidates === undefined || candidates.length === 0) continue;

if (candidates.length === 1) {
// Unique simple-name match — accept regardless of namespace.
bases.add(candidates[0].nodeId);
continue;
}

// Multiple classes share the same simple name — prefer the one
// whose namespace matches the deriving class's namespace.
// V1: exact dot-prefix match only. Cross-namespace inheritance
// (e.g., `ns::outer::Derived` extending bare `Inner` defined in
// `ns::outer::inner`) and inline-namespace cases are deferred to
// V2; the conservative skip-on-ambiguity below avoids false
// associations in those edge cases.
const nsMatch = candidates.find((c) => c.nsPrefix === classEntry.nsPrefix);
if (nsMatch !== undefined) {
bases.add(nsMatch.nodeId);
}
// else: ambiguous (multiple candidates, no namespace match) → skip.
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#pragma once

namespace geom {

template<class T>
struct Base {
void compute();
int area;
};

// Free function inside the same namespace — no ownerId, so the
// class-owned filter does NOT apply to this candidate. It is instead
// suppressed by the namespace-nesting filter (isCppDefGloballyVisible).
// The test therefore exercises a candidate path that is orthogonal to
// the class-owned filter, proving the overall suppression stack is
// robust even when ownerId-based blocking is absent.
void compute();

} // namespace geom
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#pragma once

#include "base.h"

namespace geom {

template<class T>
struct Derived : Base<T> {
// Unqualified call to compute() inside a template body whose base is
// dependent. Two-phase lookup: the compiler does NOT look into
// Base<T> for this name — so GitNexus must also suppress the edge.
void g() {
compute();
}
// Unqualified field access — same reasoning applies.
int h() {
return area;
}
};

} // namespace geom
31 changes: 31 additions & 0 deletions gitnexus/test/integration/resolvers/cpp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1903,6 +1903,37 @@ describe('C++ two-phase template lookup — dependent base suppression', () => {
// and template-body member-lookup work tracked separately. See plan
// 2026-05-13-001 follow-ups.

// ---------------------------------------------------------------------------
// U3 cross-file namespace variant: Base lives in a different file AND
// inside a namespace. The fixture also contains a free function with the
// same name inside the namespace — that candidate has no ownerId, so the
// class-owned filter does NOT apply to it; it is instead suppressed by the
// namespace-nesting filter. Both candidates must still yield zero edges.
// ---------------------------------------------------------------------------

describe('C++ two-phase template lookup — cross-file namespace variant', () => {
let result: PipelineResult;

beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-two-phase-dependent-base-ns'),
() => {},
);
}, 60000);

it('geom::Derived<T>::g() -> compute() does NOT bind to geom::Base<T>::compute (cross-file dependent base, class-owned)', () => {
const calls = getRelationships(result, 'CALLS');
const leaks = calls.filter((c) => c.source === 'g' && c.target === 'compute');
expect(leaks.length).toBe(0);
});

it('geom::Derived<T>::h() -> area does NOT bind to geom::Base<T>::area (cross-file dependent base, class-owned)', () => {
const accesses = getRelationships(result, 'ACCESSES');
const leaks = accesses.filter((c) => c.source === 'h' && c.target === 'area');
expect(leaks.length).toBe(0);
});
});

// ---------------------------------------------------------------------------
// U2 (follow-up plan 2026-05-13-001): argument-dependent (Koenig) lookup.
// Free-function calls with class-typed arguments must consider candidates
Expand Down
Loading