diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 79c504677d..bb0a3f5f42 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -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.", diff --git a/gitnexus/src/core/ingestion/languages/cpp/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/cpp/capture-side-channel.ts index 74f432b69f..ad9766d46f 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/capture-side-channel.ts @@ -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); + } } diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 265db8d5d1..883f571c58 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -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; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts index ae6843923d..4b08205893 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/languages/cpp/member-lookup.ts b/gitnexus/src/core/ingestion/languages/cpp/member-lookup.ts new file mode 100644 index 0000000000..a681c49528 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/member-lookup.ts @@ -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(); +let directParentsByDefId = new Map(); +let virtualEdges = new Set(); +let ancestorsByDefId = new Map>(); +let memberUsingsByDefId = new Map< + string, + readonly { readonly baseDefId: string; readonly memberName: string }[] +>(); +let inheritedLookupCache = new Map(); + +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 { + 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, + 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(); + 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(); + const defById = new Map(); + const defsByFileAndName = new Map(); + + 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(); + 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(); + 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, +): 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, +): 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, +): Map> { + const closure = new Map>(); + const visiting = new Set(); + + const ancestorsOf = (defId: string): ReadonlySet => { + const cached = closure.get(defId); + if (cached !== undefined) return cached; + if (visiting.has(defId)) return new Set(); + visiting.add(defId); + const ancestors = new Set(); + 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; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 5e24e292d7..3ef89bc07c 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -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- 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 1c85f23834..fc17ac4147 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index d5aa2f7886..1b74297086 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts index 4eabfa29bb..b46b47f722 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts @@ -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'; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-member-lattice/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-member-lattice/base.h new file mode 100644 index 0000000000..b25a94a88b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-member-lattice/base.h @@ -0,0 +1,5 @@ +#pragma once + +struct CrossFileBase { + void crossFile(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-member-lattice/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-member-lattice/main.cpp new file mode 100644 index 0000000000..85479b94ec --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-member-lattice/main.cpp @@ -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 +struct TemplatedOuter { + template + struct NestedBase { + void nestedTemplate(); + }; +}; + +struct TemplatedDerived : TemplatedOuter::NestedBase {}; + +void nestedTemplateCall() { + TemplatedDerived value; + value.nestedTemplate(); +} + +struct CrossFileDerived : CrossFileBase {}; + +void crossFileCall() { + CrossFileDerived value; + value.crossFile(); +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index bfae951e30..9b6a98bcb5 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts index 6bc6e1b86f..3f2761d500 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts @@ -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(); diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-member-lookup-side-channel.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-member-lookup-side-channel.test.ts new file mode 100644 index 0000000000..102ce517ad --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-member-lookup-side-channel.test.ts @@ -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); + }); +});