feat(cpp): resolve inheritance-lattice member lookup (#2077)

* feat(cpp): resolve inheritance-lattice member lookup

* fix(cpp): harden inheritance-lattice lookup

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
azizur100389 2026-06-09 06:53:11 +01:00 committed by GitHub
parent 774cd4d568
commit 3a4247ec36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1066 additions and 11 deletions

View file

@ -18,11 +18,11 @@
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
},
"cpp": {
"fingerprint": "fd3d3768cdebbb4767d7cf18b8d2df19d61de969c816d7f4d6b599f947811356",
"fingerprint": "f56625342f73e182170e2c964d538e316c079fa6e9466a7f076bff2ebcf8aac4",
"scaling_budget": 1.5,
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression).",
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae."
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5)."
},
"csharp": {
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",

View file

@ -42,6 +42,11 @@ import {
applyCppTwoPhaseSideChannel,
type CppTwoPhaseSideChannel,
} from './two-phase-lookup.js';
import {
applyCppMemberLookupSideChannel,
collectCppMemberLookupSideChannel,
type CppMemberLookupSideChannel,
} from './member-lookup.js';
/**
* Plain JSON-serializable composite of every C++ capture-time side-channel
@ -62,6 +67,7 @@ export interface CppCaptureSideChannel {
readonly inlineNamespaceRanges: readonly string[];
readonly fileLocal: CppFileLocalSideChannel;
readonly twoPhase: CppTwoPhaseSideChannel;
readonly memberLookup: CppMemberLookupSideChannel;
}
/**
@ -74,6 +80,7 @@ export function collectCppCaptureSideChannel(filePath: string): CppCaptureSideCh
const inlineNamespaceRanges = collectCppInlineNamespaceSideChannel(filePath);
const fileLocal = collectCppFileLocalSideChannel(filePath);
const twoPhase = collectCppTwoPhaseSideChannel(filePath);
const memberLookup = collectCppMemberLookupSideChannel(filePath);
const isEmpty =
adl.argInfoBySite.length === 0 &&
@ -82,10 +89,12 @@ export function collectCppCaptureSideChannel(filePath: string): CppCaptureSideCh
fileLocal.fileLocalNames.length === 0 &&
fileLocal.anonymousNamespaceRanges.length === 0 &&
twoPhase.dependentBases.length === 0 &&
twoPhase.dependentPackBaseClasses.length === 0;
twoPhase.dependentPackBaseClasses.length === 0 &&
memberLookup.baseEdges.length === 0 &&
memberLookup.memberUsings.length === 0;
if (isEmpty) return undefined;
return { kind: 'cpp', adl, inlineNamespaceRanges, fileLocal, twoPhase };
return { kind: 'cpp', adl, inlineNamespaceRanges, fileLocal, twoPhase, memberLookup };
}
/**
@ -108,4 +117,7 @@ export function applyCppCaptureSideChannel(parsed: ParsedFile): void {
}
if (data.fileLocal !== undefined) applyCppFileLocalSideChannel(parsed.filePath, data.fileLocal);
if (data.twoPhase !== undefined) applyCppTwoPhaseSideChannel(parsed.filePath, data.twoPhase);
if (data.memberLookup !== undefined) {
applyCppMemberLookupSideChannel(parsed.filePath, data.memberLookup);
}
}

View file

@ -20,6 +20,7 @@ import { markCppDependentBase, markCppDependentPackBase } from './two-phase-look
import { markCppAdlSiteArgs, markCppAdlSiteNoAdl, type CppAdlArgInfo } from './adl.js';
import { markCppInlineNamespaceRange } from './inline-namespaces.js';
import { extractCppTemplateConstraints } from './constraint-extractor.js';
import { captureCppMemberLookupFacts } from './member-lookup.js';
export function emitCppScopeCaptures(
sourceText: string,
@ -464,6 +465,7 @@ export function emitCppScopeCaptures(
// and the resolver can suppress unqualified-call binding to those
// bases per ISO C++ two-phase lookup.
detectCppDependentBases(tree.rootNode, filePath);
captureCppMemberLookupFacts(tree.rootNode, filePath);
return out;
}

View file

@ -73,6 +73,12 @@ function buildIncludeCapture(node: SyntaxNode, pathNode: SyntaxNode): CaptureMat
*/
export function splitCppUsingDecl(node: SyntaxNode): CaptureMatch | null {
if (node.type !== 'using_declaration') return null;
// A class-scope `using Base::member;` changes the derived class's member
// lookup set; it is not a namespace import. The C++ member-lookup sidecar
// captures it separately, so suppress import decomposition here.
for (let parent = node.parent; parent !== null; parent = parent.parent) {
if (parent.type === 'class_specifier' || parent.type === 'struct_specifier') return null;
}
// Check for "namespace" keyword among anonymous children
let hasNamespaceKeyword = false;

View file

@ -0,0 +1,616 @@
import type { ParsedFile, ReferenceSite, SymbolDefinition } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { SemanticModel } from '../../model/semantic-model.js';
import type { ReceiverMemberResolution } from '../../scope-resolution/contract/scope-resolver.js';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import {
isOverloadAmbiguousAfterNormalization,
narrowOverloadCandidates,
} from '../../scope-resolution/passes/overload-narrowing.js';
import { isClassLike } from '../../scope-resolution/scope/walkers.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { cppConstraintCompatibility } from './constraint-filter.js';
import { cppConversionRank } from './conversion-rank.js';
interface CapturedBaseEdge {
readonly childName: string;
readonly childQualifiedName?: string;
readonly baseName: string;
readonly baseQualifiedName?: string;
readonly isVirtual: boolean;
}
interface CapturedMemberUsing {
readonly childName: string;
readonly childQualifiedName?: string;
readonly baseName: string;
readonly baseQualifiedName?: string;
readonly memberName: string;
}
export interface CppMemberLookupSideChannel {
readonly baseEdges: readonly CapturedBaseEdge[];
readonly memberUsings: readonly CapturedMemberUsing[];
}
const capturedByFile = new Map<string, CppMemberLookupSideChannel>();
let directParentsByDefId = new Map<string, readonly string[]>();
let virtualEdges = new Set<string>();
let ancestorsByDefId = new Map<string, ReadonlySet<string>>();
let memberUsingsByDefId = new Map<
string,
readonly { readonly baseDefId: string; readonly memberName: string }[]
>();
let inheritedLookupCache = new Map<string, CachedInheritedLookup>();
const MAX_INHERITANCE_VISITS = 4096;
type CachedInheritedLookup =
| { readonly kind: 'none' }
| { readonly kind: 'candidates'; readonly definitions: readonly SymbolDefinition[] }
| { readonly kind: 'ambiguous'; readonly candidateIds: readonly string[] };
export function clearCppMemberLookupState(): void {
capturedByFile.clear();
directParentsByDefId = new Map();
virtualEdges = new Set();
ancestorsByDefId = new Map();
memberUsingsByDefId = new Map();
inheritedLookupCache = new Map();
}
export function captureCppMemberLookupFacts(root: SyntaxNode, filePath: string): void {
const baseEdges: CapturedBaseEdge[] = [];
const memberUsings: CapturedMemberUsing[] = [];
const stack: SyntaxNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.type === 'class_specifier' || node.type === 'struct_specifier') {
const childName = classNameOf(node);
const childQualifiedName = classQualifiedNameOf(node);
if (childName !== '') {
const baseClause = directChildOfType(node, 'base_class_clause');
if (baseClause !== null) {
captureBaseEdges(baseClause, childName, childQualifiedName, baseEdges);
}
const body = directChildOfType(node, 'field_declaration_list');
if (body !== null) {
for (let i = 0; i < body.namedChildCount; i++) {
const child = body.namedChild(i);
if (child?.type !== 'using_declaration') continue;
const parsed = parseMemberUsing(child, childName, childQualifiedName);
if (parsed !== undefined) memberUsings.push(parsed);
}
}
}
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child !== null) stack.push(child);
}
}
if (baseEdges.length === 0 && memberUsings.length === 0) {
capturedByFile.delete(filePath);
} else {
capturedByFile.set(filePath, { baseEdges, memberUsings });
}
}
export function collectCppMemberLookupSideChannel(filePath: string): CppMemberLookupSideChannel {
return capturedByFile.get(filePath) ?? { baseEdges: [], memberUsings: [] };
}
export function applyCppMemberLookupSideChannel(
filePath: string,
data: CppMemberLookupSideChannel,
): void {
if (!Array.isArray(data.baseEdges) || !Array.isArray(data.memberUsings)) return;
if (data.baseEdges.length === 0 && data.memberUsings.length === 0) {
capturedByFile.delete(filePath);
return;
}
capturedByFile.set(filePath, {
baseEdges: data.baseEdges.slice(),
memberUsings: data.memberUsings.slice(),
});
}
export function buildCppMemberLookupMro(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
): Map<string, string[]> {
populateResolvedHierarchy(graph, parsedFiles, nodeLookup);
return buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
}
export function resolveCppReceiverMember(
ownerDef: SymbolDefinition,
memberName: string,
callsite: ReferenceSite,
_scopes: ScopeResolutionIndexes,
model: SemanticModel,
): ReceiverMemberResolution | undefined {
if (callsite.kind !== 'call') return undefined;
const ownMethods = model.methods.lookupAllByOwner(ownerDef.nodeId, memberName);
const introduced = introducedDefinitions(ownerDef.nodeId, memberName, model);
if (introduced.length > 0) {
return chooseOverload(uniqueDefinitions([...ownMethods, ...introduced]), callsite);
}
// Direct declarations hide every base declaration. Let the shared path
// retain its existing overload/static filtering for this common case.
if (ownMethods.length > 0) return undefined;
const lookup = inheritedLookupSet(ownerDef.nodeId, memberName, model);
if (lookup.kind === 'none') return undefined;
if (lookup.kind === 'ambiguous') return lookup;
return chooseOverload(lookup.definitions, callsite);
}
interface MemberOccurrence {
readonly ownerDefId: string;
readonly definitions: readonly SymbolDefinition[];
readonly path: readonly string[];
readonly virtualAnchor?: string;
}
function collectInheritedOccurrences(
ownerDefId: string,
memberName: string,
model: SemanticModel,
path: readonly string[],
virtualAnchor: string | undefined,
active: Set<string>,
budget: { remaining: number; truncated: boolean },
): MemberOccurrence[] {
if (budget.remaining <= 0) {
budget.truncated = true;
return [];
}
budget.remaining--;
if (active.has(ownerDefId)) return [];
const nextActive = new Set(active);
nextActive.add(ownerDefId);
const definitions = uniqueDefinitions([
...model.methods.lookupAllByOwner(ownerDefId, memberName),
...introducedDefinitions(ownerDefId, memberName, model),
]);
if (definitions.length > 0) {
return [{ ownerDefId, definitions, path, virtualAnchor }];
}
const results: MemberOccurrence[] = [];
for (const parentDefId of directParentsByDefId.get(ownerDefId) ?? []) {
const edgeKey = `${ownerDefId}\0${parentDefId}`;
results.push(
...collectInheritedOccurrences(
parentDefId,
memberName,
model,
[...path, parentDefId],
virtualEdges.has(edgeKey) ? parentDefId : virtualAnchor,
nextActive,
budget,
),
);
}
return results;
}
function inheritedLookupSet(
ownerDefId: string,
memberName: string,
model: SemanticModel,
): CachedInheritedLookup {
const cacheKey = `${ownerDefId}\0${memberName}`;
const cached = inheritedLookupCache.get(cacheKey);
if (cached !== undefined) return cached;
const budget = { remaining: MAX_INHERITANCE_VISITS, truncated: false };
const occurrences = collectInheritedOccurrences(
ownerDefId,
memberName,
model,
[],
undefined,
new Set(),
budget,
);
if (budget.truncated) {
const conservative: CachedInheritedLookup = {
kind: 'ambiguous',
candidateIds: uniqueDefinitions(occurrences.flatMap((entry) => entry.definitions)).map(
(definition) => definition.nodeId,
),
};
inheritedLookupCache.set(cacheKey, conservative);
return conservative;
}
if (occurrences.length === 0) {
const none: CachedInheritedLookup = { kind: 'none' };
inheritedLookupCache.set(cacheKey, none);
return none;
}
// A declaration can dominate another lookup set only when the latter is
// reached through a shared virtual subobject. Ordinary ancestry alone is
// insufficient: declarations in one non-virtual branch do not hide members
// reached through a sibling base subobject.
const undominated = occurrences.filter(
(candidate) =>
!(
candidate.virtualAnchor !== undefined &&
occurrences.some(
(other) =>
other.ownerDefId !== candidate.ownerDefId &&
isAncestor(candidate.ownerDefId, other.ownerDefId),
)
),
);
const groups = new Map<string, MemberOccurrence[]>();
for (const occurrence of undominated) {
const key =
occurrence.virtualAnchor !== undefined
? `virtual:${occurrence.virtualAnchor}:${occurrence.ownerDefId}`
: `path:${occurrence.path.join('>')}:${occurrence.ownerDefId}`;
const bucket = groups.get(key);
if (bucket === undefined) groups.set(key, [occurrence]);
else bucket.push(occurrence);
}
let result: CachedInheritedLookup;
if (groups.size !== 1) {
result = {
kind: 'ambiguous',
candidateIds: uniqueDefinitions(undominated.flatMap((entry) => entry.definitions)).map(
(definition) => definition.nodeId,
),
};
} else {
result = {
kind: 'candidates',
definitions: groups.values().next().value?.[0]?.definitions ?? [],
};
}
inheritedLookupCache.set(cacheKey, result);
return result;
}
function introducedDefinitions(
ownerDefId: string,
memberName: string,
model: SemanticModel,
): SymbolDefinition[] {
const definitions: SymbolDefinition[] = [];
for (const entry of memberUsingsByDefId.get(ownerDefId) ?? []) {
if (entry.memberName !== memberName) continue;
definitions.push(...model.methods.lookupAllByOwner(entry.baseDefId, memberName));
}
return definitions;
}
function uniqueDefinitions(definitions: readonly SymbolDefinition[]): SymbolDefinition[] {
return [...new Map(definitions.map((definition) => [definition.nodeId, definition])).values()];
}
function chooseOverload(
candidates: readonly SymbolDefinition[],
callsite: ReferenceSite,
): ReceiverMemberResolution | undefined {
if (candidates.length === 0) return undefined;
const narrowed = narrowOverloadCandidates(candidates, callsite.arity, callsite.argumentTypes, {
argumentTypeClasses: callsite.argumentTypeClasses,
conversionRankFn: cppConversionRank,
constraintCompatibility: cppConstraintCompatibility,
});
if (narrowed.length === 1) return { kind: 'resolved', definition: narrowed[0]! };
if (narrowed.length > 1 || isOverloadAmbiguousAfterNormalization(narrowed, callsite.arity)) {
return {
kind: 'ambiguous',
candidateIds: narrowed.map((candidate) => candidate.nodeId),
};
}
return undefined;
}
function populateResolvedHierarchy(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
): void {
const defByGraphId = new Map<string, SymbolDefinition>();
const defById = new Map<string, SymbolDefinition>();
const defsByFileAndName = new Map<string, SymbolDefinition[]>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (!isClassLike(def.type)) continue;
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
if (graphId === undefined) continue;
defByGraphId.set(graphId, def);
defById.set(def.nodeId, def);
const names = new Set([simpleName(def), definitionQualifiedName(def)]);
for (const name of names) {
if (name === '') continue;
const key = `${parsed.filePath}\0${name}`;
const bucket = defsByFileAndName.get(key);
if (bucket === undefined) defsByFileAndName.set(key, [def]);
else bucket.push(def);
}
}
}
const parents = new Map<string, string[]>();
for (const rel of graph.iterRelationshipsByType('EXTENDS')) {
const child = defByGraphId.get(rel.sourceId);
const parent = defByGraphId.get(rel.targetId);
if (child === undefined || parent === undefined) continue;
const bucket = parents.get(child.nodeId);
if (bucket === undefined) parents.set(child.nodeId, [parent.nodeId]);
else bucket.push(parent.nodeId);
}
directParentsByDefId = parents;
ancestorsByDefId = buildAncestorClosure(parents);
inheritedLookupCache = new Map();
const nextVirtualEdges = new Set<string>();
const nextUsings = new Map<
string,
{ readonly baseDefId: string; readonly memberName: string }[]
>();
for (const parsed of parsedFiles) {
const captured = capturedByFile.get(parsed.filePath);
if (captured === undefined) continue;
for (const edge of captured.baseEdges) {
if (!edge.isVirtual) continue;
for (const child of matchingChildren(
parsed.filePath,
edge.childName,
edge.childQualifiedName,
defsByFileAndName,
)) {
const parent = findCapturedParent(
parents.get(child.nodeId) ?? [],
edge.baseName,
edge.baseQualifiedName,
defById,
);
if (parent !== undefined) nextVirtualEdges.add(`${child.nodeId}\0${parent.nodeId}`);
}
}
for (const using of captured.memberUsings) {
const children = matchingChildren(
parsed.filePath,
using.childName,
using.childQualifiedName,
defsByFileAndName,
);
for (const child of children) {
const baseDef = findCapturedParent(
parents.get(child.nodeId) ?? [],
using.baseName,
using.baseQualifiedName,
defById,
);
if (baseDef === undefined) continue;
const bucket = nextUsings.get(child.nodeId);
const entry = { baseDefId: baseDef.nodeId, memberName: using.memberName };
if (bucket === undefined) nextUsings.set(child.nodeId, [entry]);
else bucket.push(entry);
}
}
}
virtualEdges = nextVirtualEdges;
memberUsingsByDefId = nextUsings;
}
function captureBaseEdges(
baseClause: SyntaxNode,
childName: string,
childQualifiedName: string,
output: CapturedBaseEdge[],
): void {
let segmentStart = 0;
for (let i = 0; i < baseClause.childCount; i++) {
const child = baseClause.child(i);
if (child === null) continue;
if (child.type === ',' || child.text === ',') {
segmentStart = i + 1;
continue;
}
if (
child.type !== 'type_identifier' &&
child.type !== 'template_type' &&
child.type !== 'qualified_identifier'
) {
continue;
}
let isVirtual = false;
for (let j = segmentStart; j < i; j++) {
const modifier = baseClause.child(j);
if (modifier?.text === 'virtual') isVirtual = true;
}
const baseQualifiedName = qualifiedTypeName(child.text);
const baseName = baseQualifiedName.split('.').at(-1) ?? '';
if (baseName !== '') {
output.push({
childName,
...(childQualifiedName !== childName ? { childQualifiedName } : {}),
baseName,
...(baseQualifiedName !== baseName ? { baseQualifiedName } : {}),
isVirtual,
});
}
}
}
function parseMemberUsing(
node: SyntaxNode,
childName: string,
childQualifiedName: string,
): CapturedMemberUsing | undefined {
const qualified = node.namedChildren.find((child) => child.type === 'qualified_identifier');
if (qualified === undefined) return undefined;
const parts = splitQualifiedSegments(qualified.text);
if (parts.length < 2) return undefined;
const memberName = stripTemplateSuffix(parts.at(-1) ?? '');
const baseParts = parts.slice(0, -1).map(stripTemplateSuffix).filter(Boolean);
const baseName = baseParts.at(-1) ?? '';
const baseQualifiedName = baseParts.join('.');
if (baseName === '' || memberName === '') return undefined;
return {
childName,
...(childQualifiedName !== childName ? { childQualifiedName } : {}),
baseName,
...(baseQualifiedName !== baseName ? { baseQualifiedName } : {}),
memberName,
};
}
function classNameOf(node: SyntaxNode): string {
const name = node.childForFieldName?.('name');
return name === null || name === undefined ? '' : trailingIdentifier(name.text);
}
function classQualifiedNameOf(node: SyntaxNode): string {
const parts = [classNameOf(node)];
let current = node.parent;
while (current !== null) {
if (current.type === 'class_specifier' || current.type === 'struct_specifier') {
const name = classNameOf(current);
if (name !== '') parts.unshift(name);
} else if (current.type === 'namespace_definition') {
const name = current.childForFieldName?.('name');
if (name !== null && name !== undefined) {
parts.unshift(
...splitQualifiedSegments(name.text).map(stripTemplateSuffix).filter(Boolean),
);
}
}
current = current.parent;
}
return parts.filter(Boolean).join('.');
}
function directChildOfType(node: SyntaxNode, type: string): SyntaxNode | null {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === type) return child;
}
return null;
}
function trailingIdentifier(value: string): string {
return stripTemplateSuffix(splitQualifiedSegments(value).at(-1) ?? '');
}
function qualifiedTypeName(value: string): string {
return splitQualifiedSegments(value).map(stripTemplateSuffix).filter(Boolean).join('.');
}
function splitQualifiedSegments(value: string): string[] {
const parts: string[] = [];
let angleDepth = 0;
let segmentStart = 0;
for (let i = 0; i < value.length; i++) {
const char = value[i];
if (char === '<') angleDepth++;
else if (char === '>' && angleDepth > 0) angleDepth--;
else if (char === ':' && value[i + 1] === ':' && angleDepth === 0) {
const segment = value.slice(segmentStart, i).trim();
if (segment !== '') parts.push(segment);
segmentStart = i + 2;
i++;
}
}
const tail = value.slice(segmentStart).trim();
if (tail !== '') parts.push(tail);
return parts;
}
function stripTemplateSuffix(value: string): string {
const templateStart = value.indexOf('<');
return (templateStart >= 0 ? value.slice(0, templateStart) : value).trim();
}
function simpleName(def: SymbolDefinition): string {
return def.qualifiedName?.split('.').at(-1) ?? '';
}
function definitionQualifiedName(def: SymbolDefinition): string {
const name = def.qualifiedName ?? '';
if (name === '' || def.namespacePrefix === undefined || def.namespacePrefix === '') return name;
return name.startsWith(`${def.namespacePrefix}.`) ? name : `${def.namespacePrefix}.${name}`;
}
function matchingChildren(
filePath: string,
childName: string,
childQualifiedName: string | undefined,
defsByFileAndName: ReadonlyMap<string, readonly SymbolDefinition[]>,
): readonly SymbolDefinition[] {
if (childQualifiedName !== undefined) {
const qualified = defsByFileAndName.get(`${filePath}\0${childQualifiedName}`) ?? [];
if (qualified.length > 0) return qualified;
}
const simple = defsByFileAndName.get(`${filePath}\0${childName}`) ?? [];
return simple.length === 1 ? simple : [];
}
function findCapturedParent(
parentIds: readonly string[],
baseName: string,
baseQualifiedName: string | undefined,
defById: ReadonlyMap<string, SymbolDefinition>,
): SymbolDefinition | undefined {
const candidates = parentIds
.map((id) => defById.get(id))
.filter((definition): definition is SymbolDefinition => definition !== undefined);
if (baseQualifiedName !== undefined) {
const qualified = candidates.filter((definition) => {
const name = definitionQualifiedName(definition);
return name === baseQualifiedName || name.endsWith(`.${baseQualifiedName}`);
});
if (qualified.length === 1) return qualified[0];
return undefined;
}
const simple = candidates.filter((definition) => simpleName(definition) === baseName);
return simple.length === 1 ? simple[0] : undefined;
}
function buildAncestorClosure(
parents: ReadonlyMap<string, readonly string[]>,
): Map<string, ReadonlySet<string>> {
const closure = new Map<string, ReadonlySet<string>>();
const visiting = new Set<string>();
const ancestorsOf = (defId: string): ReadonlySet<string> => {
const cached = closure.get(defId);
if (cached !== undefined) return cached;
if (visiting.has(defId)) return new Set();
visiting.add(defId);
const ancestors = new Set<string>();
for (const parent of parents.get(defId) ?? []) {
ancestors.add(parent);
for (const ancestor of ancestorsOf(parent)) ancestors.add(ancestor);
}
visiting.delete(defId);
closure.set(defId, ancestors);
return ancestors;
};
for (const defId of parents.keys()) ancestorsOf(defId);
return closure;
}
function isAncestor(ancestorDefId: string, descendantDefId: string): boolean {
return ancestorsByDefId.get(descendantDefId)?.has(ancestorDefId) === true;
}

