Merge branch 'main' into feat/Desktop-app

This commit is contained in:
Sparsh 2026-05-15 12:43:55 +05:30 • committed by GitHub
commit 737cdaf692
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 337 additions and 28 deletions

View file

@ -21,8 +21,9 @@
* - `audit::Event& r`, `audit::Event&& rr`
* - `std::vector<audit::Event>` (template namespace + template-arg namespaces)
*
* Function-pointer arguments, base-class associated namespaces, and the rest
* of the full closure are still deliberately excluded.
* Function-pointer arguments and the rest of the full closure are still
* deliberately excluded. V2 additionally walks class ancestors (via MRO),
* so base-class enclosing namespaces also contribute associated namespaces.
*
* The current implementation also short-circuits to ADL only when ordinary lookup is empty
* (`findCallableBindingInScope` returned undefined). ISO C++ would
@ -259,10 +260,19 @@ function addAssociatedNamespaceForClassName(
associatedNamespaces: Set<string>,
): void {
if (simpleClassName.length === 0) return;
const classDef = findCppClassDefBySimpleName(simpleClassName, scopes);
if (classDef === undefined) return;
const classLookup = findCppClassDefBySimpleName(simpleClassName, scopes);
if (classLookup === undefined) return;
const { classDef, ambiguous } = classLookup;
const nsQName = classToNamespaceQualifiedName.get(classDef.nodeId);
if (nsQName !== undefined) associatedNamespaces.add(nsQName);
// Preserve V1 collision behavior for the direct class namespace, but avoid
// amplifying a same-simple-name collision by walking an arbitrary class's
// full MRO chain.
if (ambiguous) return;
for (const ancestorDefId of scopes.methodDispatch.mroFor(classDef.nodeId)) {
const ancestorNsQName = classToNamespaceQualifiedName.get(ancestorDefId);
if (ancestorNsQName !== undefined) associatedNamespaces.add(ancestorNsQName);
}
}
/** Walk upward from a Class scope, finding the innermost enclosing
@ -357,17 +367,25 @@ function findNamespaceDefInScope(scope: {
}
/** Find a class-like def by simple name across the workspace. V1
* arbitrary-pick on collisions (multiple classes share the simple name);
* C++ ADL strictness would require full type-driven lookup, but V1
* trades that for simplicity. */
* still arbitrary-picks the first class on collisions (multiple classes
* share the simple name), but reports the collision so callers can avoid
* amplifying that uncertainty (for example by skipping MRO expansion).
* C++ ADL strictness would require full type-driven lookup. */
function findCppClassDefBySimpleName(
simpleName: string,
scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
): { classDef: SymbolDefinition; ambiguous: boolean } | undefined {
let firstMatch: SymbolDefinition | undefined;
for (const def of scopes.defs.byId.values()) {
if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
if (simple === simpleName) return def;
if (simple !== simpleName) continue;
if (firstMatch === undefined) {
firstMatch = def;
continue;
}
return { classDef: firstMatch, ambiguous: true };
}
return undefined;
if (firstMatch === undefined) return undefined;
return { classDef: firstMatch, ambiguous: false };
}

View file

