Merge branch 'main' into feat/wiki

This commit is contained in:
Gergő Magyar 2026-05-15 19:59:50 +01:00 committed by GitHub
commit 0d7b288ff9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 142 additions and 19 deletions

View file

@ -29,6 +29,10 @@
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import {
isOverloadAmbiguousAfterNormalization,
narrowOverloadCandidates,
} from '../../scope-resolution/passes/overload-narrowing.js';
interface RangeKey {
readonly startLine: number;
@ -95,15 +99,17 @@ export function isCppInlineNamespaceScope(scopeId: ScopeId): boolean {
* Returns the most specific (innermost) match for `outer::foo()`
* where `inline namespace v1` declares `foo`, returns `v1::foo`. When
* multiple inline-namespace children declare the same name, ISO C++
* leaves the call ambiguous; V1 returns the first match in source
* order (stable across runs).
* leaves the call ambiguous; returns `'ambiguous'` so the caller
* suppresses edge emission rather than picking arbitrarily (#1564).
*/
export function resolveCppQualifiedNamespaceMember(
receiverName: string,
memberName: string,
parsedFiles: readonly ParsedFile[],
_scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
): SymbolDefinition | 'ambiguous' | undefined {
const allHits: SymbolDefinition[] = [];
const seenNodeId = new Set<string>();
for (const parsed of parsedFiles) {
const scopesById = new Map<ScopeId, (typeof parsed.scopes)[number]>();
for (const sc of parsed.scopes) scopesById.set(sc.id, sc);
@ -113,19 +119,45 @@ export function resolveCppQualifiedNamespaceMember(
if (nsDef === undefined) continue;
const nsName = nsDef.qualifiedName?.split('.').pop() ?? nsDef.qualifiedName ?? '';
if (nsName !== receiverName) continue;
// Found a matching namespace scope in this file. Collect the
// member transitively through any inline-namespace children.
const hit = findMemberInNamespaceTransitive(scope, scopesById, memberName);
if (hit !== undefined) return hit;
// Found a matching namespace scope in this file. Collect ALL
// members transitively through any inline-namespace children.
const hits = findMemberInNamespaceTransitive(scope, scopesById, memberName);
for (const hit of hits) {
if (seenNodeId.has(hit.nodeId)) continue;
seenNodeId.add(hit.nodeId);
allHits.push(hit);
}
}
}
return undefined;
if (allHits.length === 0) return undefined;
if (allHits.length === 1) return allHits[0];
// Multi-candidate: the `resolveQualifiedReceiverMember` hook has no
// access to call-site arity or argument types, so
// `narrowOverloadCandidates` cannot actually narrow here — the call
// with `(allHits, undefined, undefined)` is effectively a pass-through.
// We retain it so that `isOverloadAmbiguousAfterNormalization` can
// still detect int/long-style normalization collisions on this path,
// but for any multi-hit case where candidates have genuinely distinct
// signatures (e.g. `foo(int)` vs `foo(double)` in different inline
// children), we conservatively suppress rather than pick arbitrarily.
// A future enhancement could thread call-site argument info through
// the `resolveQualifiedReceiverMember` contract to enable real
// narrowing here.
const narrowed = narrowOverloadCandidates(allHits, undefined, undefined);
if (narrowed.length === 1) return narrowed[0];
if (narrowed.length === 0) return undefined;
if (isOverloadAmbiguousAfterNormalization(narrowed, undefined)) return 'ambiguous';
// Multiple surviving candidates (distinct signatures) — conservative
// suppress because we lack call-site info to disambiguate.
return 'ambiguous';
}
/** Recursively search a namespace scope and any inline-namespace
* descendants for a callable def with the given simple name. Non-inline
* descendants for callable defs with the given simple name. Non-inline
* nested namespaces are NOT traversed they require explicit
* qualification (`outer::nested::foo`). */
* qualification (`outer::nested::foo`). Returns ALL matches so the
* caller can detect same-name ambiguity across inline children (#1564). */
function findMemberInNamespaceTransitive(
scope: {
readonly id: ScopeId;
@ -142,22 +174,23 @@ function findMemberInNamespaceTransitive(
}
>,
memberName: string,
): SymbolDefinition | undefined {
): SymbolDefinition[] {
const results: SymbolDefinition[] = [];
// Check this scope's own ownedDefs first.
for (const def of scope.ownedDefs) {
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue;
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
if (simple === memberName) return def;
if (simple === memberName) results.push(def);
}
// Descend into inline-namespace children.
for (const childScope of scopesById.values()) {
if (childScope.parent !== scope.id) continue;
if (childScope.kind !== 'Namespace') continue;
if (!inlineNamespaceScopeIds.has(childScope.id)) continue;
const hit = findMemberInNamespaceTransitive(childScope, scopesById, memberName);
if (hit !== undefined) return hit;
const childHits = findMemberInNamespaceTransitive(childScope, scopesById, memberName);
for (const hit of childHits) results.push(hit);
}
return undefined;
return results;
}
function findNamespaceDefInScope(scope: {

View file

@ -235,7 +235,7 @@ export const cppScopeResolver: ScopeResolver = {
parsedFiles,
scopes,
);
if (member === undefined) continue;
if (member === undefined || member === 'ambiguous') continue;
if (seenUsing.has(member.nodeId)) continue;
seenUsing.add(member.nodeId);
usingNamedHits.push(member);

View file

@ -615,8 +615,9 @@ export interface ScopeResolver {
*
* Receiver-bound-calls invokes this hook AFTER Case 1 (namespace
* imports) and AFTER Case 2 (class-name receiver) fail to resolve.
* Returns the target def, or `undefined` to fall through to the
* remaining cases.
* Returns the target def, `'ambiguous'` when multiple inline-namespace
* children declare the same name (suppresses edge emission), or
* `undefined` to fall through to the remaining cases.
*/
readonly resolveQualifiedReceiverMember?: (
receiverName: string,
@ -624,7 +625,7 @@ export interface ScopeResolver {
callerScope: ScopeId,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
) => SymbolDefinition | undefined;
) => SymbolDefinition | 'ambiguous' | undefined;
/**
* Enable the receiver-bound Case 0.5 fallback for explicit `this`

View file

@ -445,6 +445,12 @@ export function emitReceiverBoundCalls(
scopes,
parsedFiles,
);
if (memberDef === 'ambiguous') {
// Same-name ambiguity across inline-namespace children (#1564):
// suppress edge emission, mark site handled.
handledSites.add(siteKey);
continue;
}
if (memberDef !== undefined) {
const ok = tryEmitEdge(
graph,

View file

@ -0,0 +1,5 @@
#include "lib.h"
void run() {
outer::foo(42);
}

View file

@ -0,0 +1,10 @@
#pragma once
namespace outer {
inline namespace v1 {
void foo(int x);
}
inline namespace v2 {
void foo(double y);
}
}

View file

@ -0,0 +1,5 @@
#include "lib.h"
void run() {
outer::foo();
}

View file

@ -0,0 +1,10 @@
#pragma once
namespace outer {
inline namespace v1 {
void foo();
}
inline namespace v2 {
void foo();
}
}

View file

@ -2776,6 +2776,47 @@ describe('C++ inline namespace — versioned (v1 inline, v0 not)', () => {
});
});
describe('C++ inline namespace — ambiguous same-name across inline children (#1564)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-inline-namespace-ambiguous'),
() => {},
);
}, 60000);
it('outer::foo() emits zero CALLS edges when v1 and v2 both declare foo', () => {
const calls = getRelationships(result, 'CALLS');
const fooCalls = calls.filter((c) => c.source === 'run' && c.target === 'foo');
// ISO C++ leaves this ambiguous — both inline namespace children declare
// the same name. The resolver must suppress rather than pick arbitrarily.
expect(fooCalls.length).toBe(0);
});
});
describe('C++ inline namespace — ambiguous distinct signatures (conservative suppress)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-inline-namespace-ambiguous-diff-sigs'),
() => {},
);
}, 60000);
it('outer::foo(42) emits zero CALLS edges when v1 declares foo(int) and v2 declares foo(double)', () => {
const calls = getRelationships(result, 'CALLS');
const fooCalls = calls.filter((c) => c.source === 'run' && c.target === 'foo');
// Even though the two overloads have distinct signatures and a compiler
// could disambiguate via argument types, the `resolveQualifiedReceiverMember`
// hook lacks call-site arity/argument-type information, so multi-hit cases
// are conservatively suppressed. Documents the limitation noted in
// inline-namespaces.ts (Finding 1 of Claude review on #1600).
expect(fooCalls.length).toBe(0);
});
});
describe('C++ inline namespace — nested (STL __1-style)', () => {
let result: PipelineResult;

View file

@ -175,6 +175,18 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
'Derived<T>::g_unqualified() -> f() does NOT bind to Base<T>::f',
'Derived<T>::g_this() -> this->f() resolves to Base<T>::f (1 edge)',
'Derived<T>::g() -> this->f() emits zero CALLS edges when only hidden derived overload is arity-incompatible',
// The legacy DAG path has no inline-namespace same-name ambiguity
// detection. When two inline children declare the same name, the
// legacy path picks an arbitrary match. The scope-resolver returns
// 'ambiguous' and suppresses edge emission. Scope-resolver-only
// correctness win (#1564); backporting to legacy is out of scope.
'outer::foo() emits zero CALLS edges when v1 and v2 both declare foo',
// Distinct-signature inline-namespace ambiguity: `foo(int)` in v1 and
// `foo(double)` in v2. The scope-resolver conservatively suppresses
// because `resolveQualifiedReceiverMember` lacks call-site argument
// types. Legacy DAG has no inline-namespace resolver. Scope-resolver-
// only correctness win (#1600 / Claude review Finding 1).
'outer::foo(42) emits zero CALLS edges when v1 declares foo(int) and v2 declares foo(double)',
// PR #1598: ADL free-function reference arg negative fixtures rely on
// scope-resolver-only correctness. The legacy DAG falls back to
// `pickUniqueGlobalCallable` which resolves the callee by simple-name