View file

@ -4,7 +4,6 @@ import {
findEnclosingClassDef,
} from '../../scope-resolution/scope/walkers.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import {
populateClassOwnedMembers,
tagNamespacePrefixes,
@ -42,6 +41,11 @@ import {
clearCppUserDefinedConversions,
populateCppUserDefinedConversions,
} from './user-defined-conversions.js';
import {
buildCppMemberLookupMro,
clearCppMemberLookupState,
resolveCppReceiverMember,
} from './member-lookup.js';
/**
* Per-pass memo of the augmented `#include`-resolution file set
@ -104,6 +108,7 @@ export const cppScopeResolver: ScopeResolver = {
clearCppAdlState();
clearCppInlineNamespaces();
clearCppUserDefinedConversions();
clearCppMemberLookupState();
return scanCppHeaderFiles(repoPath);
},
@ -137,8 +142,7 @@ export const cppScopeResolver: ScopeResolver = {
// `'unknown'` keeps the candidate, preserving "degrade not lie".
constraintCompatibility: cppConstraintCompatibility,
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
buildMro: buildCppMemberLookupMro,
// Worker-boundary restore (see `ScopeResolver.applyCaptureSideChannel`).
// `emitCppScopeCaptures` records per-file ADL call-site arg shapes
@ -261,6 +265,7 @@ export const cppScopeResolver: ScopeResolver = {
hoistTypeBindingsToModule: true,
// Enable receiver-bound explicit-`this` fallback only for C++.
resolveThisViaEnclosingClass: true,
resolveReceiverMember: resolveCppReceiverMember,
// The `isFileLocalDef` hook on the global free-call fallback names
// file-local linkage historically, but semantically gates "logically
// invisible cross-file" defs. C++ extends this to also reject class-

View file

@ -267,6 +267,7 @@ import type {
Callsite,
ConstraintContext,
ParsedFile,
ReferenceSite,
ScopeId,
SupportedLanguages,
SymbolDefinition,
@ -291,6 +292,10 @@ export type LinearizeStrategy = (
/** Result of `ScopeResolver.arityCompatibility` — mirrors `RegistryProviders.arityCompatibility`. */
export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible';
export type ReceiverMemberResolution =
| { readonly kind: 'resolved'; readonly definition: SymbolDefinition }
| { readonly kind: 'ambiguous'; readonly candidateIds: readonly string[] };
/** Re-exported for ScopeResolver consumers same shape as
* `RegistryProviders.constraintCompatibility`'s third parameter. */
export type { ConstraintContext } from 'gitnexus-shared';
@ -407,7 +412,7 @@ export interface ScopeResolver {
* for the Tier-A predicate registry and Kleene 3-valued evaluator.
*/
readonly constraintCompatibility?: (
callsite: Callsite,
callsite: ReferenceSite,
def: SymbolDefinition,
ctx: ConstraintContext,
) => ArityVerdict;
@ -834,6 +839,21 @@ export interface ScopeResolver {
callsite?: Callsite,
) => SymbolDefinition | 'ambiguous' | undefined;
/**
* Optional language-specific member-lattice lookup. Runs for a resolved
* simple receiver type before the generic flattened-MRO walk. Languages
* with lookup-set semantics that cannot be represented by one linear MRO
* may resolve a member, report ambiguity (which suppresses fallback), or
* return undefined to retain the shared behavior.
*/
readonly resolveReceiverMember?: (
ownerDef: SymbolDefinition,
memberName: string,
callsite: Callsite,
scopes: ScopeResolutionIndexes,
model: SemanticModel,
) => ReceiverMemberResolution | undefined;
/**
* Enable the receiver-bound Case 0.5 fallback for explicit `this`
* receivers (`this->m()` / `this.m()`) that resolves against the

View file

@ -82,6 +82,7 @@ type ReceiverBoundProviderSubset = Pick<
| 'unwrapCollectionAccessor'
| 'hoistTypeBindingsToModule'
| 'resolveQualifiedReceiverMember'
| 'resolveReceiverMember'
| 'resolveThisViaEnclosingClass'
| 'conversionRankFn'
| 'constraintCompatibility'
@ -375,6 +376,51 @@ export function emitReceiverBoundCalls(
if (provider.resolveThisViaEnclosingClass === true && receiverName === 'this') {
const enclosingClass = findEnclosingClassDef(site.inScope, scopes);
if (enclosingClass !== undefined) {
const languageResolution = provider.resolveReceiverMember?.(
enclosingClass,
memberName,
site,
scopes,
model,
);
if (languageResolution?.kind === 'ambiguous') {
options.recordResolutionOutcome?.({
kind: 'suppressed',
phase: 'receiver-bound-calls',
filePath: parsed.filePath,
name: site.name,
range: site.atRange,
reason: 'member-lookup-ambiguous',
candidateIds: languageResolution.candidateIds,
});
handledSites.add(siteKey);
continue;
}
if (languageResolution?.kind === 'resolved') {
const memberDef = languageResolution.definition;
const reason =
site.kind === 'write' || site.kind === 'read'
? site.kind
: memberDef.filePath !== parsed.filePath
? 'import-resolved'
: 'global';
const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85;
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
memberDef,
reason,
seen,
confidence,
collapse,
);
if (ok) emitted++;
handledSites.add(siteKey);
continue;
}
const chain = [
enclosingClass.nodeId,
...scopes.methodDispatch.mroFor(enclosingClass.nodeId),
@ -722,6 +768,51 @@ export function emitReceiverBoundCalls(
);
}
if (ownerDef !== undefined) {
const languageResolution = provider.resolveReceiverMember?.(
ownerDef,
memberName,
site,
scopes,
model,
);
if (languageResolution?.kind === 'ambiguous') {
options.recordResolutionOutcome?.({
kind: 'suppressed',
phase: 'receiver-bound-calls',
filePath: parsed.filePath,
name: site.name,
range: site.atRange,
reason: 'member-lookup-ambiguous',
candidateIds: languageResolution.candidateIds,
});
handledSites.add(siteKey);
continue;
}
if (languageResolution?.kind === 'resolved') {
const memberDef = languageResolution.definition;
const reason =
site.kind === 'write' || site.kind === 'read'
? site.kind
: memberDef.filePath !== parsed.filePath
? 'import-resolved'
: 'global';
const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85;
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
memberDef,
reason,
seen,
confidence,
collapse,
);
if (ok) emitted++;
handledSites.add(siteKey);
continue;
}
const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
let memberDef: SymbolDefinition | undefined;
let ambiguous = false;

View file

@ -4,6 +4,7 @@ export type ResolutionSuppressionReason =
| 'adl-ordinary-lookup-blocked'
| 'conversion-rank-tied'
| 'inline-ns-ambiguous'
| 'member-lookup-ambiguous'
| 'overload-ambiguous'
| 'overload-ambiguous-normalization';

View file

@ -0,0 +1,5 @@
#pragma once
struct CrossFileBase {
void crossFile();
};

View file

@ -0,0 +1,142 @@
#include "base.h"
struct Left {
void collide();
};
struct Right {
void collide();
};
struct Ambiguous : Left, Right {
void callThis();
};
void ambiguousCall() {
Ambiguous value;
value.collide();
}
void Ambiguous::callThis() {
this->collide();
}
struct Dominant : Left, Right {
void collide();
};
void dominantCall() {
Dominant value;
value.collide();
}
struct Root {
void shared();
};
struct VirtualLeft : virtual Root {};
struct VirtualRight : virtual Root {};
struct VirtualDiamond : VirtualLeft, VirtualRight {};
void virtualDiamondCall() {
VirtualDiamond value;
value.shared();
}
struct PlainLeft : Root {};
struct PlainRight : Root {};
struct PlainDiamond : PlainLeft, PlainRight {};
void plainDiamondCall() {
PlainDiamond value;
value.shared();
}
struct Base {
void select(int);
};
struct Derived : Base {
using Base::select;
void select(double);
};
void usingCall() {
Derived value;
value.select(1);
}
struct OverrideRoot {
void overrideMember();
};
struct OverrideLeft : OverrideRoot {
void overrideMember();
};
struct OverrideRight : OverrideRoot {};
struct OverrideDiamond : OverrideLeft, OverrideRight {};
void nonVirtualOverrideCall() {
OverrideDiamond value;
value.overrideMember();
}
struct UsingRoot {
void inheritedUsing(int);
};
struct UsingMiddle : UsingRoot {
using UsingRoot::inheritedUsing;
void inheritedUsing(double);
};
struct UsingLeaf : UsingMiddle {};
void inheritedUsingCall() {
UsingLeaf value;
value.inheritedUsing(1);
}
namespace alpha {
struct SameNameBase {
void qualified(int);
};
}
namespace beta {
struct SameNameBase {
void qualified(double);
};
}
struct QualifiedBases : alpha::SameNameBase, beta::SameNameBase {
using alpha::SameNameBase::qualified;
};
void qualifiedUsingCall() {
QualifiedBases value;
value.qualified(1);
}
template <typename T>
struct TemplatedOuter {
template <typename U>
struct NestedBase {
void nestedTemplate();
};
};
struct TemplatedDerived : TemplatedOuter<int>::NestedBase<double> {};
void nestedTemplateCall() {
TemplatedDerived value;
value.nestedTemplate();
}
struct CrossFileDerived : CrossFileBase {};
void crossFileCall() {
CrossFileDerived value;
value.crossFile();
}

View file

@ -1851,6 +1851,109 @@ describe('C++ Derived : A, B — diamond inheritance via leftmost-base MRO (SM-1
});
});
describe('C++ inheritance-lattice member lookup (#1891)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-member-lattice'), () => {});
}, 60000);
it('suppresses same-name members inherited from unrelated bases', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'ambiguousCall' && call.target === 'collide',
);
expect(calls).toHaveLength(0);
});
it('lets a derived declaration hide both base declarations', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'dominantCall' && call.target === 'collide',
);
expect(calls).toHaveLength(1);
expect(calls[0]?.targetFilePath).toBe('main.cpp');
});
it('merges a shared virtual base into one member subobject', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'virtualDiamondCall' && call.target === 'shared',
);
expect(calls).toHaveLength(1);
});
it('suppresses the same declaration reached through two non-virtual base subobjects', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'plainDiamondCall' && call.target === 'shared',
);
expect(calls).toHaveLength(0);
});
it('adds a member using-declaration to the derived overload set', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'usingCall' && call.target === 'select',
);
expect(calls).toHaveLength(1);
const target = result.graph.getNode(calls[0]!.rel.targetId);
expect(target?.properties.parameterTypes).toEqual(['int']);
});
it('records both conservative ambiguity suppressions', () => {
const outcomes = getResolutionOutcomes(result).filter(
(outcome) => outcome.kind === 'suppressed' && outcome.reason === 'member-lookup-ambiguous',
);
const names = outcomes.map((outcome) => outcome.name);
expect(names).toContain('collide');
expect(names).toContain('overrideMember');
expect(names).toContain('shared');
});
it('keeps sibling non-virtual subobjects ambiguous when one branch overrides the member', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'nonVirtualOverrideCall' && call.target === 'overrideMember',
);
expect(calls).toHaveLength(0);
});
it('merges inherited using-declarations with methods declared by the same intermediate class', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'inheritedUsingCall' && call.target === 'inheritedUsing',
);
expect(calls).toHaveLength(1);
const target = result.graph.getNode(calls[0]!.rel.targetId);
expect(target?.properties.parameterTypes).toEqual(['int']);
});
it('uses qualified base identities when same-simple-name direct bases collide', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'qualifiedUsingCall' && call.target === 'qualified',
);
expect(calls).toHaveLength(1);
const target = result.graph.getNode(calls[0]!.rel.targetId);
expect(target?.properties.parameterTypes).toEqual(['int']);
});
it('normalizes every segment of a nested templated base name', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'nestedTemplateCall' && call.target === 'nestedTemplate',
);
expect(calls).toHaveLength(1);
});
it('applies lattice ambiguity suppression to explicit this receivers', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'callThis' && call.target === 'collide',
);
expect(calls).toHaveLength(0);
});
it('resolves inherited members across files', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'crossFileCall' && call.target === 'crossFile',
);
expect(calls).toHaveLength(1);
expect(calls[0]?.targetFilePath).toBe('base.h');
});
});
// ---------------------------------------------------------------------------
// U1: `#include` must not leak class-owned methods as unqualified bindings
// ---------------------------------------------------------------------------

View file

@ -14,9 +14,14 @@ import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helper
function parseNode(src: string, type: string): SyntaxNode | null {
const tree = getCppParser().parse(src);
for (let i = 0; i < tree.rootNode.namedChildCount; i++) {
const child = tree.rootNode.namedChild(i);
if (child?.type === type) return child as SyntaxNode;
const stack: SyntaxNode[] = [tree.rootNode as SyntaxNode];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.type === type) return node;
for (let i = node.namedChildCount - 1; i >= 0; i--) {
const child = node.namedChild(i);
if (child !== null) stack.push(child as SyntaxNode);
}
}
return null;
}
@ -59,6 +64,12 @@ describe('C++ include decomposition (splitCppInclude)', () => {
// ── using declaration decomposition ─────────────────────────────────────────
describe('C++ using declaration decomposition (splitCppUsingDecl)', () => {
it('does not treat a class-scope member using-declaration as an import', () => {
const node = parseNode('struct Derived : Base { using Base::run; };', 'using_declaration');
expect(node).not.toBeNull();
expect(splitCppUsingDecl(node!)).toBeNull();
});
it('decomposes "using namespace std;" as wildcard import', () => {
const node = parseNode('using namespace std;', 'using_declaration');
expect(node).not.toBeNull();

View file

@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
applyCppMemberLookupSideChannel,
clearCppMemberLookupState,
collectCppMemberLookupSideChannel,
type CppMemberLookupSideChannel,
} from '../../../../src/core/ingestion/languages/cpp/member-lookup.js';
describe('C++ member-lookup capture side-channel', () => {
beforeEach(() => {
clearCppMemberLookupState();
});
it('preserves qualified base identities through a worker-style JSON round trip', () => {
const snapshot: CppMemberLookupSideChannel = {
baseEdges: [
{
childName: 'Derived',
childQualifiedName: 'app.Derived',
baseName: 'Base',
baseQualifiedName: 'detail.Base',
isVirtual: true,
},
],
memberUsings: [
{
childName: 'Derived',
childQualifiedName: 'app.Derived',
baseName: 'Base',
baseQualifiedName: 'detail.Base',
memberName: 'select',
},
],
};
const throughWorker = JSON.parse(JSON.stringify(snapshot)) as CppMemberLookupSideChannel;
applyCppMemberLookupSideChannel('main.cpp', throughWorker);
expect(collectCppMemberLookupSideChannel('main.cpp')).toEqual(snapshot);
});
});