@ -10,7 +10,7 @@ import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js';
import { computeCppDeclarationArity, computeCppCallArity } from './arity-metadata.js';
import { markFileLocal } from './file-local-linkage.js';
import { markCppAnonymousNamespaceRange, markFileLocal } from './file-local-linkage.js';
import { markCppDependentBase } from './two-phase-lookup.js';
import { markCppAdlSiteArgs, markCppAdlSiteNoAdl, type CppAdlArgInfo } from './adl.js';
import { markCppInlineNamespaceRange } from './inline-namespaces.js';
@ -200,20 +200,37 @@ export function emitCppScopeCaptures(
// namespace's source range so `populateCppInlineNamespaceScopes`
// (during populateOwners) can match it back to the corresponding
// Namespace scope.
if (grouped['@declaration.namespace'] !== undefined) {
const anchor = grouped['@declaration.namespace']!;
const nsNode = findNodeAtRange(tree.rootNode, anchor.range, 'namespace_definition');
if (nsNode !== null && isInlineNamespace(nsNode)) {
// `@declaration.namespace` fires only for NAMED namespaces (the query
// requires a `name: (namespace_identifier)` child). Use the unconditional
// `@scope.namespace` capture so the anonymous-namespace branch also runs.
const namespaceScopeAnchor = grouped['@declaration.namespace'] ?? grouped['@scope.namespace'];
if (namespaceScopeAnchor !== undefined) {
const nsNode = findNodeAtRange(
tree.rootNode,
namespaceScopeAnchor.range,
'namespace_definition',
);
if (nsNode !== null) {
// Range coords stored in the shared Range shape use 1-based
// line numbers (see `ast-helpers.ts` rangeForNode where
// `startPosition.row + 1` is applied). Match that convention so
// `populateCppInlineNamespaceScopes` can join against `Scope.range`.
markCppInlineNamespaceRange(filePath, {
// the populators can join against `Scope.range`.
const nsRange = {
startLine: nsNode.startPosition.row + 1,
startCol: nsNode.startPosition.column,
endLine: nsNode.endPosition.row + 1,
endCol: nsNode.endPosition.column,
});
};
if (isInlineNamespace(nsNode)) {
markCppInlineNamespaceRange(filePath, nsRange);
}
// Anonymous namespace: `namespace_definition` with no `name` field.
// Recorded so `expandCppWildcardNames` can propagate its members
// to including TUs even though their names are also `markFileLocal`'d
// (which blocks the global free-call fallback's cross-file path).
if ((nsNode.childForFieldName?.('name') ?? null) === null) {
markCppAnonymousNamespaceRange(filePath, nsRange);
}
}
}

View file

@ -35,6 +35,33 @@ const fileLocalNames = new Map<string, Set<string>>();
*/
const nonGloballyVisibleNodeIds = new Map<string, Set<string>>();
/**
* Per-file set of source-range keys identifying `namespace { ... }` blocks.
* Resolved to `ScopeId`s in `populateCppAnonymousNamespaceScopes` and
* consumed via `isCppAnonymousNamespaceScope`.
*
* Anonymous namespaces have file-local linkage but, unlike `static`, their
* members propagate to any TU that `#include`s the declaring file — each
* including TU gets its own internal-linkage copy. So for wildcard import
* expansion (`expandCppWildcardNames`) we treat anonymous-namespace owned
* defs as if declared at the enclosing scope. Cross-file unqualified
* lookup that does NOT go through `#include` is still blocked by the
* `isFileLocal` mark recorded on the def's name.
*/
const anonymousNamespaceRangesByFile = new Map<string, Set<string>>();
const anonymousNamespaceScopeIds = new Set<ScopeId>();
interface RangeKeyShape {
readonly startLine: number;
readonly startCol: number;
readonly endLine: number;
readonly endCol: number;
}
function rangeKey(r: RangeKeyShape): string {
return `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`;
}
/** Record a symbol name as file-local (static or anonymous namespace). */
export function markFileLocal(filePath: string, name: string): void {
let names = fileLocalNames.get(filePath);
@ -50,10 +77,51 @@ export function isFileLocal(filePath: string, name: string): boolean {
return fileLocalNames.get(filePath)?.has(name) ?? false;
}
/** Capture-time: record an anonymous `namespace_definition` source range. */
export function markCppAnonymousNamespaceRange(filePath: string, range: RangeKeyShape): void {
let set = anonymousNamespaceRangesByFile.get(filePath);
if (set === undefined) {
set = new Set();
anonymousNamespaceRangesByFile.set(filePath, set);
}
set.add(rangeKey(range));
}
/** Predicate consumed by `populateCppNonGloballyVisible` and
* `expandCppWildcardNames` to exempt anonymous-namespace scopes from
* the cross-file unqualified-lookup exclusion that applies to ordinary
* named namespaces. */
export function isCppAnonymousNamespaceScope(scopeId: ScopeId): boolean {
return anonymousNamespaceScopeIds.has(scopeId);
}
/** Clear tracked file-local names (call at start of each resolution pass). */
export function clearFileLocalNames(): void {
fileLocalNames.clear();
nonGloballyVisibleNodeIds.clear();
anonymousNamespaceRangesByFile.clear();
anonymousNamespaceScopeIds.clear();
}
/** Resolve recorded anonymous-namespace source ranges to `ScopeId`s.
* Must run inside `populateOwners` BEFORE `populateCppNonGloballyVisible`
* consults the resolved set. */
export function populateCppAnonymousNamespaceScopes(parsed: {
readonly filePath: string;
readonly scopes: readonly {
readonly id: ScopeId;
readonly kind: string;
readonly range: RangeKeyShape;
}[];
}): void {
const ranges = anonymousNamespaceRangesByFile.get(parsed.filePath);
if (ranges === undefined || ranges.size === 0) return;
for (const scope of parsed.scopes) {
if (scope.kind !== 'Namespace') continue;
if (ranges.has(rangeKey(scope.range))) {
anonymousNamespaceScopeIds.add(scope.id);
}
}
}
/**
@ -87,6 +155,13 @@ export function populateCppNonGloballyVisible(parsed: {
// ISO C++ `[namespace.def]/p4`. Skip them here so cross-file
// unqualified lookup can still see their callable defs.
if (scope.kind === 'Namespace' && isCppInlineNamespaceScope(scope.id)) continue;
// Anonymous namespaces give internal linkage but their contents are
// visible at the enclosing scope within the same TU and propagate to
// any TU that `#include`s the declaring file. The `isFileLocal` mark
// (recorded on the def's name in this file) still blocks cross-file
// unqualified lookup that does not go through #include, so dropping
// the structural visibility exclusion here is safe.
if (scope.kind === 'Namespace' && anonymousNamespaceScopeIds.has(scope.id)) continue;
for (const def of scope.ownedDefs) {
set.add(def.nodeId);
}
@ -191,9 +266,18 @@ export function expandCppWildcardNames(
// including TU. When the owning scope is unknown we default to
// include (preserves prior behavior for any def whose structural
// ownership wasn't recorded in `Scope.ownedDefs`).
//
// Anonymous namespaces are exempt: their members propagate to the
// enclosing scope of any TU that #includes the declaring file (each
// including TU gets its own internal-linkage copy per ISO C++).
const ownerScope = ownerScopeByNodeId.get(def.nodeId);
const ownerIsAnonymousNamespace =
ownerScope !== undefined &&
ownerScope.kind === 'Namespace' &&
anonymousNamespaceScopeIds.has(ownerScope.id);
if (
ownerScope !== undefined &&
!ownerIsAnonymousNamespace &&
(ownerScope.kind === 'Namespace' || ownerScope.kind === 'Class')
) {
continue;
@ -201,7 +285,11 @@ export function expandCppWildcardNames(
const name = simpleName(def);
if (name === '') continue;
if (isFileLocal(target.filePath, name)) continue;
// Same exemption for the `isFileLocal` mark — anonymous-namespace
// names are recorded as file-local to suppress the global free-call
// fallback's cross-file leak, but they MUST still propagate through
// wildcard import expansion to including TUs.
if (!ownerIsAnonymousNamespace && isFileLocal(target.filePath, name)) continue;
if (seen.has(name)) continue;
seen.add(name);
names.push(name);

View file

@ -16,6 +16,7 @@ import {
expandCppWildcardNames,
isFileLocal,
clearFileLocalNames,
populateCppAnonymousNamespaceScopes,
populateCppNonGloballyVisible,
isCppDefGloballyVisible,
} from './file-local-linkage.js';
@ -97,10 +98,11 @@ export const cppScopeResolver: ScopeResolver = {
populateOwners: (parsed: ParsedFile) => {
populateClassOwnedMembers(parsed);
// Resolve inline-namespace ranges (recorded at capture time) to
// ScopeIds BEFORE `populateCppNonGloballyVisible` runs, so the
// inline-namespace exemption sees the populated Set.
// Resolve inline- and anonymous-namespace ranges (recorded at capture
// time) to ScopeIds BEFORE `populateCppNonGloballyVisible` runs, so
// both exemptions see the populated Sets.
populateCppInlineNamespaceScopes(parsed);
populateCppAnonymousNamespaceScopes(parsed);
// Track namespace-nested and class-nested defs so the global free-call
// fallback and wildcard expansion can suppress them as unqualified
// cross-file callables.

View file

@ -0,0 +1,14 @@
#include "base_lib.h"
namespace app {
struct Token : base_one::Base {};
void run() {
Token t;
collide(t);
}
}
namespace other {
struct Token : base_two::Base {};
}

View file

@ -0,0 +1,11 @@
#pragma once
namespace base_one {
struct Base {};
void collide(Base);
}
namespace base_two {
struct Base {};
void collide(Base);
}

View file

@ -0,0 +1,16 @@
#include "base_lib.h"
namespace app {
struct HiddenDerived : HiddenBase {};
struct MissingDerived : missing_ns::UnknownBase {};
void run_hidden() {
HiddenDerived d;
hidden_probe(d);
}
void run_missing() {
MissingDerived d;
unresolved_probe(d);
}
}

View file

@ -0,0 +1,6 @@
#pragma once
namespace {
struct HiddenBase {};
void hidden_probe(HiddenBase);
}

View file

@ -0,0 +1,22 @@
#include "base_lib.h"
namespace app {
struct Derived : base_lib::Base {};
struct MultiLevel : middle_lib::Mid {};
struct DiamondDerived : diamond_lib::LeftBranch, diamond_lib::RightBranch {};
void run_single() {
Derived d;
log(d);
}
void run_multi() {
MultiLevel m;
trace(m);
}
void run_diamond() {
DiamondDerived d;
ping(d);
}
}

View file

@ -0,0 +1,20 @@
#pragma once
namespace base_lib {
struct Base {};
void log(Base);
struct Root {};
void trace(Root);
}
namespace middle_lib {
struct Mid : base_lib::Root {};
}
namespace diamond_lib {
struct DiamondBase {};
struct LeftBranch : DiamondBase {};
struct RightBranch : DiamondBase {};
void ping(DiamondBase);
}

View file

@ -2127,6 +2127,92 @@ describe('C++ ADL — basic associated-namespace closure', () => {
});
});
describe('C++ ADL — base-class associated namespaces', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-adl-base-associated-namespaces'),
() => {},
);
}, 60000);
it('resolves log(d) to base_lib::log via ADL when Derived inherits from base_lib::Base', () => {
const calls = getRelationships(result, 'CALLS');
const logCalls = calls.filter((c) => c.source === 'run_single' && c.target === 'log');
expect(logCalls.length).toBe(1);
expect(logCalls[0].targetFilePath).toContain('base_lib.h');
const targetNode = result.graph.getNode(logCalls[0].rel.targetId);
expect(logCalls[0].rel.targetId).toBe('Function:base_lib.h:log');
expect(targetNode?.properties.parameterTypes).toEqual(['Base']);
});
it('resolves trace(m) via full MRO walk when MultiLevel inherits via middle_lib::Mid -> base_lib::Root', () => {
const calls = getRelationships(result, 'CALLS');
const traceCalls = calls.filter((c) => c.source === 'run_multi' && c.target === 'trace');
expect(traceCalls.length).toBe(1);
expect(traceCalls[0].targetFilePath).toContain('base_lib.h');
const targetNode = result.graph.getNode(traceCalls[0].rel.targetId);
expect(traceCalls[0].rel.targetId).toBe('Function:base_lib.h:trace');
expect(targetNode?.properties.parameterTypes).toEqual(['Root']);
});
it('diamond inheritance contributes base namespace once (no duplicate/crash)', () => {
const calls = getRelationships(result, 'CALLS');
const pingCalls = calls.filter((c) => c.source === 'run_diamond' && c.target === 'ping');
expect(pingCalls.length).toBe(1);
expect(pingCalls[0].targetFilePath).toContain('base_lib.h');
const targetNode = result.graph.getNode(pingCalls[0].rel.targetId);
expect(pingCalls[0].rel.targetId).toBe('Function:base_lib.h:ping');
expect(targetNode?.properties.parameterTypes).toEqual(['DiamondBase']);
});
});
describe('C++ ADL — base-class namespace MRO with simple-name class collisions', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-adl-base-associated-namespaces-collision'),
() => {},
);
}, 60000);
it('does NOT emit CALLS for collide(t) when class-name lookup is ambiguous', () => {
const calls = getRelationships(result, 'CALLS');
const collideCalls = calls.filter((c) => c.source === 'run' && c.target === 'collide');
expect(collideCalls.length).toBe(0);
});
});
describe('C++ ADL — base-class namespace mapping skips anonymous/unresolved bases', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-adl-base-associated-namespaces-negative'),
() => {},
);
}, 60000);
it('hidden_probe(d) still resolves via ordinary lookup when declaration is visible', () => {
const calls = getRelationships(result, 'CALLS');
const hiddenProbeCalls = calls.filter(
(c) => c.source === 'run_hidden' && c.target === 'hidden_probe',
);
expect(hiddenProbeCalls.length).toBe(1);
expect(hiddenProbeCalls[0].targetFilePath).toContain('base_lib.h');
});
it('unresolved_probe(d) emits zero CALLS when base class cannot be resolved', () => {
const calls = getRelationships(result, 'CALLS');
const unresolvedProbeCalls = calls.filter(
(c) => c.source === 'run_missing' && c.target === 'unresolved_probe',
);
expect(unresolvedProbeCalls.length).toBe(0);
});
});
describe('C++ ADL — parenthesized name suppresses ADL', () => {
let result: PipelineResult;

View file

@ -122,12 +122,6 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
// (PR #1520 review follow-up plan 2026-05-13-001 U3); backporting
// is out of scope.
'Derived<T>::g() -> f() does NOT bind to Base<T>::f (dependent base)',
// The legacy DAG path has no V1/V2 ADL boundary — pointer-typed
// arguments resolve via the workspace-wide simple-name walk. The
// scope-resolver V1 ADL pass excludes pointer args (closure rules
// deferred to V2) per plan 2026-05-13-001 U2 / R4. Scope-resolver-
// only correctness win; backporting is out of scope.
'record(p) where p is audit::Event* emits zero CALLS — V1 ADL excludes pointer args',
// The legacy DAG path has no ADL_AMBIGUOUS suppression sentinel.
// When ADL surfaces multiple overloads that collide after C++
// int/long normalization, legacy picks the first match arbitrarily.

View file

@ -7,6 +7,11 @@ import {
const csharpNamespaceRootImportTest =
'emits the using-import edge App/Program.cs -> Models/User.cs through the scope-resolution path';
const cppBaseNamespaceAdlTests = [
'resolves log(d) to base_lib::log via ADL when Derived inherits from base_lib::Base',
'resolves trace(m) via full MRO walk when MultiLevel inherits via middle_lib::Mid -> base_lib::Root',
'diamond inheritance contributes base namespace once (no duplicate/crash)',
] as const;
describe('resolver parity expected legacy failures', () => {
it('uses the same env var convention as the parity workflow', () => {
@ -44,4 +49,14 @@ describe('resolver parity expected legacy failures', () => {
),
).toBe(false);
});
it('does not mark cpp base-namespace ADL coverage as expected failures in legacy parity', () => {
for (const testName of cppBaseNamespaceAdlTests) {
expect(
isLegacyResolverParityExpectedFailure('cpp', testName, {
REGISTRY_PRIMARY_CPP: '0',
}),
).toBe(false);
}
});
});